@underactive/pi-topping-web-tools 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1] - 2026-08-31
4
+
5
+ ### Added
6
+
7
+ - `web_browser`: publishes browser state through the `pi-topping-web-tools/browser` custom session feed for statusline consumers. Truthy `PI_SUPPRESS_NOTIFICATIONS` values hide the native browser footer without suppressing feed entries.
8
+
9
+ ### Changed
10
+
11
+ - `fetch_markdown`, `pdf_extract`: send browser-like HTTP headers (Accept, Accept-Language, User-Agent, Sec-Fetch-*, etc.) to reduce blocking by User-Agent-based filters; `PI_FETCH_USER_AGENT` overrides the default User-Agent.
12
+
3
13
  ## [0.2.0] - 2026-08-18
4
14
 
5
15
  ### Added
package/README.md CHANGED
@@ -143,6 +143,27 @@ Commands:
143
143
 
144
144
  Status line: a footer segment (`browser: example.com`) appears while the browser is open.
145
145
 
146
+ ### Statusline feed
147
+
148
+ `web_browser` also publishes its current state as a pi custom session entry, so [pi-topping-statusline](https://github.com/underactive/pi-topping-statusline) can render it without depending on this package:
149
+
150
+ - Type: `pi-topping-web-tools/browser`
151
+ - `label` (`text`): current hostname, `local file`, `ready`, or an empty string when closed
152
+ - `open` (`number`): `1` when open, `0` when closed
153
+
154
+ For example, subscribe to `label` with prefix `browser: ` and `text` format:
155
+
156
+ ```json
157
+ {
158
+ "type": "pi-topping-web-tools/browser",
159
+ "field": "label",
160
+ "prefix": "browser: ",
161
+ "format": "text"
162
+ }
163
+ ```
164
+
165
+ Entries are published only when the state changes, except that the current state is republished immediately at session start and once after the first turn. Closing the browser publishes `{ "label": "", "open": 0 }` so consumers can clear stale labels. Set `PI_SUPPRESS_NOTIFICATIONS` to `1`, `true`, or `yes` (case-insensitive) to hide the native footer; feed publication continues.
166
+
146
167
  ## Saved permissions
147
168
 
148
169
  When the host confirmation prompt offers a durable option ("Allow for 1 day", "Allow for 1 week", "Allow for 30 days"), the grant is persisted to `~/.pi/agent/web-permissions.json` (mode `0600`) and applies across all sessions and projects.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-web-tools",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pi extensions for the web: fetch_markdown (URL to markdown, cached, keyless), pdf_extract (PDF text extraction) and web_browser (headless Playwright automation), sharing a preapproved-host allowlist.",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Browser-like HTTP headers shared by fetch_markdown and pdf_extract.
3
+ *
4
+ * Sending these instead of bare-minimum tool-identified headers reduces
5
+ * the chance of being blocked by WAFs and CDNs that filter non-browser
6
+ * User-Agents or missing auxiliary headers.
7
+ *
8
+ * The header set mimics Chrome ~122 on macOS, omitting Sec-Ch-Ua* (which
9
+ * would need to stay in sync with the Chrome version) and Accept-Encoding
10
+ * (Node's native fetch() adds its own).
11
+ */
12
+
13
+ const DEFAULT_HEADERS: Record<string, string> = {
14
+ Accept:
15
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
16
+ "Accept-Language": "en-US,en;q=0.9",
17
+ "Upgrade-Insecure-Requests": "1",
18
+ "User-Agent":
19
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
20
+ "Sec-Fetch-Dest": "document",
21
+ "Sec-Fetch-Mode": "navigate",
22
+ "Sec-Fetch-Site": "none",
23
+ "Sec-Fetch-User": "?1",
24
+ DNT: "1",
25
+ Priority: "u=0, i",
26
+ };
27
+
28
+ const USER_AGENT_OVERRIDE: string | undefined = process.env.PI_FETCH_USER_AGENT || undefined;
29
+
30
+ /**
31
+ * Return a set of browser-like HTTP headers.
32
+ *
33
+ * When `PI_FETCH_USER_AGENT` is set in the environment, its value replaces
34
+ * the default Chrome User-Agent while all other headers remain unchanged.
35
+ *
36
+ * @param userAgentOverride - optional explicit User-Agent to use instead of
37
+ * the default Chrome macOS UA. Takes precedence over the env var when both
38
+ * are present.
39
+ */
40
+ export function getBrowserHeaders(userAgentOverride?: string): Record<string, string> {
41
+ const ua = userAgentOverride ?? USER_AGENT_OVERRIDE;
42
+ if (!ua) return DEFAULT_HEADERS;
43
+ return { ...DEFAULT_HEADERS, "User-Agent": ua };
44
+ }
package/src/env.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Same truthiness rules as pi core's isTruthyEnvFlag.
2
+ export function isTruthyEnvFlag(value: string | undefined): boolean {
3
+ if (!value) return false;
4
+ return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
5
+ }
6
+
7
+ /** Set when pi suppresses non-essential notifications. */
8
+ export function suppressNotifications(env: NodeJS.ProcessEnv = process.env): boolean {
9
+ return isTruthyEnvFlag(env.PI_SUPPRESS_NOTIFICATIONS);
10
+ }
@@ -9,6 +9,7 @@ import { Readability } from "@mozilla/readability";
9
9
  import { parseHTML } from "linkedom";
10
10
  import { STATUS_CODES } from "node:http";
11
11
  import { combineSignals } from "./abort-utils.ts";
12
+ import { getBrowserHeaders } from "./browser-headers.ts";
12
13
  import { isPreapprovedHost, permissionKey } from "./permissions.ts";
13
14
  import { requestHostPermission } from "./permission-prompt.ts";
14
15
  import { MAX_URL_LENGTH, isLocalOrPrivateHost, upgradeHttpToHttps } from "./web-browser/permissions.ts";
@@ -207,8 +208,7 @@ export async function getWithPermittedRedirects(
207
208
  signal,
208
209
  redirect: "manual",
209
210
  headers: {
210
- Accept: "text/markdown, text/html, */*",
211
- "User-Agent": "pi-fetch-markdown/1.0",
211
+ ...getBrowserHeaders(),
212
212
  ...extraHeaders,
213
213
  },
214
214
  });
@@ -0,0 +1,52 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const BROWSER_FEED_TYPE = "pi-topping-web-tools/browser";
4
+
5
+ export interface BrowserFeedPayload {
6
+ label: string;
7
+ open: 0 | 1;
8
+ }
9
+
10
+ let extension: ExtensionAPI | undefined;
11
+ let latestPayload: BrowserFeedPayload | undefined;
12
+ let emittedPayload: BrowserFeedPayload | undefined;
13
+ let publishAfterFirstTurn = false;
14
+
15
+ function samePayload(left: BrowserFeedPayload | undefined, right: BrowserFeedPayload): boolean {
16
+ return left?.label === right.label && left.open === right.open;
17
+ }
18
+
19
+ function emitBrowserState(force = false): void {
20
+ if (!extension || !latestPayload || (!force && samePayload(emittedPayload, latestPayload))) return;
21
+ try {
22
+ extension.appendEntry(BROWSER_FEED_TYPE, latestPayload);
23
+ emittedPayload = latestPayload;
24
+ } catch {
25
+ // Feed publication is best effort; leave emitted state unchanged for retry.
26
+ }
27
+ }
28
+
29
+ export function registerBrowserFeeds(pi: ExtensionAPI): void {
30
+ extension = pi;
31
+ pi.on("session_start", () => {
32
+ emittedPayload = undefined;
33
+ publishAfterFirstTurn = true;
34
+ });
35
+ pi.on("turn_end", () => {
36
+ if (!publishAfterFirstTurn) return;
37
+ publishAfterFirstTurn = false;
38
+ emitBrowserState(true);
39
+ });
40
+ }
41
+
42
+ export function publishBrowserState(label: string, open: boolean | number): void {
43
+ latestPayload = { label, open: open ? 1 : 0 };
44
+ emitBrowserState();
45
+ }
46
+
47
+ export function resetBrowserFeedState(): void {
48
+ extension = undefined;
49
+ latestPayload = undefined;
50
+ emittedPayload = undefined;
51
+ publishAfterFirstTurn = false;
52
+ }
@@ -32,6 +32,8 @@ import { checkUrlPermission, upgradeHttpToHttps, validateURL } from "./permissio
32
32
  import { requestHostPermission } from "../permission-prompt.ts";
33
33
  import { listGrants } from "../permission-store.ts";
34
34
  import { isInsideCwd } from "../pdf-extract.ts";
35
+ import { suppressNotifications } from "../env.ts";
36
+ import { publishBrowserState, registerBrowserFeeds } from "./feeds.ts";
35
37
 
36
38
  const ACTIONS = [
37
39
  "navigate",
@@ -198,21 +200,41 @@ function browserStatus(ctx: ExtensionContext, text: string, tone: "success" | "m
198
200
  return ctx.ui.theme.fg(tone, `browser: ${text}`);
199
201
  }
200
202
 
201
- function updateBrowserStatus(ctx: ExtensionContext, info: { url: string; title?: string }): void {
203
+ export function browserLabel(url: string): string {
202
204
  try {
203
- const parsed = new URL(info.url);
204
- const label = parsed.protocol === "file:" ? "local file" : parsed.hostname;
205
- ctx.ui.setStatus("browser", browserStatus(ctx, label, "success"));
205
+ const parsed = new URL(url);
206
+ return parsed.protocol === "file:" ? "local file" : parsed.hostname || "ready";
206
207
  } catch {
207
- ctx.ui.setStatus("browser", browserStatus(ctx, "ready", "muted"));
208
+ return "ready";
209
+ }
210
+ }
211
+
212
+ function syncBrowserOpen(ctx: ExtensionContext, url: string): void {
213
+ const label = browserLabel(url);
214
+ publishBrowserState(label, 1);
215
+ if (!suppressNotifications()) {
216
+ ctx.ui.setStatus("browser", browserStatus(ctx, label, label === "ready" ? "muted" : "success"));
217
+ }
218
+ }
219
+
220
+ function syncBrowserClosed(ctx: ExtensionContext): void {
221
+ publishBrowserState("", 0);
222
+ if (!suppressNotifications()) {
223
+ ctx.ui.setStatus("browser", undefined);
208
224
  }
209
225
  }
210
226
 
211
227
  export default function (pi: ExtensionAPI) {
212
228
  const sessionPermissions = new Map<string, "allow" | "deny">();
229
+ registerBrowserFeeds(pi);
213
230
 
214
231
  pi.on("session_start", async (_event, ctx) => {
215
- ctx.ui.setStatus("browser", undefined);
232
+ const info = await getBrowserManager().getPageInfoAsync();
233
+ if (info.isOpen) {
234
+ syncBrowserOpen(ctx, info.url);
235
+ } else {
236
+ syncBrowserClosed(ctx);
237
+ }
216
238
  });
217
239
 
218
240
  pi.on("session_shutdown", async () => {
@@ -271,7 +293,7 @@ export default function (pi: ExtensionAPI) {
271
293
  description: "Force close the headless browser",
272
294
  handler: async (_args, ctx) => {
273
295
  await closeBrowserManager();
274
- ctx.ui.setStatus("browser", undefined);
296
+ syncBrowserClosed(ctx);
275
297
  ctx.ui.notify("Browser closed", "info");
276
298
  },
277
299
  });
@@ -442,7 +464,7 @@ export default function (pi: ExtensionAPI) {
442
464
  try {
443
465
  if (action === "close") {
444
466
  await closeBrowserManager();
445
- ctx.ui.setStatus("browser", undefined);
467
+ syncBrowserClosed(ctx);
446
468
  return {
447
469
  content: [{ type: "text", text: "Browser closed." }],
448
470
  details: { action } satisfies WebBrowserDetails,
@@ -470,7 +492,7 @@ export default function (pi: ExtensionAPI) {
470
492
 
471
493
  const upgraded = upgradeHttpToHttps(params.url);
472
494
  const result: NavigateResult = await mgr.navigate(upgraded, { timeout, signal });
473
- updateBrowserStatus(ctx, result);
495
+ syncBrowserOpen(ctx, result.url);
474
496
 
475
497
  const text = `Navigated to ${result.url}\nTitle: ${result.title}${result.statusCode ? `\nStatus: ${result.statusCode}` : ""}`;
476
498
  return {
@@ -494,7 +516,7 @@ export default function (pi: ExtensionAPI) {
494
516
  signal,
495
517
  });
496
518
  const info = await mgr.getPageInfoAsync();
497
- updateBrowserStatus(ctx, info);
519
+ syncBrowserOpen(ctx, info.url);
498
520
  const dims = `${shot.width}×${shot.height}`;
499
521
  const dimsNote = `${dims}${shot.fullPage ? " (full page)" : ""}`;
500
522
 
@@ -940,7 +962,7 @@ export default function (pi: ExtensionAPI) {
940
962
  }
941
963
  const msg = await mgr.switchTab(params.index);
942
964
  const info = await mgr.getPageInfoAsync();
943
- updateBrowserStatus(ctx, info);
965
+ syncBrowserOpen(ctx, info.url);
944
966
  return {
945
967
  content: [{ type: "text", text: msg }],
946
968
  details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,