pi-lean-portal 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,9 +16,10 @@
16
16
 
17
17
  ```bash
18
18
  pi install npm:pi-lean-portal
19
- npx playwright install chromium firefox
20
19
  ```
21
20
 
21
+ Then run **`/web install`** inside pi to download browser binaries (these are not installed via npm).
22
+
22
23
  Once loaded, you'll see a notification like:
23
24
 
24
25
  > 🌐 Browser extension loaded (plugins: chromium, firefox). Try: web-fetch for static pages or browser-navigate for interactive browsing.
@@ -27,10 +28,6 @@ The browser tools are **enabled by default**: `web-fetch` for static pages,
27
28
  `browser-navigate` (plus click/type/scroll/screenshots via `@e` refs) for
28
29
  interactive browsing.
29
30
 
30
- > **Playwright browser binaries are not downloaded during `npm install`.**
31
- > Run `npx playwright install chromium firefox` separately. On first
32
- > `browser-navigate` without them, you'll be prompted with the exact command.
33
-
34
31
  ---
35
32
 
36
33
  ## `/web` Command — Browser Toggle & Profiles
@@ -51,13 +48,16 @@ toggles guide-saving mode, and manages browser profiles.
51
48
  | Command | Effect |
52
49
  | ------- | ------ |
53
50
  | `/web` | Show current toggle status and available sub-commands. |
54
- | `/web status` | **Detailed runtime status** including toggle state, plugin health, active sessions, and profiles on disk. |
51
+ | `/web status` | **Detailed runtime status** including toggle state, plugin health, browser-binary presence, active sessions, and profiles on disk. |
52
+ | `/web install` | **Install browser binaries** via the bundled playwright CLI (checkbox dialog in the TUI; prints the manual command elsewhere). |
53
+ | `/web install chromium\|firefox` | Directly download one engine, no dialog. |
55
54
 
56
55
  ```text
57
56
  🌐 Browser tools: ✅ on | 📖 Learn mode: ❌ off
58
57
  ────────────────────────────────────────
59
58
  Status: idle
60
59
  Plugins: chromium, firefox, chromium-py (disabled), firefox-py (disabled)
60
+ Browsers: chromium ✓ firefox ✓
61
61
  Use web-fetch for stateless HTTP fetches.
62
62
  Active sessions: 1
63
63
  PW [chromium] https://example.com — Example Domain [profile: session]
@@ -225,9 +225,9 @@ and the router dispatches tool calls to the right plugin based on a
225
225
  | `chromium-py` | Chromium | Python/Playwright | Disabled |
226
226
  | `firefox-py` | Firefox | Python/Playwright | Disabled |
227
227
 
228
- > **Install Firefox:** `npx playwright install firefox` to use the Node
229
- > `firefox` backend. For the Python parity backends, install Playwright
230
- > inside `backends/python-base/.venv`.
228
+ > **Install Firefox:** run `/web install firefox` inside pi to download the
229
+ > Firefox binary for the Node `firefox` backend. For the Python parity
230
+ > backends, install Playwright inside `backends/python-base/.venv`.
231
231
 
232
232
  All four shipped backends support screenshots (viewport-sized, not
233
233
  full-page), console capture, JS evaluation, bot detection, and dialog
@@ -331,8 +331,8 @@ the toolset's packaged default:
331
331
  - Omit a `toolsetDefaults` key to use the toolset's packaged default (`web`
332
332
  and `search` default `true`; `web-learn` defaults `false`).
333
333
  - The `search` key only applies when `pi-lean-search` is installed.
334
- - Pins do not apply in spawned subagent children (see pi-tool-masking
335
- 1.3.0's `piToolMasking.childPolicy` for the opt-out).
334
+ - Pins do not apply in spawned subagent children (see pi-tool-masking's
335
+ `piToolMasking.childPolicy` for the opt-out).
336
336
 
337
337
  ### `browser.maxStorageStateSize`
338
338
 
@@ -35,7 +35,7 @@ export class ChromiumPlugin extends PlaywrightPluginBase {
35
35
  }
36
36
 
37
37
  protected get installHint(): string {
38
- return "Browser not installed. Run: npx playwright install chromium firefox";
38
+ return "Browser not installed. Run /web install to install it.";
39
39
  }
40
40
  }
41
41
 
@@ -54,7 +54,7 @@ export class FirefoxPlugin extends PlaywrightPluginBase {
54
54
  }
55
55
 
56
56
  protected get installHint(): string {
57
- return "Browser not installed. Run: npx playwright install chromium firefox";
57
+ return "Browser not installed. Run /web install to install it.";
58
58
  }
59
59
  }
60
60
 
@@ -83,7 +83,7 @@ export abstract class PlaywrightPluginBase implements BrowserPlugin {
83
83
 
84
84
  /**
85
85
  * Engine-specific install hint, shown when the browser executable
86
- * is not installed. Example: "Run: npx playwright install firefox".
86
+ * is not installed, e.g. "Browser not installed. Run /web install to install it".
87
87
  */
88
88
  protected abstract get installHint(): string;
89
89
 
@@ -38,6 +38,7 @@ contexts across tasks.
38
38
  """
39
39
 
40
40
  import base64
41
+ import contextlib
41
42
  import json
42
43
  import os
43
44
  import re
@@ -299,13 +300,14 @@ class PlaywrightBridge:
299
300
  _csp_safe_readonly_via_init_script: bool = False
300
301
 
301
302
  #: When non-zero, ``_wait_for_navigation_settle`` uses this as the
302
- #: settle poll budget (ms) instead of the default 400. Stealth backends
303
+ #: settle poll budget (ms) instead of the default. Stealth backends
303
304
  #: whose patched browser fires navigation events with higher latency
304
305
  #: (e.g. Camoufox's Juggler) set a larger value to avoid settling before
305
- #: the navigation commit is observable. Default 400 matches the
306
- #: historical hard-coded value so shipped chromium-py / firefox-py are
307
- #: unchanged.
308
- _settle_budget_ms: int = 400
306
+ #: the navigation commit is observable. Default reads
307
+ #: ``navSettle.settleTimeoutMs`` from the shared TS↔Python parity JSON
308
+ #: (``browser-data.json``) so both runtimes stay in lockstep; shipped
309
+ #: chromium-py / firefox-py are unchanged.
310
+ _settle_budget_ms: int = NAV_SETTLE["settleTimeoutMs"]
309
311
 
310
312
  #: When True, ``_wait_for_navigation_settle`` switches the no-nav
311
313
  #: polling branch from a fixed budget to "URL stable for 150 ms **or**
@@ -448,10 +450,8 @@ class PlaywrightBridge:
448
450
  self._log("captureUA", success=False, ua="(fallback)")
449
451
  finally:
450
452
  if page is not None:
451
- try:
453
+ with contextlib.suppress(Exception):
452
454
  page.close()
453
- except Exception:
454
- pass
455
455
 
456
456
  # ── Debug logging ───────────────────────────────────────────
457
457
 
@@ -518,15 +518,11 @@ class PlaywrightBridge:
518
518
  def _maybe_stop_playwright(self) -> None:
519
519
  """Stop the shared Playwright if no sessions remain."""
520
520
  if not self.sessions and self._pw is not None:
521
- try:
522
- if self._browser:
521
+ if self._browser:
522
+ with contextlib.suppress(Exception):
523
523
  self._browser.close()
524
- except Exception:
525
- pass
526
- try:
524
+ with contextlib.suppress(Exception):
527
525
  self._pw.stop()
528
- except Exception:
529
- pass
530
526
  self._pw = None
531
527
  self._browser = None
532
528
 
@@ -577,7 +573,7 @@ class PlaywrightBridge:
577
573
  # Start Playwright trace capture if BROWSER_TRACE_DIR is set.
578
574
  _trace_dir = os.environ.get("BROWSER_TRACE_DIR")
579
575
  if _trace_dir:
580
- try:
576
+ with contextlib.suppress(Exception):
581
577
  context.tracing.start(
582
578
  screenshots=True,
583
579
  snapshots=True,
@@ -588,9 +584,7 @@ class PlaywrightBridge:
588
584
  taskId=config.get("_task_id", "shared"),
589
585
  action="start",
590
586
  dir=_trace_dir,
591
- )
592
- except Exception:
593
- pass # Best-effort
587
+ ) # Best-effort
594
588
 
595
589
  # CSP-safe read-only eval path (see _csp_safe_readonly_via_init_script).
596
590
  # Registers an init script that stashes the EXTRACTOR_SCRIPT result in
@@ -749,12 +743,10 @@ class PlaywrightBridge:
749
743
  if session is not None:
750
744
  context: Any = session.get("context")
751
745
 
752
- try:
753
- page: Any = session.get("page")
754
- if page and not page.is_closed():
746
+ page: Any = session.get("page")
747
+ if page and not page.is_closed():
748
+ with contextlib.suppress(Exception):
755
749
  page.close()
756
- except Exception:
757
- pass
758
750
 
759
751
  # Stop and save Playwright trace if BROWSER_TRACE_DIR is set.
760
752
  _trace_dir = os.environ.get("BROWSER_TRACE_DIR")
@@ -767,14 +759,15 @@ class PlaywrightBridge:
767
759
  )
768
760
  context.tracing.stop(path=_trace_path)
769
761
  self._log("tracing", taskId=task_id, action="stop", dir=_trace_dir)
770
- except Exception:
771
- pass
762
+ except Exception as exc:
763
+ # Best-effort: a failed trace save must not fail cleanup.
764
+ self._log(
765
+ "tracing", taskId=task_id, action="stop-failed", error=str(exc)
766
+ )
772
767
 
773
- try:
774
- if context:
768
+ if context:
769
+ with contextlib.suppress(Exception):
775
770
  context.close()
776
- except Exception:
777
- pass
778
771
 
779
772
  # Remove session + element cache
780
773
  self.sessions.pop(task_id, None)
@@ -926,15 +919,11 @@ class PlaywrightBridge:
926
919
  stealth patched-Firefox binaries don't fire it reliably and
927
920
  loitering for it can deadlock the Juggler driver.
928
921
  """
929
- try:
922
+ with contextlib.suppress(Exception):
930
923
  page.wait_for_load_state("load", timeout=timeout_ms)
931
- except Exception:
932
- pass
933
924
  if not skip_networkidle:
934
- try:
925
+ with contextlib.suppress(Exception):
935
926
  page.wait_for_load_state("networkidle", timeout=timeout_ms)
936
- except Exception:
937
- pass
938
927
 
939
928
  def _wait_for_navigation_settle(
940
929
  self,
@@ -974,8 +963,10 @@ class PlaywrightBridge:
974
963
  page.on("framenavigated", _on_nav)
975
964
 
976
965
  try:
977
- # Wait for a potential navigation to start (150 ms window)
978
- page.wait_for_timeout(150)
966
+ # Wait for a potential navigation to start (shared race window,
967
+ # ``navSettle.settleRaceMs`` in browser-data.json — same value
968
+ # the TS nav-settle races against ``framenavigated``).
969
+ page.wait_for_timeout(NAV_SETTLE["settleRaceMs"])
979
970
 
980
971
  waited_for_load = False
981
972
  if navigated:
@@ -1099,13 +1090,12 @@ class PlaywrightBridge:
1099
1090
  break
1100
1091
 
1101
1092
  # ── DOM stabilization wait ──────────────────────────────
1102
- try:
1093
+ # Stabilization timeout is best-effort — proceed.
1094
+ with contextlib.suppress(Exception):
1103
1095
  page.wait_for_function(
1104
1096
  _DOM_STABILIZE_JS,
1105
1097
  timeout=NAV_SETTLE["navTimeoutMs"],
1106
1098
  )
1107
- except Exception:
1108
- pass # Stabilization timed out — proceed
1109
1099
 
1110
1100
  # ── Bot detection ───────────────────────────────────────
1111
1101
  bot_detected = check_bot_detection(page)
@@ -1270,7 +1260,7 @@ class PlaywrightBridge:
1270
1260
 
1271
1261
  try:
1272
1262
  locator.click(timeout=5_000) # Focus first
1273
- locator.fill(text)
1263
+ locator.fill(text, timeout=5_000)
1274
1264
 
1275
1265
  result = self._build_interaction_result(task_id, page)
1276
1266
  except Exception as exc:
@@ -1331,11 +1321,9 @@ class PlaywrightBridge:
1331
1321
 
1332
1322
  new_url: str | None = None
1333
1323
  new_title: str | None = None
1334
- try:
1324
+ with contextlib.suppress(Exception):
1335
1325
  new_url = page.url
1336
1326
  new_title = page.title()
1337
- except Exception:
1338
- pass
1339
1327
 
1340
1328
  extra: dict[str, Any] = {}
1341
1329
  if new_url is not None:
@@ -1554,20 +1542,18 @@ class PlaywrightBridge:
1554
1542
  # ponytail: single retry, no backoff — challenge pages settle in
1555
1543
  # one load cycle; add exponential backoff if a real challenge
1556
1544
  # needs >1 retry.
1557
- # Nested ifs (rather than
1558
- # `self._wrap_mw_eval_in_eval and "Execution context was destroyed" in err_msg`)
1559
- # keep the no-boolean-in-except lint calm; behavior is identical.
1545
+ retry_destroyed = False
1560
1546
  if self._wrap_mw_eval_in_eval:
1561
- if "Execution context was destroyed" in err_msg:
1562
- try:
1563
- page.wait_for_load_state("load")
1564
- except Exception:
1565
- pass # Best-effort: proceed to retry even if wait fails
1566
- try:
1567
- result = page.evaluate(effective_expression)
1568
- return {"success": True, "result": result}
1569
- except Exception as retry_exc:
1570
- return {"success": False, "error": str(retry_exc)}
1547
+ retry_destroyed = "Execution context was destroyed" in err_msg
1548
+ if retry_destroyed:
1549
+ # Best-effort: proceed to retry even if wait fails
1550
+ with contextlib.suppress(Exception):
1551
+ page.wait_for_load_state("load")
1552
+ try:
1553
+ result = page.evaluate(effective_expression)
1554
+ return {"success": True, "result": result}
1555
+ except Exception as retry_exc:
1556
+ return {"success": False, "error": str(retry_exc)}
1571
1557
  return {
1572
1558
  "success": False,
1573
1559
  "error": err_msg,
@@ -21,9 +21,7 @@ Error response::
21
21
 
22
22
  import json
23
23
  import sys
24
- import traceback
25
- from typing import Any, Optional
26
-
24
+ from typing import Any
27
25
 
28
26
  # ─── Standard JSON-RPC error codes ────────────────────────────────────
29
27
 
@@ -31,7 +29,6 @@ PARSE_ERROR = -32700
31
29
  INVALID_REQUEST = -32600
32
30
  METHOD_NOT_FOUND = -32601
33
31
  INVALID_PARAMS = -32602
34
- INTERNAL_ERROR = -32603
35
32
 
36
33
  # Custom error codes (application-level)
37
34
  APPLICATION_ERROR = -32000
@@ -50,7 +47,8 @@ class InvalidRequestError(ValueError):
50
47
 
51
48
  # ─── Reading requests ─────────────────────────────────────────────────
52
49
 
53
- def read_request() -> Optional[dict[str, Any]]:
50
+
51
+ def read_request() -> dict[str, Any] | None:
54
52
  """Read one JSON-RPC request from stdin (line-delimited).
55
53
 
56
54
  Returns the parsed request dict, or None on EOF.
@@ -86,6 +84,7 @@ def read_request() -> Optional[dict[str, Any]]:
86
84
 
87
85
  # ─── Writing responses ────────────────────────────────────────────────
88
86
 
87
+
89
88
  def write_response(response: dict[str, Any]) -> None:
90
89
  """Write a JSON-RPC response to stdout as a single JSON line, then flush.
91
90
 
@@ -110,7 +109,7 @@ def make_error_response(
110
109
  request_id: Any,
111
110
  code: int,
112
111
  message: str,
113
- data: Optional[dict[str, Any]] = None,
112
+ data: dict[str, Any] | None = None,
114
113
  ) -> dict[str, Any]:
115
114
  """Build a JSON-RPC error response."""
116
115
  error: dict[str, Any] = {
@@ -136,24 +135,10 @@ def make_invalid_request(request_id: Any = None) -> dict[str, Any]:
136
135
  return make_error_response(request_id, INVALID_REQUEST, "Invalid Request")
137
136
 
138
137
 
139
- def make_internal_error(
140
- request_id: Any,
141
- exc: Exception,
142
- ) -> dict[str, Any]:
143
- """Build an internal error response including Python traceback."""
144
- tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
145
- return make_error_response(
146
- request_id,
147
- INTERNAL_ERROR,
148
- f"Internal error: {exc}",
149
- data={"traceback": "".join(tb)},
150
- )
151
-
152
-
153
138
  def make_application_error(
154
139
  request_id: Any,
155
140
  message: str,
156
- traceback_str: Optional[str] = None,
141
+ traceback_str: str | None = None,
157
142
  ) -> dict[str, Any]:
158
143
  """Build an application-level error response."""
159
144
  data: dict[str, Any] = {}
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Browser Install — `/web install` subcommand.
3
+ *
4
+ * The `playwright` npm package ships no browser binaries, and the generic
5
+ * `npx playwright install` advice can resolve a different playwright copy
6
+ * than the one the backends import — installing revisions the backends
7
+ * don't match. This command resolves the *bundled* playwright CLI (the same
8
+ * copy the backends use) and downloads engines from it, guaranteeing
9
+ * revision parity.
10
+ *
11
+ * Forms:
12
+ * /web install — checkbox dialog (TUI only; other modes print
13
+ * the manual bundled-CLI command)
14
+ * /web install chromium — direct download, no dialog (TUI + RPC spawn;
15
+ * print/JSON modes print the manual command)
16
+ *
17
+ * Never throws: resolution, detection, and download failures all fall back
18
+ * to printing a manual command. Removal is intentionally out of scope —
19
+ * `playwright uninstall` takes no per-engine arguments and the cache
20
+ * directory is machine-global; document the real CLI instead.
21
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { existsSync, readFileSync } from "node:fs";
25
+ import { createRequire } from "node:module";
26
+ import { basename, dirname, join } from "node:path";
27
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
28
+ import {
29
+ Container,
30
+ type SelectItem,
31
+ SelectList,
32
+ Text,
33
+ } from "@earendil-works/pi-tui";
34
+
35
+ // ─── Engines ─────────────────────────────────────────────────────
36
+
37
+ export const ENGINES = ["chromium", "firefox"] as const;
38
+ export type Engine = (typeof ENGINES)[number];
39
+
40
+ /** Rough per-engine download sizes, for the dialog rows. */
41
+ const ENGINE_SIZES = {
42
+ chromium: "~170 MB",
43
+ firefox: "~90 MB",
44
+ } as const;
45
+
46
+ // ─── Bundled playwright resolution ───────────────────────────────
47
+
48
+ export interface BundledPlaywright {
49
+ /** The playwright module the backends import (same copy, same revisions). */
50
+ pw: typeof import("playwright");
51
+ /** Absolute path to the bundled playwright CLI entry. */
52
+ cliJs: string;
53
+ /** Node interpreter for spawning the CLI. */
54
+ nodeExe: string;
55
+ }
56
+
57
+ /**
58
+ * Node interpreter for spawning the bundled CLI. `process.execPath` is only
59
+ * a Node binary under the npm-shim distribution; pi also ships a
60
+ * bun-compiled standalone binary where `process.execPath` is the pi binary
61
+ * itself — spawning it with playwright's args re-runs pi, not the CLI. The
62
+ * `.exe` strip handles Windows (`...\node.exe` there); on POSIX the replace
63
+ * is a no-op.
64
+ */
65
+ export function resolveNodeInterpreter(): string {
66
+ return basename(process.execPath).replace(/\.exe$/i, "") === "node"
67
+ ? process.execPath
68
+ : "node";
69
+ }
70
+
71
+ /**
72
+ * Resolve the bundled playwright module + CLI path. Throws if playwright is
73
+ * missing entirely (corrupted install) — callers must catch and fall back to
74
+ * the generic manual command. Must be called lazily (inside the command
75
+ * handler), never at module top level: this module is imported by the `/web`
76
+ * dispatcher, and a throwing top-level require would break the entire `/web`
77
+ * command in exactly the corrupt-install scenario this command exists for.
78
+ */
79
+ export function resolveBundledPlaywright(): BundledPlaywright {
80
+ const req = createRequire(import.meta.url);
81
+ const pw = req("playwright");
82
+ // `playwright/package.json` is exported; `playwright/cli.js` is not —
83
+ // resolving the CLI subpath directly throws ERR_PACKAGE_PATH_NOT_EXPORTED.
84
+ const pkgDir = dirname(req.resolve("playwright/package.json"));
85
+ return {
86
+ pw,
87
+ cliJs: join(pkgDir, "cli.js"),
88
+ nodeExe: resolveNodeInterpreter(),
89
+ };
90
+ }
91
+
92
+ // ─── Installed-browser detection (no launch) ─────────────────────
93
+
94
+ export interface BrowserDetection {
95
+ chromium: boolean;
96
+ firefox: boolean;
97
+ }
98
+
99
+ /**
100
+ * Detect installed Node-backend browsers without launching. Unexpected
101
+ * layouts degrade to `missing` — the safe direction, since re-running
102
+ * install is harmless.
103
+ */
104
+ export function detectInstalledBrowsers(
105
+ pw: typeof import("playwright"),
106
+ ): BrowserDetection {
107
+ return { chromium: detectChromium(pw), firefox: detectFirefox(pw) };
108
+ }
109
+
110
+ /**
111
+ * Chromium detection is keyed on the headless-shell binary, not the full
112
+ * browser: portal launches headless, and headless launches use
113
+ * `chromium_headless_shell-<rev>`, not the full `chromium-<rev>` binary that
114
+ * `executablePath()` reports — keying on the full browser would report
115
+ * "downloaded" in exactly the broken-cache scenario this command fixes
116
+ * (full chromium present, shell missing).
117
+ *
118
+ * ponytail: the directory-name transform is a heuristic on the documented
119
+ * cache layout (`chromium-<rev>/` ↔ `chromium_headless_shell-<rev>/`, same
120
+ * parent); the shell revision comes from playwright-core's `browsers.json`,
121
+ * not the chromium path, so the two may safely diverge. Correctness rests on
122
+ * the `INSTALLATION_COMPLETE` marker (what `playwright install` writes as
123
+ * its final step), which is immune to inner-layout renames. If playwright
124
+ * renames the shell *directory*, detection degrades to "missing" (safe) —
125
+ * resolve the shell directory through the registry only if that misfires.
126
+ */
127
+ function detectChromium(pw: typeof import("playwright")): boolean {
128
+ try {
129
+ const exec = pw.chromium.executablePath();
130
+ const revMatch = /([/\\])chromium-(\d+)[/\\]/.exec(exec);
131
+ if (!revMatch) return false;
132
+ let shellRev = revMatch[2];
133
+ try {
134
+ const req = createRequire(import.meta.url);
135
+ const coreDir = dirname(req.resolve("playwright-core/package.json"));
136
+ const browsers = JSON.parse(
137
+ readFileSync(join(coreDir, "browsers.json"), "utf8"),
138
+ ) as { browsers: Array<{ name: string; revision: number | string }> };
139
+ const entry = browsers.browsers.find(
140
+ (b) => b.name === "chromium-headless-shell",
141
+ );
142
+ // Independent revision field from chromium's — read the shell's own,
143
+ // fall back to the chromium revision if the read/entry fails.
144
+ if (entry) shellRev = String(entry.revision);
145
+ } catch {
146
+ /* keep chromium-revision fallback */
147
+ }
148
+ const shellDir = exec.replace(
149
+ /([/\\])chromium-\d+[/\\].*/,
150
+ `$1chromium_headless_shell-${shellRev}`,
151
+ );
152
+ // replace() returning the input means the layout didn't match.
153
+ return (
154
+ shellDir !== exec && existsSync(join(shellDir, "INSTALLATION_COMPLETE"))
155
+ );
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Firefox has no headless-shell split — one binary. Same
163
+ * INSTALLATION_COMPLETE marker as chromium: a partial download that has
164
+ * extracted the executable but not finished would otherwise false-report
165
+ * "downloaded".
166
+ */
167
+ function detectFirefox(pw: typeof import("playwright")): boolean {
168
+ try {
169
+ const exec = pw.firefox.executablePath();
170
+ const dirMatch = /[/\\]firefox-\d+[/\\]/.exec(exec);
171
+ if (!dirMatch) return false;
172
+ return existsSync(
173
+ join(
174
+ exec.slice(0, dirMatch.index + dirMatch[0].length),
175
+ "INSTALLATION_COMPLETE",
176
+ ),
177
+ );
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ // ─── Manual-command fallback ─────────────────────────────────────
184
+
185
+ function manualCommand(bundled: BundledPlaywright, engines: Engine[]): string {
186
+ return `Install: ${bundled.nodeExe} ${bundled.cliJs} install ${engines.join(" ")}`;
187
+ }
188
+
189
+ function genericManualCommand(): string {
190
+ // Bundled path unresolvable — only the generic advice remains.
191
+ return "Install: npx playwright install chromium firefox";
192
+ }
193
+
194
+ /**
195
+ * Emit a manual-command fallback everywhere a feedback channel exists.
196
+ * `notify` reaches the TUI and RPC clients but is a no-op in print/JSON
197
+ * (their UI context is a no-op stub), where stderr is the only channel —
198
+ * write there too so those modes still print the command.
199
+ */
200
+ function emitManualCommand(ctx: ExtensionContext, msg: string): void {
201
+ ctx.ui.notify(msg, "info");
202
+ if (!ctx.hasUI) process.stderr.write(`${msg}\n`);
203
+ }
204
+
205
+ // ─── Checklist dialog (copied from pi-lean-host select-picker.ts) ─
206
+ //
207
+ // pi's ctx.ui.select is single-choice and portal must not depend on host,
208
+ // so pickChecklist is copied here. Keep the dialog rendering visually
209
+ // identical to the host copy via the shared pickerTheme shape; the
210
+ // behavioral deltas are intentional: an Install confirm row, pre-checked
211
+ // rows, and an empty-selection guard (host resolves empty as []). UX
212
+ // changes should propagate to
213
+ // packages/pi-lean-host/core/select-picker.ts.
214
+
215
+ /** Shared SelectList theme callbacks — keep both pickers visually identical. */
216
+ function pickerTheme(theme: { fg(color: string, text: string): string }) {
217
+ return {
218
+ selectedPrefix: (text: string) => theme.fg("accent", text),
219
+ selectedText: (text: string) => theme.fg("accent", text),
220
+ description: (text: string) => theme.fg("muted", text),
221
+ scrollInfo: (text: string) => theme.fg("dim", text),
222
+ noMatch: (text: string) => theme.fg("warning", text),
223
+ };
224
+ }
225
+
226
+ const INSTALL_VALUE = "__install__";
227
+ const INSTALL_TITLE = "🌐 Select browsers to install";
228
+
229
+ /**
230
+ * Multi-select checklist: Enter toggles ✓/○, Enter on the Install row
231
+ * resolves the checked values, Esc returns undefined. Confirming with
232
+ * nothing checked notifies and keeps the dialog open. TUI-only — callers
233
+ * must guard with `ctx.mode === "tui"`.
234
+ */
235
+ export async function pickBrowsersToInstall(
236
+ ctx: ExtensionContext,
237
+ rows: SelectItem[],
238
+ preChecked: string[],
239
+ ): Promise<string[] | undefined> {
240
+ const checked = new Set<string>(preChecked);
241
+ return ctx.ui.custom<string[] | undefined>((tui, theme, _kb, done) => {
242
+ const container = new Container();
243
+ let selectedIndex = 0;
244
+ let list = buildList();
245
+
246
+ function buildList(): SelectList {
247
+ const selectItems: SelectItem[] = rows.map((r) => ({
248
+ ...r,
249
+ label: `${checked.has(r.value) ? "✓" : "○"} ${r.label}`,
250
+ }));
251
+ selectItems.push({
252
+ value: INSTALL_VALUE,
253
+ label: "Install — download the checked browsers",
254
+ });
255
+ const fresh = new SelectList(
256
+ selectItems,
257
+ Math.min(selectItems.length, 12),
258
+ pickerTheme(theme),
259
+ );
260
+ fresh.setSelectedIndex(Math.min(selectedIndex, selectItems.length - 1));
261
+ fresh.onSelectionChange = (item) => {
262
+ const at = selectItems.findIndex((s) => s.value === item.value);
263
+ if (at >= 0) selectedIndex = at;
264
+ };
265
+ fresh.onSelect = (item) => {
266
+ if (item.value === INSTALL_VALUE) {
267
+ if (checked.size === 0) {
268
+ ctx.ui.notify(
269
+ "Nothing selected — toggle a browser row first.",
270
+ "warning",
271
+ );
272
+ return;
273
+ }
274
+ done([...checked]);
275
+ return;
276
+ }
277
+ if (checked.has(item.value)) checked.delete(item.value);
278
+ else checked.add(item.value);
279
+ // Rebuild so the ✓/○ prefixes re-render; the highlight follows
280
+ // the tracked index across the swap.
281
+ container.removeChild(list);
282
+ list = buildList();
283
+ container.addChild(list);
284
+ // addChild appends — keep the footer below the rebuilt list.
285
+ container.removeChild(footer);
286
+ container.addChild(footer);
287
+ };
288
+ fresh.onCancel = () => done(undefined);
289
+ return fresh;
290
+ }
291
+
292
+ container.addChild(new Text(theme.fg("accent", theme.bold(INSTALL_TITLE))));
293
+ container.addChild(list);
294
+ const footer = new Text(
295
+ theme.fg("dim", "↑↓ navigate • enter toggle/select • esc cancel"),
296
+ );
297
+ container.addChild(footer);
298
+ return {
299
+ render(width: number) {
300
+ return container.render(width);
301
+ },
302
+ invalidate() {
303
+ container.invalidate();
304
+ },
305
+ handleInput(data: string) {
306
+ list.handleInput(data);
307
+ tui.requestRender();
308
+ },
309
+ };
310
+ });
311
+ }
312
+
313
+ // ─── Spawn & stream ──────────────────────────────────────────────
314
+
315
+ /**
316
+ * Run the bundled playwright CLI install for the given engines, streaming
317
+ * output into a widget above the editor. Never throws — spawn errors and non-zero
318
+ * exits notify with the manual command instead.
319
+ */
320
+ async function runInstall(
321
+ ctx: ExtensionContext,
322
+ bundled: BundledPlaywright,
323
+ engines: Engine[],
324
+ ): Promise<void> {
325
+ const manual = manualCommand(bundled, engines);
326
+ await new Promise<void>((resolve) => {
327
+ let out = "";
328
+ const child = spawn(bundled.nodeExe, [bundled.cliJs, "install", ...engines], {
329
+ stdio: "pipe",
330
+ });
331
+ // Bound the accumulated output: a long install must not grow memory
332
+ // without limit or re-split the whole buffer per chunk. The tail is
333
+ // enough — the host-validation warning is emitted near the end.
334
+ const MAX_TAIL = 64 * 1024;
335
+ // Show the last few download lines in a widget above the editor instead
336
+ // of the status bar: full progress visibility without crowding the
337
+ // footer. Piped (non-TTY) playwright prints a newline-terminated 10%-step
338
+ // progress line, so plain line splitting is enough; \r is handled for
339
+ // safety in case output shape changes.
340
+ const WIDGET_LINES = 8;
341
+ const tail = (chunk: Buffer) => {
342
+ out = (out + chunk.toString()).slice(-MAX_TAIL);
343
+ // ponytail: no progress-bar parsing — playwright's output format is
344
+ // not a stable API; the raw tail lines are enough.
345
+ const lines = out.trimEnd().split(/\r\n|\r|\n/);
346
+ ctx.ui.setWidget("install", [
347
+ `⬇ Installing ${engines.join(", ")}…`,
348
+ ...lines.slice(-WIDGET_LINES).map((l) => ` ${l.trim().slice(0, 120)}`),
349
+ ]);
350
+ };
351
+ child.stdout?.on("data", tail);
352
+ child.stderr?.on("data", tail);
353
+ // Node can fire both `error` and `close` for one failed spawn — settle
354
+ // once so the user gets a single notification.
355
+ let settled = false;
356
+ const finish = (notifyMsg: string, type: "info" | "warning") => {
357
+ if (settled) return;
358
+ settled = true;
359
+ ctx.ui.setWidget("install", undefined);
360
+ ctx.ui.notify(notifyMsg, type);
361
+ resolve();
362
+ };
363
+ child.on("error", (err) => {
364
+ // ENOENT, non-Node runtime with no `node` on PATH, etc.
365
+ finish(`Browser install failed: ${err.message}\n${manual}`, "warning");
366
+ });
367
+ child.on("close", (code) => {
368
+ if (code === 0) {
369
+ let msg = `✅ Installed: ${engines.join(", ")}`;
370
+ // The multi-line host-validation warning scrolls past in status
371
+ // text on exactly the fresh-Linux-host case where it matters —
372
+ // surface it in the completion notify instead. Still no
373
+ // --with-deps (needs root/apt): the warning names the exact
374
+ // install-deps command.
375
+ if (out.includes("Playwright Host validation warning")) {
376
+ msg +=
377
+ "\n⚠ Playwright reported missing host dependencies — run the install-deps command it printed (needs root).";
378
+ }
379
+ finish(msg, "info");
380
+ } else {
381
+ finish(`Browser install failed (exit code ${code}).\n${manual}`, "warning");
382
+ }
383
+ });
384
+ });
385
+ }
386
+
387
+ // ─── Handler ─────────────────────────────────────────────────────
388
+
389
+ /**
390
+ * Handle the `/web install` subcommand.
391
+ *
392
+ * @param sub The text after "install" ("" | "chromium" | "firefox")
393
+ */
394
+ export async function handleInstallSubcommand(
395
+ sub: string,
396
+ ctx: ExtensionContext,
397
+ ): Promise<void> {
398
+ const arg = sub.trim().toLowerCase();
399
+ const direct: Engine[] | null =
400
+ arg === "chromium" || arg === "firefox" ? [arg] : null;
401
+ if (arg !== "" && !direct) {
402
+ ctx.ui.notify(
403
+ `Unknown install sub-command: "${arg}". Usage: /web install [chromium|firefox]`,
404
+ "warning",
405
+ );
406
+ return;
407
+ }
408
+
409
+ // Lazy resolution — see resolveBundledPlaywright. On failure the bundled
410
+ // path doesn't exist to print, so only the generic command remains.
411
+ let bundled: BundledPlaywright;
412
+ try {
413
+ bundled = resolveBundledPlaywright();
414
+ } catch {
415
+ emitManualCommand(ctx, genericManualCommand());
416
+ return;
417
+ }
418
+
419
+ if (direct) {
420
+ // Direct form: spawn wherever a feedback channel exists — TUI and RPC
421
+ // (notify reaches the RPC client, so at least the completion message is
422
+ // observable); in print/JSON they're no-ops and the ~170 MB download
423
+ // would appear to hang, so print the manual command instead.
424
+ if (ctx.hasUI) {
425
+ await runInstall(ctx, bundled, direct);
426
+ } else {
427
+ emitManualCommand(ctx, manualCommand(bundled, direct));
428
+ }
429
+ return;
430
+ }
431
+
432
+ // Bare form: dialog in TUI only; every other mode (RPC included — the
433
+ // checklist dialog does not exist outside TUI) prints the manual command.
434
+ if (ctx.mode !== "tui") {
435
+ emitManualCommand(ctx, manualCommand(bundled, [...ENGINES]));
436
+ return;
437
+ }
438
+
439
+ // The dialog always opens, even when detection reports both downloaded:
440
+ // it cannot see a corrupted binary behind a valid-looking cache marker,
441
+ // so a forced redownload must stay reachable (downloaded rows are
442
+ // toggleable; missing ones come pre-checked).
443
+ const detection = detectInstalledBrowsers(bundled.pw);
444
+ const rows: SelectItem[] = ENGINES.map((engine) => ({
445
+ value: engine,
446
+ label: `${engine === "chromium" ? "Chromium" : "Firefox"} (${ENGINE_SIZES[engine]}) — ${detection[engine] ? "downloaded" : "missing"}`,
447
+ }));
448
+ const preChecked = ENGINES.filter((e) => !detection[e]);
449
+
450
+ const checked = await pickBrowsersToInstall(ctx, rows, preChecked);
451
+ if (checked === undefined) return; // Esc — user cancelled
452
+ if (checked.length === 0) return; // guarded in the dialog; defensive no-op
453
+ await runInstall(ctx, bundled, checked as Engine[]);
454
+ }
package/browser-status.ts CHANGED
@@ -10,6 +10,11 @@ import { sessionManager } from "./core/shared/session-manager.js";
10
10
  import { pluginRegistry } from "./core/plugin-registry.js";
11
11
  import { listProfiles } from "./browser-profile.js";
12
12
  import { isSessionProfile } from "./core/shared/storage-state.js";
13
+ import {
14
+ ENGINES,
15
+ detectInstalledBrowsers,
16
+ resolveBundledPlaywright,
17
+ } from "./browser-install.js";
13
18
 
14
19
  /**
15
20
  * Show detailed browser runtime status.
@@ -41,6 +46,22 @@ export function handleStatusSubcommand(
41
46
  }
42
47
  }
43
48
  msg += `\nPlugins: ${backendLines.join(", ")}`;
49
+
50
+ // Browser-binary presence for the Node backends, via the same detection
51
+ // /web install uses. Resolution failure (corrupt install) just omits the
52
+ // line — /web install reports the fallback command in that case.
53
+ try {
54
+ const bundled = resolveBundledPlaywright();
55
+ const detected = detectInstalledBrowsers(bundled.pw);
56
+ const states = ENGINES.map((e) => `${e} ${detected[e] ? "✓" : "✗"}`).join(
57
+ " ",
58
+ );
59
+ const anyMissing = ENGINES.some((e) => !detected[e]);
60
+ msg += `\nBrowsers: ${states}${anyMissing ? " (run /web install)" : ""}`;
61
+ } catch {
62
+ /* playwright unresolvable — omit */
63
+ }
64
+
44
65
  msg += `\nUse web-fetch for stateless HTTP fetches.`;
45
66
 
46
67
  if (active.length > 0) {
package/browser-toggle.ts CHANGED
@@ -2,18 +2,27 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { updateFooterStatus } from "./tools/utils.js";
6
- import {
7
- defineToolset,
8
- TOOLSET_EVENTS,
9
- getDefaultResolutionMode,
10
- } from "pi-tool-masking";
5
+ import { updateFooterStatus, getLastCtx, setLastCtx } from "./tools/utils.js";
6
+ import { defineToolset, TOOLSET_EVENTS } from "pi-tool-masking";
11
7
  import type { ToolsetSpec } from "pi-tool-masking";
12
8
 
13
- // Focus-mode guard: refuse actuating subcommands while allowlist focus is
14
- // holding the line (an upstream pi-tool-masking consumer).
15
- function isFocusHolding(): boolean {
16
- return getDefaultResolutionMode() === "allowlist";
9
+ // Catch AllowlistModeError by name, never `instanceof` — the handle may come
10
+ // from a different library copy via the shared registry. Returns true when
11
+ // the toggle ran, false when it was refused (nothing changed).
12
+ function refuseOnAllowlist(ctx: ExtensionContext, toggle: () => unknown): boolean {
13
+ try {
14
+ toggle();
15
+ return true;
16
+ } catch (err) {
17
+ if ((err as { name?: string } | undefined)?.name === "AllowlistModeError") {
18
+ ctx.ui.notify(
19
+ "Focus mode (allowlist) is active — this toolset can't be toggled while focus is holding the line. Exit focus there first.",
20
+ "warning",
21
+ );
22
+ return false;
23
+ }
24
+ throw err;
25
+ }
17
26
  }
18
27
 
19
28
  // ---- Toolset specs -----------------------------------------------
@@ -52,9 +61,6 @@ let _lastToggleState = true;
52
61
  /** @internal Last known learn state for status bar coloring. */
53
62
  let _lastLearnState = false;
54
63
 
55
- /** @internal Last captured ExtensionContext for event-driven status-bar rendering. */
56
- let _lastCtx: ExtensionContext | null = null;
57
-
58
64
  export function getToggleState(): boolean {
59
65
  return _lastToggleState;
60
66
  }
@@ -105,7 +111,6 @@ function restoreProfile(_pi: ExtensionAPI, ctx: ExtensionContext): void {
105
111
  export function resetToggleModuleState(): void {
106
112
  _lastToggleState = true;
107
113
  _lastLearnState = false;
108
- _lastCtx = null;
109
114
  _conversationDefaultProfile = undefined;
110
115
  }
111
116
 
@@ -125,8 +130,9 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
125
130
  const syncCachedState = () => {
126
131
  _lastToggleState = webToolset.isEnabled(pi);
127
132
  _lastLearnState = learnToolset.isEnabled(pi);
128
- if (_lastCtx) {
129
- updateFooterStatus(_lastCtx);
133
+ const ctx = getLastCtx();
134
+ if (ctx) {
135
+ updateFooterStatus(ctx);
130
136
  }
131
137
  };
132
138
 
@@ -137,40 +143,42 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
137
143
  pi.registerCommand("web", {
138
144
  description:
139
145
  "Enable/disable browser automation tools. " +
140
- "Usage: /web on | off | learn | status",
146
+ "Usage: /web on | off | learn | install | status",
141
147
  handler: async (args, ctx) => {
142
148
  const cmd = args.trim().toLowerCase();
143
149
 
144
- // Focus-mode guard: refuse actuating subcommands while allowlist
145
- // focus holds the line (an upstream pi-tool-masking consumer) —
146
- // a sibling toggle must not write a focus-indistinguishable
147
- // {enabled} entry.
148
- // Read-only subcommands (status/profile/cookies/bare /web) stay
149
- // unguarded, matching the focus controller's treatment of its own
150
- // read-only commands.
151
- if (["on", "off", "learn"].includes(cmd) && isFocusHolding()) {
152
- ctx.ui.notify(
153
- "Focus mode (allowlist) is active — this toolset can't be toggled while focus is holding the line. Exit focus there first.",
154
- "warning",
155
- );
156
- return;
157
- }
158
-
159
150
  if (cmd === "on") {
160
- webToolset.enable(pi);
161
- learnToolset.disable(pi);
151
+ if (
152
+ !refuseOnAllowlist(ctx, () => {
153
+ webToolset.enable(pi, ctx.sessionManager);
154
+ learnToolset.disable(pi, ctx.sessionManager);
155
+ })
156
+ )
157
+ return;
162
158
  ctx.ui.notify(
163
159
  "🌐 Browser tools enabled. /web learn to make web-learn available.",
164
160
  "info",
165
161
  );
166
162
  } else if (cmd === "learn") {
167
- learnToolset.enable(pi); // cascades web on via requires
163
+ if (
164
+ !refuseOnAllowlist(
165
+ ctx,
166
+ () => learnToolset.enable(pi, ctx.sessionManager), // cascades web on via requires
167
+ )
168
+ )
169
+ return;
168
170
  ctx.ui.notify(
169
171
  "📖 web-learn tool is now available. Agent will save/update guides when asked.",
170
172
  "info",
171
173
  );
172
174
  } else if (cmd === "off") {
173
- webToolset.disable(pi); // cascades learn off via requires
175
+ if (
176
+ !refuseOnAllowlist(
177
+ ctx,
178
+ () => webToolset.disable(pi, ctx.sessionManager), // cascades learn off via requires
179
+ )
180
+ )
181
+ return;
174
182
  ctx.ui.notify("🌐 Browser tools disabled. /web on to re-enable.", "info");
175
183
  } else if (cmd === "profile" || cmd.startsWith("profile ")) {
176
184
  const sub = cmd.slice("profile".length).trim();
@@ -187,6 +195,10 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
187
195
  const sub = cmd.slice("cookies".length).trim();
188
196
  const { handleCookiesSubcommand } = await import("./browser-cookies.js");
189
197
  await handleCookiesSubcommand(sub, ctx);
198
+ } else if (cmd === "install" || cmd.startsWith("install ")) {
199
+ const sub = cmd.slice("install".length).trim();
200
+ const { handleInstallSubcommand } = await import("./browser-install.js");
201
+ await handleInstallSubcommand(sub, ctx);
190
202
  } else if (cmd === "status") {
191
203
  const { handleStatusSubcommand } = await import("./browser-status.js");
192
204
  handleStatusSubcommand(
@@ -203,6 +215,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
203
215
  `📖 Learn mode: ${learnOn}\n` +
204
216
  ` /web profile manage browser profiles\n` +
205
217
  ` /web cookies inspect or clear session cookies\n` +
218
+ ` /web install install browser binaries (bundled playwright CLI)\n` +
206
219
  ` /web off disable all browser tools\n` +
207
220
  ` /web on enable browsing only\n` +
208
221
  ` /web learn enable browsing + guide-saving\n` +
@@ -217,17 +230,17 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
217
230
  // ── Session handlers: restore profile + render status bar ─
218
231
  pi.on("session_start", async (_event, ctx) => {
219
232
  restoreProfile(pi, ctx);
220
- _lastCtx = ctx;
233
+ setLastCtx(ctx);
221
234
  syncCachedState();
222
235
  });
223
236
 
224
237
  pi.on("session_tree", async (_event, ctx) => {
225
238
  restoreProfile(pi, ctx);
226
- _lastCtx = ctx;
239
+ setLastCtx(ctx);
227
240
  syncCachedState();
228
241
  });
229
242
 
230
243
  pi.on("session_shutdown", async () => {
231
- _lastCtx = null;
244
+ setLastCtx(null);
232
245
  });
233
246
  }
@@ -111,8 +111,6 @@ async function performFetch(
111
111
  redirect: "follow",
112
112
  });
113
113
 
114
- clearTimeout(timeoutId);
115
-
116
114
  if (!response.ok) {
117
115
  throw new Error(`HTTP ${response.status} ${response.statusText ?? ""}`);
118
116
  }
@@ -128,6 +126,8 @@ async function performFetch(
128
126
 
129
127
  return { title, needsJavaScript, root };
130
128
  } finally {
129
+ // Timer stays armed through the body read, so `timeout` bounds the
130
+ // whole request, not just the response headers.
131
131
  clearTimeout(timeoutId);
132
132
  }
133
133
  }
package/core/router.ts CHANGED
@@ -83,36 +83,6 @@ export interface NavigateOptions {
83
83
  piSessionId?: string;
84
84
  }
85
85
 
86
- // ─── Profile Change Callback ───────────────────────────────────────
87
-
88
- /**
89
- * Simple nullable callback for profile changes.
90
- * Used by index.ts to update the TUI status bar.
91
- */
92
- let _onProfileChange:
93
- | ((
94
- taskId: string,
95
- profileName?: string,
96
- profileMode?: "none" | "session" | "named",
97
- ) => void)
98
- | null = null;
99
-
100
- /**
101
- * Set the profile change callback.
102
- * Called once at extension startup from index.ts.
103
- */
104
- export function setOnProfileChange(
105
- handler:
106
- | ((
107
- taskId: string,
108
- profileName?: string,
109
- profileMode?: "none" | "session" | "named",
110
- ) => void)
111
- | null,
112
- ): void {
113
- _onProfileChange = handler;
114
- }
115
-
116
86
  // ─── Helpers ─────────────────────────────────────────────────────────
117
87
 
118
88
  /**
@@ -489,8 +459,13 @@ export async function navigate(
489
459
  resolvedProfileName = profileInput;
490
460
  }
491
461
 
492
- // ── Notify profile change callback ─────────────────────
493
- _onProfileChange?.(taskId, resolvedProfileName, profileMode);
462
+ // ── Notify profile change ─────────────────────
463
+ if (process.env.BROWSER_DEBUG) {
464
+ const parts = [`[browser] profile_changed: task=${taskId}`];
465
+ if (resolvedProfileName) parts.push(`profile=${resolvedProfileName}`);
466
+ if (profileMode) parts.push(`mode=${profileMode}`);
467
+ console.error(parts.join(" "));
468
+ }
494
469
 
495
470
  // ── Create session and navigate ───────────────────────────
496
471
  sessionManager.createSession(taskId, plugin.name);
@@ -2,8 +2,6 @@ import { getProfileLabel } from "./storage-state.js";
2
2
 
3
3
  /**
4
4
  * Session manager — tracks browser session lifecycle per task_id.
5
-
6
-
7
5
  *
8
6
  * Design: sessions track metadata; browsers and contexts are managed
9
7
  * entirely by the plugin. The session manager is Playwright-agnostic.
@@ -112,7 +110,7 @@ class SessionManager {
112
110
  this.#lastNav.delete(taskId);
113
111
  }
114
112
 
115
- async removeAll(): Promise<void> {
113
+ removeAll(): void {
116
114
  this.#sessions.clear();
117
115
  this.#lastNav.clear();
118
116
  }
package/index.ts CHANGED
@@ -15,7 +15,7 @@ import type { PythonBridgeConfig } from "./backends/python-adapter.js";
15
15
  import { sessionManager } from "./core/shared/session-manager.js";
16
16
  import { removeAllSnapshotFiles } from "./core/shared/snapshot-cache.js";
17
17
  import initBrowserToggle from "./browser-toggle.js";
18
- import { updateFooterStatus, getLastCtx, setLastCtx } from "./tools/utils.js";
18
+ import { updateFooterStatus, setLastCtx } from "./tools/utils.js";
19
19
  import { deleteSessionKey, resetTaskIds } from "./core/shared/task-id.js";
20
20
  import { resetToggleModuleState } from "./browser-toggle.js";
21
21
  import { registerGuideProvider } from "./core/guides.js";
@@ -214,23 +214,6 @@ export default function (pi: ExtensionAPI) {
214
214
  // --- Register commands ------------------------------------------
215
215
  initBrowserToggle(pi);
216
216
 
217
- // --- Profile change callback for TUI status updates ------------
218
- router.setOnProfileChange((taskId, profileName, profileMode) => {
219
- // Update TUI status bar on any profile lifecycle event
220
- const lastCtx = getLastCtx();
221
- if (lastCtx) {
222
- updateFooterStatus(lastCtx);
223
- }
224
-
225
- // Debug logging when BROWSER_DEBUG is set
226
- if (process.env.BROWSER_DEBUG) {
227
- const parts = [`[browser] profile_changed: task=${taskId}`];
228
- if (profileName) parts.push(`profile=${profileName}`);
229
- if (profileMode) parts.push(`mode=${profileMode}`);
230
- console.error(parts.join(" "));
231
- }
232
- });
233
-
234
217
  // --- Startup ----------------------------------------------------
235
218
  pi.on("session_start", async (_event, ctx) => {
236
219
  const pluginNames = pluginRegistry.available().join(", ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-portal",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Interactive Playwright web browsing for Pi. Chromium and Firefox built in; author stealth backends on either engine. Snapshots and screenshots cache to disk, saving context. Configurable profiles/cookies. Agents write site guides, auto-matched by domain.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -32,6 +32,7 @@
32
32
  "browser-profile.ts",
33
33
  "browser-cookies.ts",
34
34
  "browser-status.ts",
35
+ "browser-install.ts",
35
36
  "LICENSE",
36
37
  "backends/",
37
38
  "!backends/python-base/.venv/",
@@ -54,7 +55,7 @@
54
55
  },
55
56
  "dependencies": {
56
57
  "node-html-parser": "^6.1.0",
57
- "pi-tool-masking": "^1.3.0",
58
+ "pi-tool-masking": "^2.0.1",
58
59
  "playwright": "^1.60.0",
59
60
  "turndown": "^7.2.0"
60
61
  },
@@ -137,9 +137,13 @@ export const browserNavigateTool = defineTool({
137
137
  // If the browser executable is not installed, notify the user with install instructions.
138
138
  const isInstallError =
139
139
  result.error && result.error.toLowerCase().includes("not installed");
140
- if (isInstallError) {
140
+ // Python backends (chromium-py, firefox-py, stealth -py backends) report a
141
+ // missing venv Playwright wheel — their error already carries the accurate
142
+ // pip install hint, and /web install cannot fix that, so stay quiet.
143
+ const isPythonBackend = result.backendUsed?.endsWith("-py") ?? false;
144
+ if (isInstallError && !isPythonBackend) {
141
145
  ctx?.ui?.notify(
142
- "Browser not installed. Run: npx playwright install chromium firefox",
146
+ "Browser not installed. Run /web install to install it.",
143
147
  "warning",
144
148
  );
145
149
  }
package/tools/utils.ts CHANGED
@@ -29,10 +29,9 @@ export function contentText(
29
29
  // ─── Status bar ─────────────────────────────────────────────────
30
30
 
31
31
  /**
32
- * Retained reference to the most recent extension context for use by
33
- * the browser.profile event listener (which fires asynchronously from
34
- * tool execution contexts). Updated on session_start and cleared on
35
- * session_shutdown.
32
+ * Retained reference to the most recent extension context, so event
33
+ * handlers that fire outside a tool/ctx scope (e.g. browser-toggle's
34
+ * toolset changed/restored listeners) can still repaint the status bar.
36
35
  */
37
36
  let _lastCtx: {
38
37
  ui: {
@@ -62,12 +61,12 @@ export function updateFooterStatus(ctx: {
62
61
  ctx.ui.setStatus("browser", `${dot} ${body}`);
63
62
  }
64
63
 
65
- /** @internal — used by index.ts's profile event listener */
64
+ /** @internal — read by browser-toggle's event-driven status repaint */
66
65
  export function getLastCtx(): typeof _lastCtx {
67
66
  return _lastCtx;
68
67
  }
69
68
 
70
- /** @internal — set by index.ts on startup/shutdown */
69
+ /** @internal — updated on session_start/session_tree, cleared on shutdown */
71
70
  export function setLastCtx(ctx: typeof _lastCtx): void {
72
71
  _lastCtx = ctx;
73
72
  }