pi-lean-portal 0.5.0 β 0.6.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 +9 -9
- package/backends/chromium/index.ts +1 -1
- package/backends/firefox/index.ts +1 -1
- package/backends/playwright-base/playwright-plugin.ts +1 -1
- package/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
- package/backends/python-base/pi_browser_bridge/transport.py +6 -21
- package/browser-install.ts +454 -0
- package/browser-status.ts +21 -0
- package/browser-toggle.ts +13 -11
- package/core/fetch-backend.ts +2 -2
- package/core/router.ts +7 -32
- package/core/shared/session-manager.ts +1 -3
- package/index.ts +1 -18
- package/package.json +2 -1
- package/tools/browser-navigate.ts +6 -2
- package/tools/utils.ts +5 -6
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:**
|
|
229
|
-
> `firefox` backend. For the Python parity
|
|
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
|
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
306
|
-
#:
|
|
307
|
-
#:
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
521
|
+
if self._browser:
|
|
522
|
+
with contextlib.suppress(Exception):
|
|
523
523
|
self._browser.close()
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
978
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -1331,11 +1321,9 @@ class PlaywrightBridge:
|
|
|
1331
1321
|
|
|
1332
1322
|
new_url: str | None = None
|
|
1333
1323
|
new_title: str | None = None
|
|
1334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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,7 +2,7 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { updateFooterStatus } from "./tools/utils.js";
|
|
5
|
+
import { updateFooterStatus, getLastCtx, setLastCtx } from "./tools/utils.js";
|
|
6
6
|
import {
|
|
7
7
|
defineToolset,
|
|
8
8
|
TOOLSET_EVENTS,
|
|
@@ -52,9 +52,6 @@ let _lastToggleState = true;
|
|
|
52
52
|
/** @internal Last known learn state for status bar coloring. */
|
|
53
53
|
let _lastLearnState = false;
|
|
54
54
|
|
|
55
|
-
/** @internal Last captured ExtensionContext for event-driven status-bar rendering. */
|
|
56
|
-
let _lastCtx: ExtensionContext | null = null;
|
|
57
|
-
|
|
58
55
|
export function getToggleState(): boolean {
|
|
59
56
|
return _lastToggleState;
|
|
60
57
|
}
|
|
@@ -105,7 +102,6 @@ function restoreProfile(_pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
|
105
102
|
export function resetToggleModuleState(): void {
|
|
106
103
|
_lastToggleState = true;
|
|
107
104
|
_lastLearnState = false;
|
|
108
|
-
_lastCtx = null;
|
|
109
105
|
_conversationDefaultProfile = undefined;
|
|
110
106
|
}
|
|
111
107
|
|
|
@@ -125,8 +121,9 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
125
121
|
const syncCachedState = () => {
|
|
126
122
|
_lastToggleState = webToolset.isEnabled(pi);
|
|
127
123
|
_lastLearnState = learnToolset.isEnabled(pi);
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
const ctx = getLastCtx();
|
|
125
|
+
if (ctx) {
|
|
126
|
+
updateFooterStatus(ctx);
|
|
130
127
|
}
|
|
131
128
|
};
|
|
132
129
|
|
|
@@ -137,7 +134,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
137
134
|
pi.registerCommand("web", {
|
|
138
135
|
description:
|
|
139
136
|
"Enable/disable browser automation tools. " +
|
|
140
|
-
"Usage: /web on | off | learn | status",
|
|
137
|
+
"Usage: /web on | off | learn | install | status",
|
|
141
138
|
handler: async (args, ctx) => {
|
|
142
139
|
const cmd = args.trim().toLowerCase();
|
|
143
140
|
|
|
@@ -187,6 +184,10 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
187
184
|
const sub = cmd.slice("cookies".length).trim();
|
|
188
185
|
const { handleCookiesSubcommand } = await import("./browser-cookies.js");
|
|
189
186
|
await handleCookiesSubcommand(sub, ctx);
|
|
187
|
+
} else if (cmd === "install" || cmd.startsWith("install ")) {
|
|
188
|
+
const sub = cmd.slice("install".length).trim();
|
|
189
|
+
const { handleInstallSubcommand } = await import("./browser-install.js");
|
|
190
|
+
await handleInstallSubcommand(sub, ctx);
|
|
190
191
|
} else if (cmd === "status") {
|
|
191
192
|
const { handleStatusSubcommand } = await import("./browser-status.js");
|
|
192
193
|
handleStatusSubcommand(
|
|
@@ -203,6 +204,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
203
204
|
`π Learn mode: ${learnOn}\n` +
|
|
204
205
|
` /web profile manage browser profiles\n` +
|
|
205
206
|
` /web cookies inspect or clear session cookies\n` +
|
|
207
|
+
` /web install install browser binaries (bundled playwright CLI)\n` +
|
|
206
208
|
` /web off disable all browser tools\n` +
|
|
207
209
|
` /web on enable browsing only\n` +
|
|
208
210
|
` /web learn enable browsing + guide-saving\n` +
|
|
@@ -217,17 +219,17 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
217
219
|
// ββ Session handlers: restore profile + render status bar β
|
|
218
220
|
pi.on("session_start", async (_event, ctx) => {
|
|
219
221
|
restoreProfile(pi, ctx);
|
|
220
|
-
|
|
222
|
+
setLastCtx(ctx);
|
|
221
223
|
syncCachedState();
|
|
222
224
|
});
|
|
223
225
|
|
|
224
226
|
pi.on("session_tree", async (_event, ctx) => {
|
|
225
227
|
restoreProfile(pi, ctx);
|
|
226
|
-
|
|
228
|
+
setLastCtx(ctx);
|
|
227
229
|
syncCachedState();
|
|
228
230
|
});
|
|
229
231
|
|
|
230
232
|
pi.on("session_shutdown", async () => {
|
|
231
|
-
|
|
233
|
+
setLastCtx(null);
|
|
232
234
|
});
|
|
233
235
|
}
|
package/core/fetch-backend.ts
CHANGED
|
@@ -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
|
|
493
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.6.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/",
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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 β
|
|
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 β
|
|
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
|
}
|