@narumitw/pi-chrome-devtools 0.53.0 → 0.53.2

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 (32) hide show
  1. package/README.md +88 -230
  2. package/dist/chunks/{browser-settings-menu-N5O65I7S.js → browser-settings-menu-IMB6HXSU.js} +5 -3
  3. package/dist/chunks/browser-settings-menu-IMB6HXSU.js.map +7 -0
  4. package/dist/chunks/{chunk-RTECHAEZ.js → chunk-GTTYS5GT.js} +20 -9
  5. package/dist/chunks/chunk-GTTYS5GT.js.map +7 -0
  6. package/dist/chunks/{chunk-B7B63PWB.js → chunk-LLXOCRNF.js} +50 -30
  7. package/dist/chunks/chunk-LLXOCRNF.js.map +7 -0
  8. package/dist/chunks/{chunk-GVBXT3FB.js → chunk-SRT3UUIP.js} +117 -73
  9. package/dist/chunks/chunk-SRT3UUIP.js.map +7 -0
  10. package/dist/chunks/{menu-UBHW6UP2.js → menu-UJETYMSB.js} +4 -4
  11. package/dist/chunks/{tools-V7OR5KJ3.js → tools-6P7EUX37.js} +49 -30
  12. package/dist/chunks/tools-6P7EUX37.js.map +7 -0
  13. package/dist/index.ts +21 -18
  14. package/dist/index.ts.map +2 -2
  15. package/docs/browser-setup.md +147 -0
  16. package/package.json +7 -7
  17. package/src/browser-manager.ts +138 -72
  18. package/src/browser-settings-menu.ts +2 -1
  19. package/src/cdp-client.ts +18 -7
  20. package/src/chrome-devtools.ts +8 -1
  21. package/src/display.ts +38 -0
  22. package/src/render.ts +6 -7
  23. package/src/tool-selector.ts +21 -32
  24. package/src/tools.ts +5 -5
  25. package/src/webmcp/policy.ts +14 -7
  26. package/src/webmcp/protocol.ts +33 -21
  27. package/dist/chunks/browser-settings-menu-N5O65I7S.js.map +0 -7
  28. package/dist/chunks/chunk-B7B63PWB.js.map +0 -7
  29. package/dist/chunks/chunk-GVBXT3FB.js.map +0 -7
  30. package/dist/chunks/chunk-RTECHAEZ.js.map +0 -7
  31. package/dist/chunks/tools-V7OR5KJ3.js.map +0 -7
  32. /package/dist/chunks/{menu-UBHW6UP2.js.map → menu-UJETYMSB.js.map} +0 -0
@@ -19,6 +19,7 @@ import {
19
19
  type ManagedBrowser,
20
20
  state,
21
21
  } from "./runtime.js";
22
+ import type { EffectiveBrowserSettings } from "./settings.js";
22
23
 
23
24
  // Endpoint attachment, managed launch, dynamic-port publication, and shutdown intentionally remain
24
25
  // together because they form one ownership state machine behind the testable operations boundary.
@@ -54,6 +55,7 @@ const DEFAULT_BROWSER_MANAGER_OPERATIONS: BrowserManagerOperations = {
54
55
  let browserManagerOperations = DEFAULT_BROWSER_MANAGER_OPERATIONS;
55
56
 
56
57
  interface ManagedBrowserScope {
58
+ browser: EffectiveBrowserSettings;
57
59
  controller: AbortController;
58
60
  generation: number;
59
61
  launchPromise?: Promise<void>;
@@ -64,13 +66,36 @@ interface ManagedBrowserScope {
64
66
 
65
67
  const managedBrowserScopes = new WeakMap<object, ManagedBrowserScope>();
66
68
 
69
+ function cloneBrowserSettings(browser: EffectiveBrowserSettings): EffectiveBrowserSettings {
70
+ return { ...browser, extensionPaths: [...browser.extensionPaths] };
71
+ }
72
+
73
+ function runtimeBrowserSettings(): EffectiveBrowserSettings {
74
+ return {
75
+ endpoint: `http://${formatHostForUrl(state.host)}:${state.configuredPort}`,
76
+ host: state.host,
77
+ port: state.configuredPort,
78
+ hostConfigured: state.hostConfigured,
79
+ portConfigured: state.portConfigured,
80
+ autoLaunchEnabled: state.autoLaunchEnabled,
81
+ ...(state.browserExecutable ? { executablePath: state.browserExecutable } : {}),
82
+ extensionPaths: [...state.extensionPaths],
83
+ endpointSource: state.endpointSource,
84
+ autoLaunchSource: state.autoLaunchSource,
85
+ executablePathSource: state.browserExecutableSource,
86
+ extensionPathsSource: state.extensionPathsSource,
87
+ };
88
+ }
89
+
67
90
  function managedBrowserScope(owner: object) {
68
91
  const existing = managedBrowserScopes.get(owner);
69
92
  if (existing) return existing;
93
+ const browser = runtimeBrowserSettings();
70
94
  const created: ManagedBrowserScope = {
95
+ browser,
71
96
  controller: new AbortController(),
72
97
  generation: 0,
73
- port: state.configuredPort,
98
+ port: browser.port,
74
99
  };
75
100
  managedBrowserScopes.set(owner, created);
76
101
  return created;
@@ -81,10 +106,27 @@ export function startManagedBrowserSession(owner: object) {
81
106
  scope.controller.abort(new DOMException("Chrome DevTools session replaced", "AbortError"));
82
107
  scope.controller = new AbortController();
83
108
  scope.generation += 1;
84
- scope.port = state.configuredPort;
85
109
  scope.lastLaunchAttempt = undefined;
86
110
  }
87
111
 
112
+ export function syncManagedBrowserSettings(owner: object, browser: EffectiveBrowserSettings) {
113
+ const scope = managedBrowserScope(owner);
114
+ scope.browser = cloneBrowserSettings(browser);
115
+ scope.port = browser.port;
116
+ }
117
+
118
+ export function browserSettingsForOwner(owner?: object) {
119
+ return owner ? managedBrowserScope(owner).browser : runtimeBrowserSettings();
120
+ }
121
+
122
+ export function managedBrowserExtensionPaths(owner?: object) {
123
+ return browserSettingsForOwner(owner).extensionPaths;
124
+ }
125
+
126
+ export function managedBrowserExtensionPathsSource(owner?: object) {
127
+ return browserSettingsForOwner(owner).extensionPathsSource;
128
+ }
129
+
88
130
  export function managedBrowserForOwner(owner?: object) {
89
131
  if (!owner) return state.managedBrowser;
90
132
  const scopedBrowser = managedBrowserScope(owner).managedBrowser;
@@ -110,6 +152,14 @@ function managedBrowserPort(owner?: object) {
110
152
  return owner ? managedBrowserScope(owner).port : state.port;
111
153
  }
112
154
 
155
+ function managedBrowserConfiguredPort(owner?: object) {
156
+ return browserSettingsForOwner(owner).port;
157
+ }
158
+
159
+ function managedBrowserPortConfigured(owner?: object) {
160
+ return browserSettingsForOwner(owner).portConfigured;
161
+ }
162
+
113
163
  function setManagedBrowserPort(owner: object | undefined, port: number) {
114
164
  if (owner) managedBrowserScope(owner).port = port;
115
165
  else state.port = port;
@@ -153,12 +203,12 @@ export async function ensureDevToolsEndpoint(
153
203
  owner?: object,
154
204
  ) {
155
205
  callerSignal?.throwIfAborted();
156
- if (extensionsConfigured()) {
157
- validateExtensionLaunchMode();
206
+ if (extensionsConfigured(owner)) {
207
+ validateExtensionLaunchMode(owner);
158
208
  await ensureManagedBrowserLaunched(waitMs, callerSignal, owner);
159
209
  return;
160
210
  }
161
- if (canAutoLaunchBrowser()) {
211
+ if (canAutoLaunchBrowser(owner)) {
162
212
  try {
163
213
  await withEndpointRetry(
164
214
  () => fetchDevToolsJson<unknown>("/json/version", { signal: callerSignal }, owner),
@@ -167,7 +217,7 @@ export async function ensureDevToolsEndpoint(
167
217
  );
168
218
  return;
169
219
  } catch (error) {
170
- if (shouldAutoLaunchAfterEndpointError(error)) {
220
+ if (shouldAutoLaunchAfterEndpointError(error, owner)) {
171
221
  await ensureManagedBrowserLaunched(waitMs, callerSignal, owner);
172
222
  return;
173
223
  }
@@ -183,18 +233,19 @@ export async function ensureDevToolsEndpoint(
183
233
  }
184
234
  }
185
235
 
186
- function validateExtensionLaunchMode() {
187
- if (!isLocalDevToolsHost(state.host)) {
236
+ function validateExtensionLaunchMode(owner?: object) {
237
+ const browser = browserSettingsForOwner(owner);
238
+ if (!isLocalDevToolsHost(browser.host)) {
188
239
  throw new DevToolsEndpointError(
189
240
  "Unpacked extensions require a local extension-owned managed browser; remote CDP endpoints cannot be modified.",
190
241
  );
191
242
  }
192
- if (!state.autoLaunchEnabled) {
243
+ if (!browser.autoLaunchEnabled) {
193
244
  throw new DevToolsEndpointError(
194
245
  "Auto-launch is required for unpacked extensions so Pi can create an isolated managed browser.",
195
246
  );
196
247
  }
197
- if (!state.browserExecutable) {
248
+ if (!browser.executablePath) {
198
249
  throw new DevToolsEndpointError(
199
250
  "Unpacked extensions require browser.executablePath for Chrome for Testing or Chromium in pi-chrome-devtools.json.",
200
251
  );
@@ -258,9 +309,9 @@ async function prepareManagedBrowserLaunch(
258
309
  await shutdownManagedBrowser(existingBrowser, { awaitLaunch: false, owner });
259
310
  throwIfBrowserLaunchCancelled(generation, signal, owner);
260
311
  }
261
- if (state.portConfigured) {
312
+ if (managedBrowserPortConfigured(owner)) {
262
313
  const available = await browserManagerOperations.isPortAvailable(
263
- state.host,
314
+ browserSettingsForOwner(owner).host,
264
315
  managedBrowserPort(owner),
265
316
  );
266
317
  throwIfBrowserLaunchCancelled(generation, signal, owner);
@@ -280,7 +331,7 @@ async function launchManagedBrowser(
280
331
  owner?: object,
281
332
  ) {
282
333
  throwIfBrowserLaunchCancelled(generation, signal, owner);
283
- const candidateDefinitions = browserCandidateDefinitions();
334
+ const candidateDefinitions = browserCandidateDefinitions(owner);
284
335
  const candidates = await resolveBrowserCandidates(
285
336
  candidateDefinitions,
286
337
  generation,
@@ -290,25 +341,25 @@ async function launchManagedBrowser(
290
341
  throwIfBrowserLaunchCancelled(generation, signal, owner);
291
342
  setLastLaunchAttempt(owner, {
292
343
  candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
293
- mode: state.portConfigured ? "explicit-port" : "dynamic-port",
344
+ mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "dynamic-port",
294
345
  });
295
346
 
296
347
  if (candidates.length === 0) {
297
- throw new DevToolsEndpointError(noBrowserCandidateMessage(candidateDefinitions));
348
+ throw new DevToolsEndpointError(noBrowserCandidateMessage(candidateDefinitions, owner));
298
349
  }
299
350
 
300
351
  let lastError: unknown;
301
352
  for (const candidate of candidates) {
302
353
  throwIfBrowserLaunchCancelled(generation, signal, owner);
303
354
  try {
304
- if (extensionsConfigured())
355
+ if (extensionsConfigured(owner))
305
356
  await requireSupportedExtensionBrowser(candidate, generation, signal, owner);
306
357
  await launchBrowserCandidate(candidate, waitMs, generation, signal, owner);
307
358
  throwIfBrowserLaunchCancelled(generation, signal, owner);
308
359
  setLastLaunchAttempt(owner, {
309
360
  ...lastLaunchAttempt(owner),
310
361
  candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
311
- mode: state.portConfigured ? "explicit-port" : "dynamic-port",
362
+ mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "dynamic-port",
312
363
  selectedCandidate: formatBrowserCandidate(candidate),
313
364
  userDataDir: managedBrowserForOwner(owner)?.userDataDir,
314
365
  });
@@ -320,7 +371,7 @@ async function launchManagedBrowser(
320
371
  setLastLaunchAttempt(owner, {
321
372
  ...(lastLaunchAttempt(owner) ?? {
322
373
  candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
323
- mode: state.portConfigured ? "explicit-port" : "dynamic-port",
374
+ mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "dynamic-port",
324
375
  }),
325
376
  lastError: formatError(cancellation),
326
377
  });
@@ -330,20 +381,20 @@ async function launchManagedBrowser(
330
381
  setLastLaunchAttempt(owner, {
331
382
  ...(lastLaunchAttempt(owner) ?? {
332
383
  candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
333
- mode: state.portConfigured ? "explicit-port" : "dynamic-port",
384
+ mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "dynamic-port",
334
385
  }),
335
386
  lastError: formatError(error),
336
387
  });
337
388
  }
338
389
  }
339
390
 
340
- if (lastError instanceof DevToolsEndpointError && extensionsConfigured()) throw lastError;
391
+ if (lastError instanceof DevToolsEndpointError && extensionsConfigured(owner)) throw lastError;
341
392
  throw new DevToolsEndpointError(
342
393
  [
343
394
  "Unable to auto-launch a Chromium-family browser for Chrome DevTools.",
344
395
  `Tried: ${candidates.map(formatBrowserCandidate).join(", ")}`,
345
396
  lastError ? `Last error: ${formatError(lastError)}` : undefined,
346
- launchHint(),
397
+ launchHint(owner),
347
398
  endpointConfigHint(),
348
399
  ]
349
400
  .filter(Boolean)
@@ -403,11 +454,13 @@ async function launchBrowserCandidate(
403
454
  let managedBrowser: ManagedBrowser | undefined;
404
455
  try {
405
456
  throwIfBrowserLaunchCancelled(generation, signal, owner);
406
- const portArgument = state.portConfigured ? String(managedBrowserPort(owner)) : "0";
457
+ const portArgument = managedBrowserPortConfigured(owner)
458
+ ? String(managedBrowserPort(owner))
459
+ : "0";
407
460
  const args = buildManagedBrowserLaunchArguments(
408
461
  userDataDir,
409
462
  portArgument,
410
- state.extensionPaths,
463
+ managedBrowserExtensionPaths(owner),
411
464
  );
412
465
  const child = browserManagerOperations.spawn(candidate.resolvedExecutable, args, {
413
466
  shell: false,
@@ -430,14 +483,17 @@ async function launchBrowserCandidate(
430
483
  }
431
484
  launchedBrowser.exited = true;
432
485
  launchedBrowser.ready = false;
433
- if (!state.portConfigured && launchedBrowser.port === managedBrowserPort(owner)) {
434
- setManagedBrowserPort(owner, state.configuredPort);
486
+ if (
487
+ !managedBrowserPortConfigured(owner) &&
488
+ launchedBrowser.port === managedBrowserPort(owner)
489
+ ) {
490
+ setManagedBrowserPort(owner, managedBrowserConfiguredPort(owner));
435
491
  }
436
492
  });
437
493
 
438
494
  await waitForBrowserSpawn(child);
439
495
  throwIfBrowserLaunchCancelled(generation, signal, owner);
440
- if (state.portConfigured) {
496
+ if (managedBrowserPortConfigured(owner)) {
441
497
  launchedBrowser.port = managedBrowserPort(owner);
442
498
  } else {
443
499
  launchedBrowser.port = await readManagedBrowserPort(
@@ -533,7 +589,7 @@ async function readManagedBrowserPort(
533
589
  [
534
590
  "Timed out waiting for auto-launched browser DevToolsActivePort.",
535
591
  `Expected file: ${activePortFile}`,
536
- launchHint(),
592
+ launchHint(owner),
537
593
  ].join("\n"),
538
594
  );
539
595
  }
@@ -565,7 +621,7 @@ async function waitForDevToolsEndpoint(
565
621
  throw new DevToolsEndpointError(
566
622
  [
567
623
  `Timed out waiting for auto-launched browser at ${devToolsEndpoint(owner)}.`,
568
- launchHint(),
624
+ launchHint(owner),
569
625
  ].join("\n"),
570
626
  );
571
627
  }
@@ -636,8 +692,8 @@ async function cleanupManagedBrowser(managedBrowser: ManagedBrowser, owner?: obj
636
692
  await browserManagerOperations
637
693
  .rm(managedBrowser.userDataDir, { recursive: true, force: true })
638
694
  .catch(() => undefined);
639
- if (!state.portConfigured && managedBrowser.port === managedBrowserPort(owner)) {
640
- setManagedBrowserPort(owner, state.configuredPort);
695
+ if (!managedBrowserPortConfigured(owner) && managedBrowser.port === managedBrowserPort(owner)) {
696
+ setManagedBrowserPort(owner, managedBrowserConfiguredPort(owner));
641
697
  }
642
698
  }
643
699
 
@@ -743,28 +799,29 @@ function isLaunchableEndpointError(error: unknown) {
743
799
  return error instanceof DevToolsEndpointError && error.launchable;
744
800
  }
745
801
 
746
- function shouldAutoLaunchAfterEndpointError(error: unknown) {
747
- if (!canAutoLaunchBrowser()) return false;
802
+ function shouldAutoLaunchAfterEndpointError(error: unknown, owner?: object) {
803
+ if (!canAutoLaunchBrowser(owner)) return false;
748
804
  if (isLaunchableEndpointError(error)) return true;
749
805
 
750
806
  // After the attach-first attempt (including its retry window, when applicable) fails, treat any
751
807
  // DevTools endpoint error on an unpinned port as a conflict we can avoid with a dynamic port.
752
- return !state.portConfigured && error instanceof DevToolsEndpointError;
808
+ return !managedBrowserPortConfigured(owner) && error instanceof DevToolsEndpointError;
753
809
  }
754
810
 
755
- function canAutoLaunchBrowser() {
756
- return state.autoLaunchEnabled && isLocalDevToolsHost(state.host);
811
+ function canAutoLaunchBrowser(owner?: object) {
812
+ const browser = browserSettingsForOwner(owner);
813
+ return browser.autoLaunchEnabled && isLocalDevToolsHost(browser.host);
757
814
  }
758
815
 
759
- function extensionsConfigured() {
760
- return state.extensionPaths.length > 0;
816
+ function extensionsConfigured(owner?: object) {
817
+ return managedBrowserExtensionPaths(owner).length > 0;
761
818
  }
762
819
 
763
820
  function endpointConnectionErrorMessage(error: unknown, owner?: object) {
764
821
  const reason = isTimeoutError(error) ? "request timed out" : "connection failed";
765
822
  return [
766
823
  `Cannot connect to Chrome DevTools endpoint at ${devToolsEndpoint(owner)} (${reason}).`,
767
- launchHint(),
824
+ launchHint(owner),
768
825
  endpointConfigHint(),
769
826
  ].join("\n");
770
827
  }
@@ -774,7 +831,7 @@ function isTimeoutError(error: unknown) {
774
831
  }
775
832
 
776
833
  export function devToolsEndpoint(owner?: object) {
777
- return `http://${formatHostForUrl(state.host)}:${managedBrowserPort(owner)}`;
834
+ return `http://${formatHostForUrl(browserSettingsForOwner(owner).host)}:${managedBrowserPort(owner)}`;
778
835
  }
779
836
 
780
837
  export function formatHostForUrl(host: string) {
@@ -782,8 +839,8 @@ export function formatHostForUrl(host: string) {
782
839
  return host.includes(":") ? `[${host}]` : host;
783
840
  }
784
841
 
785
- export function endpointSourceLabel() {
786
- return state.endpointSource;
842
+ export function endpointSourceLabel(owner?: object) {
843
+ return browserSettingsForOwner(owner).endpointSource;
787
844
  }
788
845
 
789
846
  export type BrowserLifecycleState = "starting" | "running" | "exited" | "failed" | "unobserved";
@@ -797,27 +854,28 @@ export function browserLifecycleState(owner?: object): BrowserLifecycleState {
797
854
  }
798
855
 
799
856
  export function launchModeLabel(owner?: object) {
857
+ const browser = browserSettingsForOwner(owner);
800
858
  const managedBrowser = managedBrowserForOwner(owner);
801
- if (extensionsConfigured()) {
802
- if (!isLocalDevToolsHost(state.host)) return "invalid; extensions require a local endpoint";
803
- if (!state.autoLaunchEnabled) return "invalid; extensions require auto-launch";
859
+ if (extensionsConfigured(owner)) {
860
+ if (!isLocalDevToolsHost(browser.host)) return "invalid; extensions require a local endpoint";
861
+ if (!browser.autoLaunchEnabled) return "invalid; extensions require auto-launch";
804
862
  if (managedBrowser && !managedBrowser.exited) {
805
- return state.portConfigured
863
+ return managedBrowserPortConfigured(owner)
806
864
  ? "extension-owned browser on explicit port"
807
865
  : "extension-owned browser on dynamic port";
808
866
  }
809
- return state.portConfigured
867
+ return managedBrowserPortConfigured(owner)
810
868
  ? "force extension-owned launch on explicit port"
811
869
  : "force extension-owned launch on dynamic port";
812
870
  }
813
- if (!isLocalDevToolsHost(state.host)) return "manual remote endpoint";
814
- if (!state.autoLaunchEnabled) return "manual; auto-launch disabled";
871
+ if (!isLocalDevToolsHost(browser.host)) return "manual remote endpoint";
872
+ if (!browser.autoLaunchEnabled) return "manual; auto-launch disabled";
815
873
  if (managedBrowser && !managedBrowser.exited) {
816
- return state.portConfigured
874
+ return managedBrowserPortConfigured(owner)
817
875
  ? "auto-launched on explicit port"
818
876
  : "auto-launched on dynamic port";
819
877
  }
820
- return state.portConfigured
878
+ return managedBrowserPortConfigured(owner)
821
879
  ? "attach first; auto-launch explicit port"
822
880
  : "attach first; auto-launch dynamic port";
823
881
  }
@@ -837,34 +895,38 @@ export function launchAttemptLines(owner?: object) {
837
895
  return lines;
838
896
  }
839
897
 
840
- export function launchHint() {
841
- if (extensionsConfigured()) {
898
+ export function launchHint(owner?: object) {
899
+ const browser = browserSettingsForOwner(owner);
900
+ if (extensionsConfigured(owner)) {
842
901
  return "Configured unpacked extensions force an isolated extension-owned launch; existing CDP browsers are never reused, modified, or closed.";
843
902
  }
844
- if (!isLocalDevToolsHost(state.host)) {
845
- return `Remote/non-local endpoints are not auto-launched. Start a browser with CDP enabled at ${devToolsEndpoint()}.`;
903
+ if (!isLocalDevToolsHost(browser.host)) {
904
+ return `Remote/non-local endpoints are not auto-launched. Start a browser with CDP enabled at ${devToolsEndpoint(owner)}.`;
846
905
  }
847
- if (!state.autoLaunchEnabled) {
848
- return `Auto-launch is disabled. Start a browser manually: ${chromeLaunchCommand()}`;
906
+ if (!browser.autoLaunchEnabled) {
907
+ return `Auto-launch is disabled. Start a browser manually: ${chromeLaunchCommand(owner)}`;
849
908
  }
850
- const managedMode = state.portConfigured ? `port ${state.port}` : "a dynamic DevTools port";
851
- return `If no endpoint is available, Pi will auto-launch a Chromium-family browser with ${managedMode} and an isolated temp profile. Manual command: ${chromeLaunchCommand()}`;
909
+ const managedMode = managedBrowserPortConfigured(owner)
910
+ ? `port ${managedBrowserPort(owner)}`
911
+ : "a dynamic DevTools port";
912
+ return `If no endpoint is available, Pi will auto-launch a Chromium-family browser with ${managedMode} and an isolated temp profile. Manual command: ${chromeLaunchCommand(owner)}`;
852
913
  }
853
914
 
854
- export function browserCandidateHint() {
855
- if (extensionsConfigured()) {
915
+ export function browserCandidateHint(owner?: object) {
916
+ if (extensionsConfigured(owner)) {
856
917
  return "Extension browser requirement: configure Chrome for Testing or Chromium with browser.executablePath; branded Chrome is rejected because it may ignore unpacked-extension flags.";
857
918
  }
858
- return `Browser candidates: ${browserCandidateDefinitions()
919
+ return `Browser candidates: ${browserCandidateDefinitions(owner)
859
920
  .map((candidate) => candidate.label)
860
921
  .join(", ")}`;
861
922
  }
862
923
 
863
- export function chromeLaunchCommand() {
864
- const executable = state.browserExecutable ?? defaultManualBrowserExecutable();
924
+ export function chromeLaunchCommand(owner?: object) {
925
+ const executable =
926
+ browserSettingsForOwner(owner).executablePath ?? defaultManualBrowserExecutable();
865
927
  const dataDir =
866
928
  process.platform === "win32" ? "%TEMP%\\pi-chrome-devtools" : "/tmp/pi-chrome-devtools";
867
- return `${quoteCommandPart(executable)} --remote-debugging-port=${state.port} --user-data-dir=${dataDir}`;
929
+ return `${quoteCommandPart(executable)} --remote-debugging-port=${managedBrowserPort(owner)} --user-data-dir=${dataDir}`;
868
930
  }
869
931
 
870
932
  function defaultManualBrowserExecutable() {
@@ -888,22 +950,23 @@ export function isLocalDevToolsHost(host: string) {
888
950
  return ["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"].includes(normalizedHost);
889
951
  }
890
952
 
891
- function browserCandidateDefinitions(): BrowserCandidateDefinition[] {
892
- const explicitCandidate = explicitBrowserCandidateDefinition();
953
+ function browserCandidateDefinitions(owner?: object): BrowserCandidateDefinition[] {
954
+ const explicitCandidate = explicitBrowserCandidateDefinition(owner);
893
955
  if (explicitCandidate.length > 0) return explicitCandidate;
894
956
 
895
957
  return uniqueBrowserCandidates(platformBrowserCandidateDefinitions());
896
958
  }
897
959
 
898
- function explicitBrowserCandidateDefinition(): BrowserCandidateDefinition[] {
899
- if (!state.browserExecutable) return [];
960
+ function explicitBrowserCandidateDefinition(owner?: object): BrowserCandidateDefinition[] {
961
+ const browser = browserSettingsForOwner(owner);
962
+ if (!browser.executablePath) return [];
900
963
  return [
901
964
  {
902
965
  label:
903
- state.browserExecutableSource === "environment"
966
+ browser.executablePathSource === "environment"
904
967
  ? "PI_CHROME_DEVTOOLS_BROWSER"
905
968
  : "browser.executablePath",
906
- executable: state.browserExecutable,
969
+ executable: browser.executablePath,
907
970
  source: "env",
908
971
  },
909
972
  ];
@@ -1070,8 +1133,11 @@ function formatBrowserCandidateDefinition(candidate: BrowserCandidateDefinition)
1070
1133
  return `${candidate.label} (${candidate.executable})`;
1071
1134
  }
1072
1135
 
1073
- function noBrowserCandidateMessage(candidateDefinitions: BrowserCandidateDefinition[]) {
1074
- if (extensionsConfigured()) {
1136
+ function noBrowserCandidateMessage(
1137
+ candidateDefinitions: BrowserCandidateDefinition[],
1138
+ owner?: object,
1139
+ ) {
1140
+ if (extensionsConfigured(owner)) {
1075
1141
  return [
1076
1142
  "Cannot launch unpacked extensions because the configured browser executable was not found or is not executable.",
1077
1143
  `Tried: ${candidateDefinitions.map(formatBrowserCandidateDefinition).join(", ")}`,
@@ -1,7 +1,7 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
3
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
4
- import { shutdownManagedBrowser } from "./browser-manager.js";
4
+ import { shutdownManagedBrowser, syncManagedBrowserSettings } from "./browser-manager.js";
5
5
  import {
6
6
  applyAvailableChromeDevtoolsTools,
7
7
  configuredChromeDevtoolsTools,
@@ -276,6 +276,7 @@ async function saveAndApplyBrowserPatch(
276
276
  const loaded = await loadSettings({ cwd: ctx.cwd, projectTrusted });
277
277
  if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
278
278
  applyRuntimeBrowserSettings(loaded.effectiveBrowser, loaded.paths, projectTrusted);
279
+ syncManagedBrowserSettings(ctx.sessionManager, loaded.effectiveBrowser);
279
280
  state.settingsNotice = loaded.notice;
280
281
  return loaded;
281
282
  } catch (error) {
package/src/cdp-client.ts CHANGED
@@ -51,6 +51,7 @@ export interface CdpConnectOptions extends CdpOperationOptions {
51
51
 
52
52
  const MAX_BUFFERED_EVENTS_PER_METHOD = 32;
53
53
  const MAX_CDP_MESSAGE_BYTES = 8 * 1024 * 1024;
54
+ const activePageIds = new WeakMap<object, string>();
54
55
 
55
56
  interface PageOperationOptions {
56
57
  sessionOwner?: object;
@@ -84,13 +85,13 @@ export async function resolvePage(pageId?: string, options: PageOperationOptions
84
85
  const pages = await listPages(options);
85
86
  if (pageId) return requirePage(pageId, pages);
86
87
 
87
- const page = resolveDefaultPage(pages);
88
+ const page = resolveDefaultPage(pages, options.sessionOwner);
88
89
  if (!page) {
89
90
  throw new Error(
90
91
  [
91
92
  `No Chrome pages found at ${devToolsEndpoint(options.sessionOwner)}.`,
92
93
  "Use chrome_devtools_navigate with a URL to create a page, or open a Chrome tab manually.",
93
- launchHint(),
94
+ launchHint(options.sessionOwner),
94
95
  ].join("\n"),
95
96
  );
96
97
  }
@@ -105,22 +106,32 @@ export async function resolvePageForNavigation(
105
106
  const pages = await listPages(options);
106
107
  if (pageId) return { created: false, page: requirePage(pageId, pages) };
107
108
 
108
- const page = resolveDefaultPage(pages);
109
+ const page = resolveDefaultPage(pages, options.sessionOwner);
109
110
  if (page) return { created: false, page };
110
111
 
111
112
  return { created: true, page: await createPage("about:blank", options) };
112
113
  }
113
114
 
114
- function resolveDefaultPage(pages: DevToolsPage[]) {
115
- if (!state.activePageId) return pages[0];
115
+ function resolveDefaultPage(pages: DevToolsPage[], owner?: object) {
116
+ const activePageId = owner ? activePageIds.get(owner) : state.activePageId;
117
+ if (!activePageId) return pages[0];
116
118
 
117
- const activePage = pages.find((candidate) => candidate.id === state.activePageId);
119
+ const activePage = pages.find((candidate) => candidate.id === activePageId);
118
120
  if (activePage) return activePage;
119
121
 
120
- state.activePageId = undefined;
122
+ setActivePageId(owner, undefined);
121
123
  return pages[0];
122
124
  }
123
125
 
126
+ export function setActivePageId(owner: object | undefined, pageId: string | undefined) {
127
+ if (!owner) {
128
+ state.activePageId = pageId;
129
+ return;
130
+ }
131
+ if (pageId) activePageIds.set(owner, pageId);
132
+ else activePageIds.delete(owner);
133
+ }
134
+
124
135
  function requirePage(pageId: string, pages: DevToolsPage[]) {
125
136
  const page = pages.find((candidate) => candidate.id === pageId);
126
137
  if (page) return page;
@@ -1,5 +1,10 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { shutdownManagedBrowser, startManagedBrowserSession } 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,
@@ -98,12 +103,14 @@ export default function chromeDevtools(pi: ExtensionAPI) {
98
103
  });
99
104
  startManagedBrowserSession(ctx.sessionManager);
100
105
  if (generation !== state.sessionGeneration) return;
106
+ setActivePageId(ctx.sessionManager, undefined);
101
107
  state.activePageId = undefined;
102
108
  state.lastLaunchAttempt = undefined;
103
109
  const projectTrusted = ctx.isProjectTrusted();
104
110
  const settings = await loadSettings({ cwd: ctx.cwd, projectTrusted });
105
111
  if (generation !== state.sessionGeneration) return;
106
112
  applyRuntimeBrowserSettings(settings.effectiveBrowser, settings.paths, projectTrusted);
113
+ syncManagedBrowserSettings(ctx.sessionManager, settings.effectiveBrowser);
107
114
  applyRuntimeWebMcpSetting(settings.effectiveWebMcpEnabled, ctx.sessionManager);
108
115
  state.settingsNotice = settings.notice;
109
116
  for (const warning of settings.warnings) {
package/src/display.ts ADDED
@@ -0,0 +1,38 @@
1
+ const MAX_SANITIZER_INPUT_CODE_UNITS = 50_000;
2
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+
4
+ export function sanitizeChromeDevtoolsDisplay(value: string, maxCharacters = 50_000) {
5
+ const inputWasTruncated = value.length > MAX_SANITIZER_INPUT_CODE_UNITS;
6
+ const boundedInput = inputWasTruncated
7
+ ? truncateAtGraphemeBoundary(value, MAX_SANITIZER_INPUT_CODE_UNITS)
8
+ : value;
9
+ const normalizedLineEndings = boundedInput.replace(/\r\n/g, "\n");
10
+ // Neutralize controls without parsing untrusted terminal sequences; keep printable payload visible.
11
+ const sanitized = Array.from(normalizedLineEndings, (character) => {
12
+ const codePoint = character.codePointAt(0) ?? 0;
13
+ const unsafeControl =
14
+ (codePoint >= 0 && codePoint <= 8) ||
15
+ (codePoint >= 11 && codePoint <= 31) ||
16
+ (codePoint >= 127 && codePoint <= 159);
17
+ const unsafeBidi =
18
+ (codePoint >= 0x202a && codePoint <= 0x202e) || (codePoint >= 0x2066 && codePoint <= 0x2069);
19
+ const loneSurrogate = character.length === 1 && codePoint >= 0xd800 && codePoint <= 0xdfff;
20
+ return unsafeControl || unsafeBidi || loneSurrogate ? "�" : character;
21
+ }).join("");
22
+ const outputLimit = Math.min(maxCharacters, MAX_SANITIZER_INPUT_CODE_UNITS);
23
+ if (!inputWasTruncated && sanitized.length <= outputLimit) return sanitized;
24
+
25
+ return `${truncateAtGraphemeBoundary(sanitized, Math.max(0, outputLimit - 1))}…`;
26
+ }
27
+
28
+ function truncateAtGraphemeBoundary(value: string, maxCodeUnits: number) {
29
+ if (value.length <= maxCodeUnits) return value;
30
+ const boundedLookahead = value.slice(0, Math.max(0, maxCodeUnits) + 2);
31
+ let safeEnd = 0;
32
+ for (const { index, segment } of graphemeSegmenter.segment(boundedLookahead)) {
33
+ const segmentEnd = index + segment.length;
34
+ if (segmentEnd > maxCodeUnits) break;
35
+ safeEnd = segmentEnd;
36
+ }
37
+ return value.slice(0, safeEnd);
38
+ }
package/src/render.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { AgentToolResult, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { sanitizeChromeDevtoolsDisplay } from "./display.js";
2
4
 
3
5
  const STATUS_KEY = "chrome-devtools";
4
6
  interface StatusContext {
@@ -84,21 +86,18 @@ class PiTextComponent implements RenderComponent {
84
86
  }
85
87
 
86
88
  render(width: number) {
87
- if (!this.text.trim()) return [];
88
- return this.text
89
+ const sanitizedText = sanitizeChromeDevtoolsDisplay(this.text);
90
+ if (!sanitizedText.trim()) return [];
91
+ return sanitizedText
89
92
  .replace(/\t/g, " ")
90
93
  .split(/\r?\n/)
91
94
  .map((line) => {
92
- const truncatedLine = truncateLine(line, Math.max(1, width));
95
+ const truncatedLine = truncateToWidth(line, Math.max(0, width), "");
93
96
  return this.theme && this.color ? this.theme.fg(this.color, truncatedLine) : truncatedLine;
94
97
  });
95
98
  }
96
99
  }
97
100
 
98
- function truncateLine(line: string, maxWidth: number) {
99
- return Array.from(line).slice(0, maxWidth).join("");
100
- }
101
-
102
101
  export async function withStatus<T>(
103
102
  ctx: StatusContext,
104
103
  status: string,