pi-lean-dimension 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.
Files changed (104) hide show
  1. package/README.md +5 -3
  2. package/node_modules/pi-lean-host/AGENTS.md +13 -6
  3. package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
  4. package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
  5. package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
  6. package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
  7. package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
  8. package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
  9. package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
  10. package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
  11. package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
  12. package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
  13. package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
  14. package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
  15. package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
  16. package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
  17. package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
  18. package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
  19. package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
  20. package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
  21. package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
  22. package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
  23. package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
  24. package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
  25. package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
  26. package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
  27. package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
  28. package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
  29. package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
  30. package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
  31. package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
  32. package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
  33. package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
  34. package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
  35. package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
  36. package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
  37. package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
  38. package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
  39. package/node_modules/pi-lean-host/core/auth.ts +2 -1
  40. package/node_modules/pi-lean-host/core/helpers.ts +29 -35
  41. package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
  42. package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
  43. package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
  44. package/node_modules/pi-lean-host/core/transport.ts +1 -1
  45. package/node_modules/pi-lean-host/package.json +1 -1
  46. package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
  47. package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
  48. package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
  49. package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
  50. package/node_modules/pi-lean-host/tools/utils.ts +69 -0
  51. package/node_modules/pi-lean-portal/AGENTS.md +4 -3
  52. package/node_modules/pi-lean-portal/README.md +9 -9
  53. package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
  54. package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
  55. package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
  56. package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
  57. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
  58. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
  59. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
  60. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  62. package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
  63. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
  64. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
  65. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
  66. package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
  67. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
  68. package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
  69. package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
  70. package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
  71. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
  72. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
  77. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
  84. package/node_modules/pi-lean-portal/browser-install.ts +454 -0
  85. package/node_modules/pi-lean-portal/browser-status.ts +21 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
  87. package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
  88. package/node_modules/pi-lean-portal/core/router.ts +7 -32
  89. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
  90. package/node_modules/pi-lean-portal/index.ts +1 -18
  91. package/node_modules/pi-lean-portal/package.json +2 -1
  92. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
  93. package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
  94. package/node_modules/pi-lean-search/AGENTS.md +5 -3
  95. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
  96. package/node_modules/pi-lean-search/index.ts +0 -3
  97. package/node_modules/pi-lean-search/package.json +1 -1
  98. package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
  99. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  100. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  101. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  102. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  103. package/node_modules/yaml/package.json +1 -1
  104. package/package.json +4 -4
@@ -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
+ }
@@ -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) {
@@ -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
- if (_lastCtx) {
129
- updateFooterStatus(_lastCtx);
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
- _lastCtx = ctx;
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
- _lastCtx = ctx;
228
+ setLastCtx(ctx);
227
229
  syncCachedState();
228
230
  });
229
231
 
230
232
  pi.on("session_shutdown", async () => {
231
- _lastCtx = null;
233
+ setLastCtx(null);
232
234
  });
233
235
  }
@@ -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
  }
@@ -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
  }