@underactive/pi-topping-web-tools 0.2.3 → 0.2.5

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
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.5] - 2026-09-30
6
+
7
+ ### Changed
8
+
9
+ - Tested against pi 0.99.2. No break: the 0.99.1 to 0.99.2 extension API is additive only (`ToolNamespace.instructions`), and `pi-tui` and `pi-agent-core` declarations are unchanged.
10
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 0.99.1 to 0.99.2.
11
+
12
+ ### Fixed
13
+
14
+ - Bounded tool result and status previews. A result that is one long logical line — minified JSON, raw HTML from `get_content` — rendered as a single unbounded logical line and word-wrapped into ~1250 screen lines. An expanded preview now keeps 10 logical lines of 200 characters, and a collapsed result whose `details` cannot be rendered is clipped to 200 characters.
15
+ - `fetch_markdown`, `pdf_extract`, `web_browser`: attacker-controlled header fields are clipped to 200 characters before display. A hostile server could previously send a multi-megabyte HTTP reason phrase, and a page could carry a huge `<title>`; either one filled the screen from the collapsed one-line summary.
16
+
17
+ ## [0.2.4] - 2026-09-29
18
+
19
+ ### Added
20
+
21
+ - Documented pi 0.87+ `inputLimits.images.resize` for `web_browser` inline screenshots: how the active model's resize profile applies to tool-result images, a cost-saving example profile, the resize note appended to resized images, and a warning that aggressive resizing can make small page text unreadable.
22
+ - `fetch_markdown`, `pdf_extract`, `web_browser`: declare pi 0.99 tool `annotations` so permission extensions can decide which calls to confirm. `fetch_markdown` and `pdf_extract` set `readOnlyHint` and `openWorldHint`; `web_browser` sets `openWorldHint` and `readOnlyHint: false`. Older pi versions ignore the field.
23
+
24
+ ### Changed
25
+
26
+ - Tested against pi 0.99; host-permission gates also apply to codemode script calls.
27
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 0.86.0 to 0.99.1 so `npm run typecheck` accepts tool `annotations`.
28
+
5
29
  ## [0.2.3] - 2026-09-20
6
30
 
7
31
  ### Changed
package/README.md CHANGED
@@ -54,6 +54,13 @@ Start with `fetch_markdown` for efficient static content; use `web_browser` when
54
54
  | `pdf_extract` | Extract the text layer from a remote or local PDF, with page markers |
55
55
  | `web_browser` | Headless Chromium automation — navigate, click, type, screenshot, evaluate, logs, cookies |
56
56
 
57
+ All three tools are `direct`-exposure and pi activates those on registration (`defaultActive` defaults to true), so nothing needs to be enabled.
58
+
59
+ ### Enabling and disabling tools
60
+
61
+ - To hide a tool for a session, name it in `--exclude-tools` (or start with `--no-tools` and re-add what you want with `--tools`).
62
+ - To change the startup set in your `settings.json`, use `defaultTools`. Bare names **replace** pi's defaults, so write `"+fetch_markdown"` to add a tool without dropping `read`, `bash`, `edit`, and `write`. On pi 0.99.2+, `/reload` activates tools **newly added** to `defaultTools` without a restart. The reverse does not follow: a tool **removed** from `defaultTools` stays active until you restart, and a tool you turned off during the session stays off. `--tools`, `--no-tools`, and `--no-builtin-tools` still override the setting, on reload as well.
63
+
57
64
  ## `fetch_markdown`
58
65
 
59
66
  Complements pi's built-in search-backed `web_fetch` with a lightweight, keyless fetch path for public documentation, READMEs, and articles:
@@ -129,6 +136,47 @@ Single tool with an `action` parameter:
129
136
 
130
137
  Optional `timeout` (ms, default 30000) applies to navigation and to selector-based actions (click, type, hover, select_option, screenshot, get_text, get_markdown, wait_for, get_accessibility_snapshot, scroll, drag, upload_file). Other actions use Playwright defaults.
131
138
 
139
+ ### Screenshots and image resize
140
+
141
+ When `screenshot` returns an inline result (`toFile` omitted or `false`), `web_browser` places the captured PNG in the conversation as a base64 image block. Base64 adds roughly one-third to the payload, and full-page PNGs can be several MiB. On pi 0.87+, the active model's `inputLimits.images.resize` profile is applied to these tool-result images before they enter history; the same profile covers `@file` attachments, `read`, and images returned by other tools. Images are encoded once when they enter history, so changing models later does not rewrite historical images.
142
+
143
+ `toFile=true` and `/browser-screenshot` save the original PNG to disk and put only the path and dimensions in the tool result. The file is resized only if a later `read` or `@file` attachment sends it to the model.
144
+
145
+ The resize fields are:
146
+
147
+ - `maxWidth` / `maxHeight`: pixel bounds (pi defaults to 2000×2000)
148
+ - `maxBytes`: maximum base64-encoded payload size (pi defaults to 4.5 MiB)
149
+ - `jpegQuality`: JPEG quality when the PNG does not fit under `maxBytes` (pi defaults to 80)
150
+
151
+ The default capture is the viewport (1280×720 unless `set_viewport` changed it); `fullPage: true` captures the whole document. `web_browser` applies a 1920px maximum-width clamp to the reported dimensions of full-page screenshots; this is metadata only, not image resizing—the returned PNG remains the original capture. Tall full-page screenshots regularly exceed the default 2000px height bound and are scaled before they enter history; for normal viewport screenshots, `maxBytes` is usually the effective cost lever.
152
+
153
+ Configure a profile per model in `~/.pi/agent/models.json`; use the provider and model ID shown by `pi --list-models`. `modelOverrides.inputLimits` is deep-merged, so a resize override can be scoped to one model:
154
+
155
+ ```json
156
+ {
157
+ "providers": {
158
+ "<provider>": {
159
+ "modelOverrides": {
160
+ "<model-id>": {
161
+ "inputLimits": {
162
+ "images": {
163
+ "resize": {
164
+ "maxWidth": 1568,
165
+ "maxHeight": 1568,
166
+ "maxBytes": 524288,
167
+ "jpegQuality": 75
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
178
+ This matches pi's documented example. It leaves a default 1280×720 viewport screenshot unscaled on the long edge, making `maxBytes` the practical size lever. When pi resizes an image, it adds a coordinate note such as `[Image: original 1280x4000, displayed at 800x2500. Multiply coordinates by 1.60 to map to original image.]`; the tool's own `Screenshot <dims>` line is only a summary of the capture, so the resize note is the authoritative account of the dimensions sent to the model. An overly tight `maxBytes` does not fail loudly: pi keeps shrinking dimensions until the encoded image fits. Aggressive values can make small page text (navigation labels, fine print, or code) unreadable, and JPEG fallback adds artifacts; prefer `get_text`, `get_markdown`, or `get_accessibility_snapshot` when the task is reading copy. Set `images.autoResize: false` in pi's settings to disable resizing globally.
179
+
132
180
  ### Browser network egress
133
181
 
134
182
  `web_browser` enforces its permissions inside every fresh browser context:
@@ -197,6 +245,7 @@ Commands:
197
245
 
198
246
  - **Preapproved hosts**: common documentation and dev sites are allowed without prompting. The allowlist lives in `src/permissions.ts` and is shared by both tools.
199
247
  - **User confirmation**: other hosts prompt Allow once / Allow for this session / Allow for 1 day / Allow for 1 week / Allow for 30 days / Deny. For `web_browser` navigation, Allow once becomes a browser-session grant because request-level enforcement must authorize the loaded document's later requests.
248
+ - **Tool annotations**: on pi 0.99+, `fetch_markdown` and `pdf_extract` declare `readOnlyHint` and `openWorldHint`, and `web_browser` declares `openWorldHint` with `readOnlyHint: false`, for permission extensions that confirm calls by hint. The host confirmation above applies regardless of how another extension treats the hints.
200
249
  - **URL validation**: all tools reject embedded credentials and overlong URLs. `fetch_markdown` and `pdf_extract` additionally reject loopback, private, and link-local hosts and any non-HTTP(S) scheme on a `url`. `web_browser` accepts `localhost`, private IPs, and `file://` paths so it can drive local dev servers and local HTML — each still prompts for confirmation unless session-approved or covered by a saved durable grant.
201
250
  - **Local file resolution**: `pdf_extract` resolves a `path` through `realpath` before any check, so a symlink pointing outside the working directory is treated as outside and prompts. Directories, FIFOs, and devices are rejected, and the size limit is enforced from `stat` before any bytes are read. `web_browser` `upload_file` follows the same rule: files outside the working directory prompt for confirmation (session-scoped) before they are read.
202
251
  - **Isolated browser context and routed egress**: each session uses a fresh Chromium context (no shared profile/cookies with your system browser). HTTP(S) requests, redirect targets, client navigations, popups, and WebSocket connections are checked against live `web_browser` permissions; service workers are disabled.
@@ -206,7 +255,7 @@ Commands:
206
255
  ## Limitations
207
256
 
208
257
  - `web_browser` requires the Chromium download.
209
- - Screenshots consume significant context on vision models — prefer `get_text`, `get_markdown`, or `get_accessibility_snapshot`.
258
+ - Screenshots consume significant context on vision models — prefer `get_text`, `get_markdown`, or `get_accessibility_snapshot`. On pi 0.87+, a model-side resize profile ([Screenshots and image resize](#screenshots-and-image-resize)) can cut screenshot request size and cost; aggressive resizing can make small page text unreadable.
210
259
  - Network logs capture blocked/failed requests and 4xx/5xx responses, not successful traffic.
211
260
  - Request routing buffers HTTP(S) responses and blocks redirected subresources, so streaming endpoints and resources that rely on redirects may not behave like an unrestricted browser.
212
261
  - Playwright routing covers HTTP(S) and WebSockets, not browser transports such as WebRTC; OS-level egress sandboxing remains outside this extension's scope.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-web-tools",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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",
@@ -53,9 +53,9 @@
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-ai": "0.86.0",
57
- "@earendil-works/pi-coding-agent": "0.86.0",
58
- "@earendil-works/pi-tui": "0.86.0",
56
+ "@earendil-works/pi-ai": "0.99.2",
57
+ "@earendil-works/pi-coding-agent": "0.99.2",
58
+ "@earendil-works/pi-tui": "0.99.2",
59
59
  "@types/node": "^24.0.0",
60
60
  "@types/turndown": "^5.0.5",
61
61
  "typebox": "^1.3.3",
@@ -12,6 +12,7 @@ import { combineSignals } from "./abort-utils.ts";
12
12
  import { getBrowserHeaders } from "./browser-headers.ts";
13
13
  import { isPreapprovedHost, permissionKey } from "./permissions.ts";
14
14
  import { requestHostPermission } from "./permission-prompt.ts";
15
+ import { clipField, resultPreview, resultText } from "./render-preview.ts";
15
16
  import { MAX_URL_LENGTH, isLocalOrPrivateHost, upgradeHttpToHttps } from "./web-browser/permissions.ts";
16
17
 
17
18
  const MAX_HTTP_CONTENT_LENGTH = 25 * 1024 * 1024;
@@ -476,6 +477,7 @@ Usage notes:
476
477
  "If fetch_markdown reports truncated content, call it again with the reported offset to read the next window.",
477
478
  ],
478
479
  parameters: WebFetchParams,
480
+ annotations: { readOnlyHint: true, openWorldHint: true },
479
481
 
480
482
  async execute(_toolCallId, params, signal, onUpdate) {
481
483
  const start = Date.now();
@@ -556,12 +558,12 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
556
558
  }
557
559
 
558
560
  if (!details || typeof details.bytes !== "number") {
559
- const content = result.content[0];
560
- return new Text(content?.type === "text" ? content.text : "", 0, 0);
561
+ return new Text(clipField(resultText(result.content)), 0, 0);
561
562
  }
562
563
 
563
564
  let text = theme.fg("success", `Received ${formatSize(details.bytes)}`);
564
- text += theme.fg("muted", ` (${details.code} ${details.codeText})`);
565
+ // The HTTP reason phrase is attacker-controlled, so bound it like the body.
566
+ text += theme.fg("muted", ` (${details.code} ${clipField(details.codeText)})`);
565
567
  if (details.cached) {
566
568
  text += theme.fg("dim", " cached");
567
569
  }
@@ -570,15 +572,12 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
570
572
  }
571
573
 
572
574
  if (expanded) {
573
- const content = result.content[0];
574
- if (content?.type === "text") {
575
- const lines = content.text.split("\n", 16);
576
- for (const line of lines.slice(0, 15)) {
577
- text += `\n${theme.fg("dim", line)}`;
578
- }
579
- if (lines.length > 15) {
580
- text += `\n${theme.fg("muted", "… (truncated)")}`;
581
- }
575
+ const preview = resultPreview(resultText(result.content));
576
+ for (const line of preview.lines) {
577
+ text += `\n${theme.fg("dim", line)}`;
578
+ }
579
+ if (preview.truncated) {
580
+ text += `\n${theme.fg("muted", "… (truncated)")}`;
582
581
  }
583
582
  }
584
583
 
@@ -11,6 +11,7 @@ import { extractText, getDocumentProxy } from "unpdf";
11
11
  import { combineSignals } from "./abort-utils.ts";
12
12
  import { isPreapprovedHost, permissionKey } from "./permissions.ts";
13
13
  import { requestHostPermission } from "./permission-prompt.ts";
14
+ import { clipField, resultPreview, resultText } from "./render-preview.ts";
14
15
  import { getWithPermittedRedirects, isPermittedRedirect, sliceContent, validateURL } from "./fetch-markdown.ts";
15
16
  import { upgradeHttpToHttps } from "./web-browser/permissions.ts";
16
17
 
@@ -316,6 +317,7 @@ Usage notes:
316
317
  "If pdf_extract reports no text layer, the PDF is scanned — say so rather than retrying.",
317
318
  ],
318
319
  parameters: PdfExtractParams,
320
+ annotations: { readOnlyHint: true, openWorldHint: true },
319
321
 
320
322
  async execute(_toolCallId, params, signal, onUpdate) {
321
323
  const start = Date.now();
@@ -408,8 +410,7 @@ Usage notes:
408
410
  }
409
411
 
410
412
  if (!details || typeof details.pageCount !== "number") {
411
- const content = result.content[0];
412
- return new Text(content?.type === "text" ? content.text : "", 0, 0);
413
+ return new Text(clipField(resultText(result.content)), 0, 0);
413
414
  }
414
415
 
415
416
  let text = theme.fg("success", `Extracted ${details.pageCount} of ${details.totalPages} page(s)`);
@@ -422,15 +423,12 @@ Usage notes:
422
423
  }
423
424
 
424
425
  if (expanded) {
425
- const content = result.content[0];
426
- if (content?.type === "text") {
427
- const lines = content.text.split("\n", 16);
428
- for (const line of lines.slice(0, 15)) {
429
- text += `\n${theme.fg("dim", line)}`;
430
- }
431
- if (lines.length > 15) {
432
- text += `\n${theme.fg("muted", "… (truncated)")}`;
433
- }
426
+ const preview = resultPreview(resultText(result.content));
427
+ for (const line of preview.lines) {
428
+ text += `\n${theme.fg("dim", line)}`;
429
+ }
430
+ if (preview.truncated) {
431
+ text += `\n${theme.fg("muted", "… (truncated)")}`;
434
432
  }
435
433
  }
436
434
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Bounded previews for tool results in the TUI.
3
+ *
4
+ * pi 0.99.2 fixed long single-line results filling the screen for bash, codemode,
5
+ * and MCP tool output, but that fix lives in the host's own renderers. Extension
6
+ * tools that supply a custom `renderResult` render whatever they return, so the
7
+ * same bound has to be applied here. Header fields need the same bound: an HTTP
8
+ * reason phrase and a page `<title>` are attacker-controlled and can be megabytes.
9
+ *
10
+ * Bounds are expressed in characters rather than visual lines because pi's `Text`
11
+ * word-wraps (and hard-breaks unspaced tokens) on render. That keeps the work
12
+ * independent of the terminal, but rendered height still scales as
13
+ * `characters / width`; the worst case here is about 43 lines at 80 columns and 76
14
+ * at 40, versus the ~1250 lines a 100K-character result produced before.
15
+ */
16
+
17
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
18
+
19
+ /** Logical lines shown in an expanded preview. */
20
+ export const MAX_PREVIEW_LINES = 10;
21
+
22
+ /** Characters kept per logical line in an expanded preview. */
23
+ export const MAX_PREVIEW_LINE_CHARS = 200;
24
+
25
+ /** Characters kept from a single unbounded field: a header value, status field, or collapsed body. */
26
+ export const MAX_FIELD_CHARS = 200;
27
+
28
+ export interface ResultPreview {
29
+ /** Logical lines to display, each already clipped to {@link MAX_PREVIEW_LINE_CHARS}. */
30
+ lines: string[];
31
+ /** Whether logical lines were dropped by the {@link MAX_PREVIEW_LINES} cap. */
32
+ truncated: boolean;
33
+ }
34
+
35
+ /** Concatenate a tool result's text blocks; empty when the result carries only images. */
36
+ export function resultText(content: readonly (TextContent | ImageContent)[]): string {
37
+ return content
38
+ .filter((block): block is TextContent => block.type === "text")
39
+ .map((block) => block.text)
40
+ .join("\n");
41
+ }
42
+
43
+ /** Clip one attacker-controlled field to {@link MAX_FIELD_CHARS}, marking the cut with an ellipsis. */
44
+ export function clipField(text: string): string {
45
+ return text.length > MAX_FIELD_CHARS ? `${text.slice(0, MAX_FIELD_CHARS)}…` : text;
46
+ }
47
+
48
+ /**
49
+ * Preview for an expanded tool result: at most {@link MAX_PREVIEW_LINES} logical lines,
50
+ * each clipped so one long line cannot fill the screen. A line long enough to hit the
51
+ * character cap loses its tail in the preview; the full text stays in the transcript.
52
+ */
53
+ export function resultPreview(text: string): ResultPreview {
54
+ // The limit argument keeps the split bounded on a large body: one extra element is
55
+ // enough to detect that more lines existed.
56
+ const all = text.split("\n", MAX_PREVIEW_LINES + 1);
57
+ return {
58
+ lines: all.slice(0, MAX_PREVIEW_LINES).map((line) => clipField(line)),
59
+ truncated: all.length > MAX_PREVIEW_LINES,
60
+ };
61
+ }
@@ -31,6 +31,7 @@ import {
31
31
  import { buildOriginChecker } from "./egress-policy.ts";
32
32
  import { checkUrlPermission, upgradeHttpToHttps, validateURL } from "./permissions.ts";
33
33
  import { requestHostPermission } from "../permission-prompt.ts";
34
+ import { clipField, resultPreview, resultText } from "../render-preview.ts";
34
35
  import { listGrants } from "../permission-store.ts";
35
36
  import { permissionKey } from "../permissions.ts";
36
37
  import { isInsideCwd } from "../pdf-extract.ts";
@@ -291,8 +292,8 @@ export default function (pi: ExtensionAPI) {
291
292
  const lines = [
292
293
  "Web Browser:",
293
294
  ` Status: ${info.isOpen ? "open" : "closed"}`,
294
- ` URL: ${info.url || "(none)"}`,
295
- ` Title: ${info.title?.replace(/[\u0000-\u001f\u007f]/g, " ") || "(none)"}`,
295
+ ` URL: ${info.url ? clipField(info.url) : "(none)"}`,
296
+ ` Title: ${info.title ? clipField(info.title.replace(/[\u0000-\u001f\u007f]/g, " ")) : "(none)"}`,
296
297
  ` Tabs open: ${info.tabCount}`,
297
298
  ` Console messages: ${info.consoleCount}`,
298
299
  ` Network issues: ${info.networkCount}`,
@@ -469,6 +470,8 @@ export default function (pi: ExtensionAPI) {
469
470
  Type.Number({ description: "Timeout in milliseconds for navigation/interaction (default 30000)" }),
470
471
  ),
471
472
  }),
473
+ // Not read-only: click, type, evaluate, set_cookies, and upload_file act on the page.
474
+ annotations: { readOnlyHint: false, openWorldHint: true },
472
475
 
473
476
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
474
477
  const action = params.action as WebBrowserAction;
@@ -1040,7 +1043,7 @@ export default function (pi: ExtensionAPI) {
1040
1043
  }
1041
1044
 
1042
1045
  if (details.error) {
1043
- return new Text(theme.fg("error", details.error.slice(0, 120)), 0, 0);
1046
+ return new Text(theme.fg("error", clipField(details.error)), 0, 0);
1044
1047
  }
1045
1048
 
1046
1049
  const imageBlock = result.content.find((c) => c.type === "image");
@@ -1060,9 +1063,14 @@ export default function (pi: ExtensionAPI) {
1060
1063
  let text = "";
1061
1064
  switch (details.action) {
1062
1065
  case "navigate":
1066
+ // The page title is attacker-controlled, so bound it like the body.
1063
1067
  text = theme.fg(
1064
1068
  "success",
1065
- `Navigated — ${details.title?.replace(/[\u0000-\u001f\u007f]/g, " ") ?? details.url ?? "ok"}`,
1069
+ `Navigated — ${
1070
+ details.title
1071
+ ? clipField(details.title.replace(/[\u0000-\u001f\u007f]/g, " "))
1072
+ : clipField(details.url ?? "ok")
1073
+ }`,
1066
1074
  );
1067
1075
  break;
1068
1076
  case "get_content":
@@ -1093,15 +1101,12 @@ export default function (pi: ExtensionAPI) {
1093
1101
  }
1094
1102
 
1095
1103
  if (expanded) {
1096
- const content = result.content.find((c) => c.type === "text");
1097
- if (content?.type === "text") {
1098
- const lines = content.text.split("\n", 16);
1099
- for (const line of lines.slice(0, 15)) {
1100
- text += `\n${theme.fg("dim", line)}`;
1101
- }
1102
- if (lines.length > 15) {
1103
- text += `\n${theme.fg("muted", "…")}`;
1104
- }
1104
+ const preview = resultPreview(resultText(result.content));
1105
+ for (const line of preview.lines) {
1106
+ text += `\n${theme.fg("dim", line)}`;
1107
+ }
1108
+ if (preview.truncated) {
1109
+ text += `\n${theme.fg("muted", "…")}`;
1105
1110
  }
1106
1111
  if (details.fullOutputPath) {
1107
1112
  text += `\n${theme.fg("dim", `Full: ${details.fullOutputPath}`)}`;