pi-lean-dimension 0.1.0 → 0.2.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 (130) hide show
  1. package/README.md +96 -36
  2. package/node_modules/pi-lean-portal/AGENTS.md +146 -4
  3. package/node_modules/pi-lean-portal/README.md +112 -50
  4. package/node_modules/pi-lean-portal/__tests__/browser-data.test.ts +124 -0
  5. package/node_modules/pi-lean-portal/__tests__/browser-inspect.test.ts +192 -16
  6. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -3
  7. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +49 -35
  8. package/node_modules/pi-lean-portal/__tests__/chromium-py-persistence.test.ts +21 -195
  9. package/node_modules/pi-lean-portal/__tests__/chromium-py.test.ts +17 -81
  10. package/node_modules/pi-lean-portal/__tests__/chromium.test.ts +25 -0
  11. package/node_modules/pi-lean-portal/__tests__/contributed/invisible-py/invisible-py.test.ts +299 -0
  12. package/node_modules/pi-lean-portal/__tests__/cookie-persistence.test.ts +22 -182
  13. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +1 -1
  14. package/node_modules/pi-lean-portal/__tests__/firefox-py-persistence.test.ts +21 -184
  15. package/node_modules/pi-lean-portal/__tests__/firefox-py.test.ts +17 -101
  16. package/node_modules/pi-lean-portal/__tests__/firefox.test.ts +2 -18
  17. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  18. package/node_modules/pi-lean-portal/__tests__/helpers/create-py-backend-harness.ts +105 -0
  19. package/node_modules/pi-lean-portal/__tests__/helpers/load-plugin-config-from-file.ts +53 -0
  20. package/node_modules/pi-lean-portal/__tests__/helpers/mock-plugin.ts +11 -7
  21. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +4 -0
  22. package/node_modules/pi-lean-portal/__tests__/helpers/persistence-suite.ts +218 -0
  23. package/node_modules/pi-lean-portal/__tests__/helpers/plugin-contract.ts +198 -318
  24. package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +198 -0
  25. package/node_modules/pi-lean-portal/__tests__/helpers/test-server.ts +14 -0
  26. package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +150 -15
  27. package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +120 -18
  28. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +6 -67
  29. package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +236 -0
  30. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +401 -11
  31. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +4 -1
  32. package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +318 -0
  33. package/node_modules/pi-lean-portal/__tests__/session-manager.test.ts +50 -0
  34. package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +2 -2
  35. package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +1 -1
  36. package/node_modules/pi-lean-portal/backends/chromium/index.ts +5 -13
  37. package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
  38. package/node_modules/pi-lean-portal/backends/chromium-py/bridge.py +0 -2
  39. package/node_modules/pi-lean-portal/backends/firefox/index.ts +6 -5
  40. package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
  41. package/node_modules/pi-lean-portal/backends/firefox-py/bridge.py +7 -6
  42. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +241 -398
  43. package/node_modules/pi-lean-portal/backends/python-adapter.ts +182 -83
  44. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +1 -42
  45. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
  46. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-312.pyc +0 -0
  48. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-313.pyc +0 -0
  49. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-312.pyc +0 -0
  50. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-313.pyc +0 -0
  51. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
  52. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
  53. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-312.pyc +0 -0
  54. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-313.pyc +0 -0
  55. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-312.pyc +0 -0
  56. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
  57. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  58. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  59. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  60. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  61. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/accessibility.py +12 -147
  62. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bot_detection.py +12 -37
  63. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +249 -322
  64. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/browser_data.py +92 -0
  65. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/patch_playwright.py +321 -0
  66. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +511 -299
  67. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  68. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_accessibility.cpython-313-pytest-9.1.0.pyc → test_accessibility.cpython-313-pytest-9.1.1.pyc} +0 -0
  69. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
  70. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
  72. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_chromium_py_bridge.cpython-313-pytest-9.1.0.pyc → test_chromium_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_firefox_py_bridge.cpython-313-pytest-9.1.0.pyc → test_firefox_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_transport.cpython-313-pytest-9.1.0.pyc → test_transport.cpython-313-pytest-9.1.1.pyc} +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/conftest.py +95 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/test_accessibility.py +8 -132
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_bot_detection.py +8 -147
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_browser_data.py +131 -0
  84. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +768 -0
  85. package/node_modules/pi-lean-portal/backends/python-base/tests/test_py_bridges.py +198 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +33 -69
  87. package/node_modules/pi-lean-portal/contributed/CHOOSING.md +126 -0
  88. package/node_modules/pi-lean-portal/contributed/README.md +304 -0
  89. package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +216 -0
  90. package/node_modules/pi-lean-portal/contributed/invisible-py/__pycache__/bridge.cpython-313.pyc +0 -0
  91. package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +434 -0
  92. package/node_modules/pi-lean-portal/core/fetch-backend.ts +0 -5
  93. package/node_modules/pi-lean-portal/core/plugin-api.ts +6 -33
  94. package/node_modules/pi-lean-portal/core/plugin-config.ts +75 -58
  95. package/node_modules/pi-lean-portal/core/plugin-registry.ts +11 -49
  96. package/node_modules/pi-lean-portal/core/router.ts +59 -98
  97. package/node_modules/pi-lean-portal/core/shared/accessibility-tree.ts +10 -143
  98. package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +31 -77
  99. package/node_modules/pi-lean-portal/core/shared/browser-data.json +183 -0
  100. package/node_modules/pi-lean-portal/core/shared/browser-data.ts +50 -0
  101. package/node_modules/pi-lean-portal/core/shared/browser-events.ts +6 -6
  102. package/node_modules/pi-lean-portal/core/shared/dom-extractor.ts +97 -36
  103. package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +12 -15
  104. package/node_modules/pi-lean-portal/core/shared/paths.ts +3 -0
  105. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +7 -21
  106. package/node_modules/pi-lean-portal/{verify-ship-manifest.ts → core/shared/ship-manifest.ts} +34 -9
  107. package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +7 -6
  108. package/node_modules/pi-lean-portal/core/shared/storage-state.ts +40 -9
  109. package/node_modules/pi-lean-portal/index.ts +42 -7
  110. package/node_modules/pi-lean-portal/package.json +8 -3
  111. package/node_modules/pi-lean-portal/ship-manifest.test.ts +8 -3
  112. package/node_modules/pi-lean-portal/tools/browser-inspect.ts +2 -6
  113. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +7 -5
  114. package/node_modules/pi-lean-portal/tools/browser-snapshot.ts +2 -5
  115. package/node_modules/pi-lean-portal/tools/utils.ts +22 -4
  116. package/node_modules/pi-lean-portal/tools/web-fetch.ts +3 -4
  117. package/node_modules/pi-lean-search/README.md +6 -2
  118. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +11 -0
  119. package/node_modules/pi-lean-search/index.ts +4 -21
  120. package/node_modules/pi-lean-search/package.json +2 -2
  121. package/node_modules/pi-lean-search/verify-ship-manifest.ts +6 -92
  122. package/node_modules/pi-lean-search/web-search-tool.ts +19 -13
  123. package/package.json +4 -4
  124. package/node_modules/pi-lean-portal/__tests__/helpers/reddit-fixture.ts +0 -264
  125. package/node_modules/pi-lean-portal/__tests__/helpers/toggle-test-utils.ts +0 -31
  126. package/node_modules/pi-lean-portal/__tests__/reddit-dialog.test.ts +0 -302
  127. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/occlusion.cpython-313.pyc +0 -0
  128. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.0.pyc +0 -0
  129. package/node_modules/pi-lean-portal/backends/python-base/tests/test_chromium_py_bridge.py +0 -281
  130. package/node_modules/pi-lean-portal/backends/python-base/tests/test_firefox_py_bridge.py +0 -212
@@ -6,14 +6,18 @@
6
6
  */
7
7
 
8
8
  import { existsSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { join, isAbsolute } from "node:path";
10
+ import { USER_BACKENDS_DIR } from "./shared/paths.js";
10
11
  // ─── Plugin Config types ──────────────────────────────────────────
11
12
 
12
13
  /** A single plugin entry from the user's settings.json */
13
14
  export interface PluginConfig {
14
15
  /** Stable identifier used in strategy param, session tracking, errors */
15
16
  name: string;
16
- /** Directory name under backends/ containing the plugin code */
17
+ /** Directory name (or absolute path) containing the plugin code.
18
+ * Resolved by `detectPluginType` against the configured roots: an
19
+ * absolute path short-circuits; otherwise the shipped `backends/`
20
+ * root is tried first, then the user `user-backends/` tree. */
17
21
  dir: string;
18
22
  /** Whether this plugin is active (default: true) */
19
23
  enabled: boolean;
@@ -65,16 +69,11 @@ export interface PluginConfigLoadResult {
65
69
  errors: string[];
66
70
  }
67
71
 
68
- /**
69
- * Unified full configuration result — includes both browser and plugin config.
70
- */
71
- export interface FullConfig {
72
+ /** Internal cache for loadFullConfig() — invalidated via invalidateConfigCache() */
73
+ let _fullConfigCache: {
72
74
  browser: BrowserConfig;
73
75
  plugins: PluginConfigLoadResult;
74
- }
75
-
76
- /** Internal cache for loadFullConfig() — invalidated via invalidateConfigCache() */
77
- let _fullConfigCache: FullConfig | null = null;
76
+ } | null = null;
78
77
 
79
78
  /**
80
79
  * Parse the browser config section from settings JSON.
@@ -129,9 +128,10 @@ function parseBrowserConfig(
129
128
  * Extracts and validates the `browser.plugins` array.
130
129
  * If no plugins are configured, returns a single default Chromium plugin.
131
130
  */
132
- function parsePluginConfig(
131
+ /** @internal */
132
+ export function parsePluginConfig(
133
133
  raw: Record<string, unknown> | undefined,
134
- backendsRoot: string,
134
+ roots: readonly string[],
135
135
  ): PluginConfigLoadResult {
136
136
  const errors: string[] = [];
137
137
 
@@ -180,7 +180,7 @@ function parsePluginConfig(
180
180
  if (validated) {
181
181
  // Also validate that the directory exists and is unambiguous
182
182
  try {
183
- detectPluginType(validated.dir, backendsRoot);
183
+ detectPluginType(validated.dir, roots);
184
184
  } catch (err) {
185
185
  errors.push(
186
186
  `plugins[${i}] ('${validated.name}'): ${err instanceof Error ? err.message : String(err)}`,
@@ -222,17 +222,20 @@ function readBrowserConfigRaw(): Record<string, unknown> | undefined {
222
222
  * Load and cache the full browser configuration from settings.json.
223
223
  *
224
224
  * Reads settings.json once on first call and caches the result for
225
- * subsequent calls. Both `loadBrowserConfig()` and `loadPluginConfig()`
226
- * delegate to this function.
225
+ * subsequent calls.
227
226
  */
228
- export function loadFullConfig(backendsRoot?: string): FullConfig {
227
+ export function loadFullConfig(roots?: readonly string[]): {
228
+ browser: BrowserConfig;
229
+ plugins: PluginConfigLoadResult;
230
+ } {
229
231
  if (_fullConfigCache) return _fullConfigCache;
230
232
 
231
233
  const raw = readBrowserConfigRaw();
234
+ const effectiveRoots = roots ?? DEFAULT_BACKEND_ROOTS;
232
235
 
233
236
  _fullConfigCache = {
234
237
  browser: parseBrowserConfig(raw),
235
- plugins: parsePluginConfig(raw, backendsRoot ?? DEFAULT_BACKENDS_ROOT),
238
+ plugins: parsePluginConfig(raw, effectiveRoots),
236
239
  };
237
240
 
238
241
  return _fullConfigCache;
@@ -246,19 +249,6 @@ export function invalidateConfigCache(): void {
246
249
  _fullConfigCache = null;
247
250
  }
248
251
 
249
- /**
250
- * Convenience wrapper — returns the `defaultProfile` setting from the
251
- * cached browser configuration. Delegates to `loadFullConfig()` which
252
- * reads `settings.json` once and caches the result.
253
- *
254
- * The `browser.defaultProfile` field controls what happens when
255
- * `browser-navigate` is called without an explicit `profile` parameter.
256
- * See `BrowserConfig` for valid values.
257
- */
258
- export function loadBrowserConfig(): BrowserConfig {
259
- return loadFullConfig().browser;
260
- }
261
-
262
252
  // ─── Validation ───────────────────────────────────────────────────
263
253
 
264
254
  /**
@@ -332,38 +322,66 @@ function validateEntry(
332
322
  /**
333
323
  * Detect the plugin type from the directory contents.
334
324
  *
325
+ * Resolution order for `dir`:
326
+ * 1. **Absolute path** — if `dir` is absolute, it is used directly
327
+ * (skips all roots; useful for development / power users).
328
+ * 2. **Each root in `roots`, in order** — `join(root, dir)`. The first
329
+ * root that contains an unambiguous entry point wins.
330
+ *
331
+ * Per-root ambiguity: if a single resolved dir contains *both*
332
+ * `index.ts` and `bridge.py`, that is an error (the original behaviour).
333
+ * If the first root has `index.ts` and a later root has `bridge.py`, the
334
+ * first root wins — that is a legitimate multi-root layout, not ambiguity.
335
+ *
336
+ * If no resolved dir has any entry point, throws an error naming every
337
+ * root searched so users can see where the loader looked.
338
+ *
335
339
  * - `backends/<dir>/index.ts` exists → Node plugin
336
340
  * - `backends/<dir>/bridge.py` exists → Python plugin
337
- * - Both exist → error (ambiguous)
338
- * - Neither exists → error
341
+ * - Both exist in the same resolved dir → error (ambiguous)
342
+ * - Neither exists anywhere → error
339
343
  */
340
344
  export function detectPluginType(
341
345
  dir: string,
342
- backendsRoot: string,
346
+ roots: readonly string[],
343
347
  ): PluginDetection {
344
- const dirPath = join(backendsRoot, dir);
345
- const indexPath = join(dirPath, "index.ts");
346
- const bridgePath = join(dirPath, "bridge.py");
348
+ const candidatePaths: string[] = [];
349
+ if (isAbsolute(dir)) {
350
+ candidatePaths.push(dir);
351
+ } else {
352
+ for (const root of roots) {
353
+ candidatePaths.push(join(root, dir));
354
+ }
355
+ }
347
356
 
348
- const hasIndex = existsSync(indexPath);
349
- const hasBridge = existsSync(bridgePath);
357
+ for (const dirPath of candidatePaths) {
358
+ const indexPath = join(dirPath, "index.ts");
359
+ const bridgePath = join(dirPath, "bridge.py");
350
360
 
351
- if (hasIndex && hasBridge) {
352
- throw new Error(
353
- `Plugin dir '${dir}' is ambiguous: both index.ts and bridge.py found. Remove one.`,
354
- );
355
- }
361
+ const hasIndex = existsSync(indexPath);
362
+ const hasBridge = existsSync(bridgePath);
356
363
 
357
- if (hasIndex) {
358
- return { type: "node", entryPoint: indexPath };
359
- }
364
+ if (hasIndex && hasBridge) {
365
+ throw new Error(
366
+ `Plugin dir '${dir}' is ambiguous: both index.ts and bridge.py found in ${dirPath}. Remove one.`,
367
+ );
368
+ }
369
+
370
+ if (hasIndex) {
371
+ return { type: "node", entryPoint: indexPath };
372
+ }
360
373
 
361
- if (hasBridge) {
362
- return { type: "python", entryPoint: bridgePath };
374
+ if (hasBridge) {
375
+ return { type: "python", entryPoint: bridgePath };
376
+ }
363
377
  }
364
378
 
379
+ const searched = candidatePaths.length
380
+ ? candidatePaths.join(", ")
381
+ : "(no roots provided)";
365
382
  throw new Error(
366
- `Plugin dir '${dir}' has no entry point. Expected index.ts (Node) or bridge.py (Python).`,
383
+ `Plugin dir '${dir}' has no entry point. Expected index.ts (Node) or bridge.py (Python). ` +
384
+ `Searched: ${searched}`,
367
385
  );
368
386
  }
369
387
 
@@ -376,13 +394,12 @@ export function detectPluginType(
376
394
  export const DEFAULT_BACKENDS_ROOT = join(__dirname, "..", "backends");
377
395
 
378
396
  /**
379
- * Load and validate the plugin configuration.
380
- *
381
- * If no `browser.plugins` config exists, returns the default fallback
382
- * (chromium + firefox enabled, chromium-py + firefox-py disabled).
397
+ * Default root search order for plugin discovery: the shipped package
398
+ * `backends/` first, then the user-writable `user-backends/` tree under
399
+ * `~/.pi/agent/pi-lean-portal/`. Callers may pass a custom `roots`
400
+ * array (e.g. tests with temp dirs); when omitted, this default is used.
383
401
  */
384
- export function loadPluginConfig(
385
- backendsRoot: string = DEFAULT_BACKENDS_ROOT,
386
- ): PluginConfigLoadResult {
387
- return loadFullConfig(backendsRoot).plugins;
388
- }
402
+ export const DEFAULT_BACKEND_ROOTS: readonly string[] = [
403
+ DEFAULT_BACKENDS_ROOT,
404
+ USER_BACKENDS_DIR,
405
+ ];
@@ -49,8 +49,6 @@ export function validatePlugin(plugin: BrowserPlugin): string[] {
49
49
  /** Internal tracking for a registered plugin */
50
50
  interface RegistryEntry {
51
51
  plugin: BrowserPlugin;
52
- /** Position in the user's plugins config array (lower = higher priority, used for LLM escalation hints) */
53
- level: number;
54
52
  /** Whether this plugin is enabled */
55
53
  enabled: boolean;
56
54
  }
@@ -61,7 +59,7 @@ export class PluginRegistry {
61
59
  /** Map of plugin name → registry entry */
62
60
  private entries = new Map<string, RegistryEntry>();
63
61
 
64
- /** Ordered list of plugin names from config (defines escalation priority — lower index = recommended first) */
62
+ /** Ordered list of plugin names from config (lower index = higher priority / recommended first) */
65
63
  private orderedNames: string[] = [];
66
64
 
67
65
  /**
@@ -72,8 +70,8 @@ export class PluginRegistry {
72
70
  * plugins loaded via dynamic `import()` vs Python plugins registered
73
71
  * synchronously).
74
72
  *
75
- * If `register()` finds its name already in the seeded order, it uses the
76
- * existing position as the priority level instead of appending.
73
+ * If `register()` finds its name already in the seeded order, it keeps
74
+ * that position instead of appending.
77
75
  *
78
76
  * @param names - Plugin names in the desired priority order (typically
79
77
  * the order from the user's `browser.plugins` config array).
@@ -85,9 +83,8 @@ export class PluginRegistry {
85
83
  /**
86
84
  * Register a plugin with its config.
87
85
  *
88
- * If the plugin name was pre-seeded via `seedOrder()`, its priority level
89
- * is taken from that pre-determined position. Otherwise it is appended at
90
- * the end.
86
+ * If the plugin name was pre-seeded via `seedOrder()`, it keeps that
87
+ * position in the ordered list. Otherwise it is appended at the end.
91
88
  *
92
89
  * @throws if a plugin with the same name is already registered
93
90
  * @throws if the plugin is missing required operations
@@ -107,23 +104,17 @@ export class PluginRegistry {
107
104
  );
108
105
  }
109
106
 
110
- // Determine the level from the pre-seeded orderedNames, or append
111
- let level = this.orderedNames.indexOf(plugin.name);
112
- if (level === -1) {
113
- level = this.orderedNames.length;
107
+ // Preserve pre-seeded order (from seedOrder); otherwise append.
108
+ if (!this.orderedNames.includes(plugin.name)) {
114
109
  this.orderedNames.push(plugin.name);
115
110
  }
116
111
 
117
112
  this.entries.set(plugin.name, {
118
113
  plugin,
119
- level,
120
114
  enabled: config.enabled,
121
115
  });
122
116
  }
123
117
 
124
- /**
125
- * Get a plugin by name. Returns undefined if not registered or disabled.
126
- */
127
118
  get(name: string): BrowserPlugin | undefined {
128
119
  const entry = this.entries.get(name);
129
120
  if (!entry || !entry.enabled) return undefined;
@@ -152,14 +143,13 @@ export class PluginRegistry {
152
143
 
153
144
  /**
154
145
  * Get all enabled plugins in config order (priority order).
155
- * Each entry includes the plugin and its priority level (lower = recommended first).
156
146
  */
157
- getOrdered(): Array<{ plugin: BrowserPlugin; level: number }> {
158
- const result: Array<{ plugin: BrowserPlugin; level: number }> = [];
147
+ getOrdered(): BrowserPlugin[] {
148
+ const result: BrowserPlugin[] = [];
159
149
  for (const name of this.orderedNames) {
160
150
  const entry = this.entries.get(name);
161
151
  if (entry?.enabled) {
162
- result.push({ plugin: entry.plugin, level: entry.level });
152
+ result.push(entry.plugin);
163
153
  }
164
154
  }
165
155
  return result;
@@ -169,7 +159,7 @@ export class PluginRegistry {
169
159
  * List all registered plugin names (enabled only).
170
160
  */
171
161
  available(): string[] {
172
- return this.getOrdered().map((e) => e.plugin.name);
162
+ return this.getOrdered().map((p) => p.name);
173
163
  }
174
164
 
175
165
  /**
@@ -182,26 +172,6 @@ export class PluginRegistry {
182
172
  });
183
173
  }
184
174
 
185
- /**
186
- * Get the priority level for a plugin (lower = recommended first).
187
- * Used by the LLM to decide whether to escalate to a different backend.
188
- * Returns undefined if the plugin is not registered.
189
- */
190
- getLevel(name: string): number | undefined {
191
- return this.entries.get(name)?.level;
192
- }
193
-
194
- /**
195
- * Get plugins at higher priority levels (further in the backup chain) than the given level.
196
- * Used to suggest alternative backends when bot detection fires.
197
- */
198
- getHigherStealth(currentLevel: number): Array<{
199
- plugin: BrowserPlugin;
200
- level: number;
201
- }> {
202
- return this.getOrdered().filter((e) => e.level > currentLevel);
203
- }
204
-
205
175
  /**
206
176
  * Resolve a strategy value to a plugin.
207
177
  *
@@ -248,16 +218,8 @@ export class PluginRegistry {
248
218
  this.entries.clear();
249
219
  this.orderedNames = [];
250
220
  }
251
-
252
- /**
253
- * Number of registered plugins (enabled and disabled).
254
- */
255
- get size(): number {
256
- return this.entries.size;
257
- }
258
221
  }
259
222
 
260
223
  // ─── Singleton ────────────────────────────────────────────────────
261
224
 
262
- /** Global plugin registry instance */
263
225
  export const pluginRegistry = new PluginRegistry();
@@ -1,14 +1,6 @@
1
1
  /**
2
2
  * Plugin Router — registry-based dispatch for interactive browser operations.
3
- *
4
- * All dispatch goes through the PluginRegistry, which resolves
5
- * the correct plugin based on the `strategy` parameter.
6
- *
7
- * Cross-cutting concerns handled here (not in plugins):
8
- * - Snapshot truncation (compactSnapshot)
9
- * - URL safety validation
10
- * - Session lifecycle (via sessionManager)
11
- * - Auto-recovery from crashed sessions (via lastNav)
3
+ * Handles snapshot truncation, URL safety, session lifecycle, and crash recovery.
12
4
  */
13
5
 
14
6
  import { writeFileSync } from "node:fs";
@@ -30,7 +22,7 @@ import {
30
22
  sanitizeProfileName,
31
23
  sessionProfileName,
32
24
  } from "./shared/storage-state.js";
33
- import { loadBrowserConfig } from "./plugin-config.js";
25
+ import { loadFullConfig } from "./plugin-config.js";
34
26
  import {
35
27
  runExtractor,
36
28
  correlateElements,
@@ -38,6 +30,7 @@ import {
38
30
  formatElementList,
39
31
  formatRoleCountSummary,
40
32
  type ExtractResult,
33
+ type QueryStatus,
41
34
  } from "./shared/dom-extractor.js";
42
35
  import type {
43
36
  DialogEvent,
@@ -228,16 +221,6 @@ async function requireInteractiveSession(taskId: string): Promise<{
228
221
  return null;
229
222
  }
230
223
 
231
- /**
232
- * Get the plugin for a given session. Returns undefined if the session
233
- * doesn't exist or the plugin is not available.
234
- */
235
- function getPluginForSession(
236
- session: BrowserSession,
237
- ): import("./plugin-api.js").BrowserPlugin | undefined {
238
- return pluginRegistry.get(session.pluginName);
239
- }
240
-
241
224
  interface ResolvedSession {
242
225
  tid: string;
243
226
  session: BrowserSession;
@@ -255,7 +238,7 @@ async function resolveSession(
255
238
  const tid = taskId ?? "default";
256
239
  const sr = await requireInteractiveSession(tid);
257
240
  if (!sr) return null;
258
- const plugin = getPluginForSession(sr.session);
241
+ const plugin = pluginRegistry.get(sr.session.pluginName);
259
242
  if (!plugin) return null;
260
243
  return {
261
244
  tid,
@@ -463,7 +446,7 @@ export async function navigate(
463
446
  let resolvedProfileName: string | undefined;
464
447
  let profileMode: "none" | "session" | "named" | undefined;
465
448
 
466
- const browserConfig = loadBrowserConfig();
449
+ const browserConfig = loadFullConfig().browser;
467
450
  const profileInput = options.profile ?? browserConfig.defaultProfile;
468
451
 
469
452
  if (profileInput === "none") {
@@ -731,13 +714,16 @@ export async function snapshot(
731
714
 
732
715
  // ─── Interaction tools ──────────────────────────────────────────────
733
716
 
734
- export async function click(
717
+ /** Shared shape for the 5 ref-based interaction tools (click/type/scroll/goBack/press). */
718
+ async function wrapInteraction(
735
719
  taskId: string | undefined,
736
- ref: string,
720
+ run: (
721
+ plugin: import("./plugin-api.js").BrowserPlugin,
722
+ tid: string,
723
+ ) => Promise<InteractionResult>,
737
724
  ): Promise<InteractionResult> {
738
725
  const resolved = await resolveSession(taskId);
739
726
  if (!resolved) return noSessionError();
740
-
741
727
  return refBasedInteractionOrSnapshot(
742
728
  resolved.tid,
743
729
  resolved.wasAutoCreated,
@@ -745,83 +731,44 @@ export async function click(
745
731
  async () =>
746
732
  compactInteractionResult(
747
733
  resolved.tid,
748
- await resolved.plugin.click(resolved.tid, ref),
734
+ await run(resolved.plugin, resolved.tid),
749
735
  ),
750
736
  );
751
737
  }
752
738
 
739
+ export async function click(
740
+ taskId: string | undefined,
741
+ ref: string,
742
+ ): Promise<InteractionResult> {
743
+ return wrapInteraction(taskId, (plugin, tid) => plugin.click(tid, ref));
744
+ }
745
+
753
746
  export async function type(
754
747
  taskId: string | undefined,
755
748
  ref: string,
756
749
  text: string,
757
750
  ): Promise<InteractionResult> {
758
- const resolved = await resolveSession(taskId);
759
- if (!resolved) return noSessionError();
760
-
761
- return refBasedInteractionOrSnapshot(
762
- resolved.tid,
763
- resolved.wasAutoCreated,
764
- resolved.plugin,
765
- async () =>
766
- compactInteractionResult(
767
- resolved.tid,
768
- await resolved.plugin.type(resolved.tid, ref, text),
769
- ),
770
- );
751
+ return wrapInteraction(taskId, (plugin, tid) => plugin.type(tid, ref, text));
771
752
  }
772
753
 
773
754
  export async function scroll(
774
755
  taskId: string | undefined,
775
756
  direction: "up" | "down",
776
757
  ): Promise<InteractionResult> {
777
- const resolved = await resolveSession(taskId);
778
- if (!resolved) return noSessionError();
779
-
780
- return refBasedInteractionOrSnapshot(
781
- resolved.tid,
782
- resolved.wasAutoCreated,
783
- resolved.plugin,
784
- async () =>
785
- compactInteractionResult(
786
- resolved.tid,
787
- await resolved.plugin.scroll(resolved.tid, direction),
788
- ),
758
+ return wrapInteraction(taskId, (plugin, tid) =>
759
+ plugin.scroll(tid, direction),
789
760
  );
790
761
  }
791
762
 
792
763
  export async function goBack(taskId?: string): Promise<InteractionResult> {
793
- const resolved = await resolveSession(taskId);
794
- if (!resolved) return noSessionError();
795
-
796
- return refBasedInteractionOrSnapshot(
797
- resolved.tid,
798
- resolved.wasAutoCreated,
799
- resolved.plugin,
800
- async () =>
801
- compactInteractionResult(
802
- resolved.tid,
803
- await resolved.plugin.goBack(resolved.tid),
804
- ),
805
- );
764
+ return wrapInteraction(taskId, (plugin, tid) => plugin.goBack(tid));
806
765
  }
807
766
 
808
767
  export async function press(
809
768
  taskId: string | undefined,
810
769
  key: string,
811
770
  ): Promise<InteractionResult> {
812
- const resolved = await resolveSession(taskId);
813
- if (!resolved) return noSessionError();
814
-
815
- return refBasedInteractionOrSnapshot(
816
- resolved.tid,
817
- resolved.wasAutoCreated,
818
- resolved.plugin,
819
- async () =>
820
- compactInteractionResult(
821
- resolved.tid,
822
- await resolved.plugin.press(resolved.tid, key),
823
- ),
824
- );
771
+ return wrapInteraction(taskId, (plugin, tid) => plugin.press(tid, key));
825
772
  }
826
773
 
827
774
  /**
@@ -992,17 +939,24 @@ export async function browserInspect(
992
939
  };
993
940
  }
994
941
 
995
- // Run the DOM extractor
996
- const extracted = await runExtractor(tid, plugin);
997
- if (!extracted) {
942
+ // Run the DOM extractor. The !cacheExists guard above guarantees
943
+ // cache is non-null and non-empty here, so we always route to the
944
+ // live-cache query hint on failure.
945
+ const outcome = await runExtractor(tid, plugin);
946
+ if (!outcome.ok) {
998
947
  return {
999
948
  success: false,
1000
949
  content: "",
1001
950
  error:
1002
- "Text extraction returned no content. The page may be empty, blocked, or strict CSP prevents DOM access. Use browser-snapshot to inspect visually.",
951
+ `Text extraction failed: ${outcome.error}. ` +
952
+ `A live element cache (${cache!.size} elements) is available — ` +
953
+ "use browser-inspect with role=, name=, or ref= to query it, " +
954
+ "or browser-snapshot to refresh.",
1003
955
  };
1004
956
  }
1005
957
 
958
+ const extracted = outcome.result;
959
+
1006
960
  // Keyword filtering — case-insensitive substring match on extracted text
1007
961
  if (params.query) {
1008
962
  applyQueryFilter(extracted, params.query);
@@ -1044,16 +998,30 @@ export async function browserInspect(
1044
998
  };
1045
999
  }
1046
1000
 
1047
- const filtered = queryElementCache(cache!, {
1048
- ...(params.role !== undefined ? { role: params.role } : {}),
1049
- ...(params.name !== undefined ? { name: params.name } : {}),
1050
- ...(params.ref !== undefined ? { ref: params.ref } : {}),
1051
- ...(params.subtree !== undefined ? { subtree: params.subtree } : {}),
1052
- });
1001
+ const status: QueryStatus = {};
1002
+ const filtered = queryElementCache(
1003
+ cache!,
1004
+ {
1005
+ ...(params.role !== undefined ? { role: params.role } : {}),
1006
+ ...(params.name !== undefined ? { name: params.name } : {}),
1007
+ ...(params.ref !== undefined ? { ref: params.ref } : {}),
1008
+ ...(params.subtree !== undefined ? { subtree: params.subtree } : {}),
1009
+ },
1010
+ status,
1011
+ );
1053
1012
 
1054
- const content = formatElementList(filtered, {
1055
- ...(params.ref !== undefined ? { ref: params.ref } : {}),
1056
- });
1013
+ let content: string;
1014
+ if (filtered.length === 0 && status.refFilteredOut) {
1015
+ const { node, filter, value } = status.refFilteredOut;
1016
+ content =
1017
+ `Element @${node.ref} found in cache (role=${node.role}` +
1018
+ `${node.name ? `, name="${node.name}"` : ""}) but does not match ` +
1019
+ `filter ${filter}="${value}". Drop the ${filter} filter or adjust it.`;
1020
+ } else {
1021
+ content = formatElementList(filtered, {
1022
+ ...(params.ref !== undefined ? { ref: params.ref } : {}),
1023
+ });
1024
+ }
1057
1025
 
1058
1026
  return {
1059
1027
  success: true,
@@ -1090,13 +1058,6 @@ export async function browserInspect(
1090
1058
  function applyQueryFilter(extracted: ExtractResult, query: string): void {
1091
1059
  const q = query.toLowerCase();
1092
1060
 
1093
- const beforeCount =
1094
- extracted.headings.length +
1095
- extracted.paragraphs.length +
1096
- extracted.links.length +
1097
- extracted.images.length +
1098
- extracted.interactive.length;
1099
-
1100
1061
  extracted.headings = extracted.headings.filter((h) =>
1101
1062
  h.text.toLowerCase().includes(q),
1102
1063
  );
@@ -1122,7 +1083,7 @@ function applyQueryFilter(extracted: ExtractResult, query: string): void {
1122
1083
  extracted.interactive.length;
1123
1084
 
1124
1085
  // Signal empty results so the agent doesn't get a silent empty page
1125
- if (beforeCount > 0 && afterCount === 0) {
1086
+ if (afterCount === 0) {
1126
1087
  extracted.paragraphs.push({
1127
1088
  text: `⚠ No content matched "${query}". Try a different keyword or remove the query parameter.`,
1128
1089
  });