@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 +10 -0
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/browser-headers.ts +44 -0
- package/src/env.ts +10 -0
- package/src/fetch-markdown.ts +2 -2
- package/src/web-browser/feeds.ts +52 -0
- package/src/web-browser/index.ts +33 -11
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.
|
|
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
|
+
}
|
package/src/fetch-markdown.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/web-browser/index.ts
CHANGED
|
@@ -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
|
|
203
|
+
export function browserLabel(url: string): string {
|
|
202
204
|
try {
|
|
203
|
-
const parsed = new URL(
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|