pi-browser-harness 0.8.3 → 0.10.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.
@@ -0,0 +1,119 @@
1
+ import { Type } from "typebox";
2
+ import { type Result, err, ok } from "../../util/result";
3
+ import { applyTruncation } from "../../util/truncate";
4
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
5
+ import {
6
+ closeIsolatedTab,
7
+ evalInIsolatedTab,
8
+ type IsolatedTab,
9
+ navigateIsolatedTab,
10
+ openIsolatedTab,
11
+ waitForIsolatedLoad,
12
+ } from "../isolated-tab";
13
+ import { renderExpandableText } from "../render";
14
+ import { buildPageCaptureExpr } from "./capture";
15
+ import { extractReadable, type PageCapture, type ReadablePage } from "./readability";
16
+
17
+ const LOAD_TIMEOUT_MS = 15_000;
18
+
19
+ const ReadPageArgs = Type.Object({
20
+ url: Type.Optional(Type.String({ description: "URL to open in an isolated tab, read, then close." })),
21
+ targetId: Type.Optional(
22
+ Type.String({ description: "Read an already-open tab this session owns instead of opening a URL." }),
23
+ ),
24
+ });
25
+
26
+ const isPageCapture = (value: unknown): value is PageCapture => {
27
+ if (typeof value !== "object" || value === null) return false;
28
+ const v = value as Record<string, unknown>;
29
+ return typeof v["url"] === "string" && typeof v["title"] === "string" && Array.isArray(v["blocks"]) && typeof v["bodyText"] === "string";
30
+ };
31
+
32
+ const safeParse = (text: string): unknown => {
33
+ try {
34
+ return JSON.parse(text) as unknown;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ };
39
+
40
+ const captureToResult = async (raw: unknown): Promise<Result<ToolOk, ToolErr>> => {
41
+ const parsed = typeof raw === "string" ? safeParse(raw) : raw;
42
+ if (!isPageCapture(parsed)) return err({ kind: "internal", message: "page capture returned an unexpected shape" });
43
+ const page: ReadablePage = extractReadable(parsed);
44
+ const header = `# ${page.title}\n${page.url}\n(${page.wordCount} words)\n\n`;
45
+ const truncated = await applyTruncation(header + page.text, "readpage");
46
+ const summary = `${page.title} · ${page.wordCount} words`;
47
+ return ok({
48
+ text: truncated.text,
49
+ details: {
50
+ title: page.title,
51
+ url: page.url,
52
+ wordCount: page.wordCount,
53
+ render: {
54
+ summary,
55
+ body: page.text,
56
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
57
+ },
58
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
59
+ },
60
+ });
61
+ };
62
+
63
+ const readOpenedUrl = async (
64
+ client: Parameters<typeof openIsolatedTab>[0],
65
+ url: string,
66
+ signal: AbortSignal | undefined,
67
+ ): Promise<Result<ToolOk, ToolErr>> => {
68
+ const opened = await openIsolatedTab(client);
69
+ if (!opened.success) return err(opened.error);
70
+ const tab: IsolatedTab = opened.data;
71
+ try {
72
+ const navigated = await navigateIsolatedTab(client, tab, url);
73
+ if (!navigated.success) return err(navigated.error);
74
+ const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
75
+ if (!loaded.success) return err(loaded.error);
76
+ const captured = await evalInIsolatedTab(client, tab, buildPageCaptureExpr());
77
+ if (!captured.success) return err(captured.error);
78
+ return captureToResult(captured.data);
79
+ } finally {
80
+ await closeIsolatedTab(client, tab);
81
+ }
82
+ };
83
+
84
+ const readOwnedTab = async (
85
+ client: Parameters<typeof openIsolatedTab>[0],
86
+ targetId: string,
87
+ ): Promise<Result<ToolOk, ToolErr>> => {
88
+ if (!client.owns(targetId)) {
89
+ return err({ kind: "invalid_state", message: `Tab ${targetId} is not owned by this session.` });
90
+ }
91
+ const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
92
+ if (!attached.success) return err({ kind: "cdp_error", message: attached.error.message });
93
+ const sessionId = (attached.data as { sessionId: string }).sessionId;
94
+ const captured = await client.evaluateJs(buildPageCaptureExpr(), sessionId);
95
+ if (!captured.success) return err({ kind: "cdp_error", message: captured.error.message });
96
+ return captureToResult(captured.data);
97
+ };
98
+
99
+ export const readPageTool = defineBrowserTool({
100
+ name: "browser_read_page",
101
+ label: "Browser Read Page",
102
+ description:
103
+ "Read a page as clean article text — main content with nav/ads/boilerplate stripped. Pass a url (opened in an isolated tab, read, then closed) or a targetId of an owned tab. Reader-mode counterpart to browser_web_search.",
104
+ promptSnippet: "Read a page's main content as clean text",
105
+ promptGuidelines: [
106
+ "Pass url to read an arbitrary page (opened + closed in its own tab, never disturbing your current tab), or targetId to read an already-open owned tab.",
107
+ "Returns readable main-article text — use this over browser_snapshot/browser_execute_js when you want an article's content for reading or research.",
108
+ "Boilerplate-heavy or structure-less pages fall back to bounded body text rather than erroring.",
109
+ ],
110
+ parameters: ReadPageArgs,
111
+ async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
112
+ if (args.url !== undefined && args.url.length > 0) return readOpenedUrl(client, args.url, signal);
113
+ if (args.targetId !== undefined && args.targetId.length > 0) return readOwnedTab(client, args.targetId);
114
+ return err({ kind: "invalid_state", message: "provide either url or targetId" });
115
+ },
116
+ renderResult(result, expanded, theme) {
117
+ return renderExpandableText("read_page", result, expanded, theme);
118
+ },
119
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Pure reader-mode extraction — no CDP, no DOM. The in-page script
3
+ * (see capture.ts) serializes candidate content blocks; these pure functions
4
+ * score them and assemble clean article text, so the selection logic is
5
+ * unit-testable against saved fixtures (test/deep-research/readability-test.ts).
6
+ *
7
+ * Dependency-free by design (no jsdom / Readability): the harness ships no new
8
+ * runtime deps. The heuristic favors blocks with high text density and low link
9
+ * density, which is what separates article body from nav/ads/boilerplate.
10
+ */
11
+
12
+ /** One candidate content block captured in-page. */
13
+ export type ContentBlock = {
14
+ /** Semantic tag: paragraph | heading | listitem | blockquote | other. */
15
+ readonly kind: "paragraph" | "heading" | "listitem" | "blockquote" | "other";
16
+ readonly text: string;
17
+ /** Chars of text that sit inside <a> tags — high ratio ⇒ nav/menu, not prose. */
18
+ readonly linkTextLength: number;
19
+ /** True when an ancestor is nav/header/footer/aside — structural boilerplate. */
20
+ readonly inBoilerplate: boolean;
21
+ };
22
+
23
+ /** The JSON payload capture.ts returns from the page. */
24
+ export type PageCapture = {
25
+ readonly url: string;
26
+ readonly title: string;
27
+ readonly blocks: ReadonlyArray<ContentBlock>;
28
+ /** Bounded innerText fallback when block extraction finds nothing usable. */
29
+ readonly bodyText: string;
30
+ };
31
+
32
+ /** The tool's public output shape. */
33
+ export type ReadablePage = {
34
+ readonly title: string;
35
+ readonly url: string;
36
+ readonly text: string;
37
+ readonly wordCount: number;
38
+ };
39
+
40
+ const MIN_PARAGRAPH_CHARS = 25;
41
+ const MAX_LINK_DENSITY = 0.5;
42
+ const MIN_ARTICLE_WORDS = 40;
43
+
44
+ const linkDensity = (block: ContentBlock): number => {
45
+ if (block.text.length === 0) return 1;
46
+ return block.linkTextLength / block.text.length;
47
+ };
48
+
49
+ /** A block is article prose if it is substantive, text-dense, and not chrome. */
50
+ const isArticleBlock = (block: ContentBlock): boolean => {
51
+ if (block.inBoilerplate) return false;
52
+ if (linkDensity(block) > MAX_LINK_DENSITY) return false;
53
+ if (block.kind === "heading" || block.kind === "blockquote" || block.kind === "listitem") {
54
+ return block.text.trim().length > 0;
55
+ }
56
+ return block.text.trim().length >= MIN_PARAGRAPH_CHARS;
57
+ };
58
+
59
+ const render = (block: ContentBlock): string => {
60
+ const text = block.text.trim();
61
+ if (block.kind === "heading") return `\n## ${text}\n`;
62
+ if (block.kind === "listitem") return `- ${text}`;
63
+ if (block.kind === "blockquote") return `> ${text}`;
64
+ return text;
65
+ };
66
+
67
+ const wordCountOf = (text: string): number => {
68
+ const trimmed = text.trim();
69
+ return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
70
+ };
71
+
72
+ /**
73
+ * Assemble readable article text from a page capture. Keeps text-dense,
74
+ * non-boilerplate blocks; falls back to the bounded body text when the article
75
+ * is too thin (e.g. a page with no clear content structure).
76
+ */
77
+ export const extractReadable = (capture: PageCapture): ReadablePage => {
78
+ const articleText = capture.blocks.filter(isArticleBlock).map(render).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
79
+
80
+ const useFallback = wordCountOf(articleText) < MIN_ARTICLE_WORDS;
81
+ const text = useFallback ? capture.bodyText.trim() : articleText;
82
+
83
+ return {
84
+ title: capture.title.trim(),
85
+ url: capture.url,
86
+ text,
87
+ wordCount: wordCountOf(text),
88
+ };
89
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared TUI renderer for text-heavy tool output (browser_web_search,
3
+ * browser_read_page). Gives the native pi expand/collapse (Ctrl+O) behavior:
4
+ * a compact preview by default, the full body when expanded — mirroring the
5
+ * pattern in browser_execute_js (src/domains/js.ts:81-109).
6
+ *
7
+ * The full text always goes to the LLM via ToolOk.text; this only governs the
8
+ * human-facing TUI view.
9
+ */
10
+ import { Markdown, Text } from "@mariozechner/pi-tui";
11
+ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
12
+ import type { Component } from "@mariozechner/pi-tui";
13
+ import type { Theme } from "@mariozechner/pi-coding-agent";
14
+
15
+ const COMPACT_PREVIEW_LINES = 6;
16
+
17
+ /** Structured payload a text-heavy tool stores so renderResult can rebuild views. */
18
+ export type ExpandableText = {
19
+ /** One-line summary shown above the body in both states (e.g. "5 results · google"). */
20
+ readonly summary: string;
21
+ /** The full rendered body (markdown/plain). */
22
+ readonly body: string;
23
+ /** Path to the on-disk full output when the LLM text was truncated, if any. */
24
+ readonly fullOutputPath?: string;
25
+ };
26
+
27
+ const isExpandableText = (value: unknown): value is ExpandableText => {
28
+ if (typeof value !== "object" || value === null) return false;
29
+ const v = value as Record<string, unknown>;
30
+ return typeof v["summary"] === "string" && typeof v["body"] === "string";
31
+ };
32
+
33
+ const extractRender = (details: unknown): unknown => {
34
+ if (typeof details !== "object" || details === null) return undefined;
35
+ return (details as Record<string, unknown>)["render"];
36
+ };
37
+
38
+ /**
39
+ * Build a renderResult over an ExpandableText stored in `details.render`.
40
+ * `label` names the tool for the error fallback (e.g. "web_search").
41
+ */
42
+ export const renderExpandableText = (
43
+ label: string,
44
+ result: { readonly details?: unknown },
45
+ expanded: boolean,
46
+ theme: Theme,
47
+ ): Component => {
48
+ const render = extractRender(result.details);
49
+ if (!isExpandableText(render)) return new Text(theme.fg("error", `${label}: no details`), 0, 0);
50
+
51
+ if (!expanded) {
52
+ const lines = render.body.split("\n");
53
+ const preview = lines.slice(0, COMPACT_PREVIEW_LINES).join("\n");
54
+ const more = lines.length > COMPACT_PREVIEW_LINES ? `\n… ${lines.length - COMPACT_PREVIEW_LINES} more lines` : "";
55
+ const md = `**${render.summary}**\n\n${preview}${more}\n\n${keyHint("app.tools.expand", "to expand")}`;
56
+ return new Markdown(md, 0, 0, getMarkdownTheme());
57
+ }
58
+
59
+ const tail = render.fullOutputPath
60
+ ? `\n\nFull output at \`${render.fullOutputPath}\` · ${keyHint("app.tools.expand", "to collapse")}`
61
+ : `\n\n${keyHint("app.tools.expand", "to collapse")}`;
62
+ const md = `**${render.summary}**\n\n${render.body}${tail}`;
63
+ return new Markdown(md, 0, 0, getMarkdownTheme());
64
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The JavaScript source evaluated inside the Google SERP page to capture raw
3
+ * anchors + page text. This string crosses the CDP boundary via
4
+ * Runtime.evaluate (returnByValue) and returns a JSON string that
5
+ * `parseGoogleSerp` / `classifySerp` consume on the harness side.
6
+ *
7
+ * ADR-0001: anchors are selected structurally (a link carrying an <h3>), not by
8
+ * Google CSS class names, so cosmetic markup churn does not break extraction.
9
+ */
10
+
11
+ /** Max bytes of page text captured for CAPTCHA / no-results classification. */
12
+ const PAGE_TEXT_LIMIT = 4000;
13
+
14
+ /**
15
+ * Build the extraction expression. Takes no interpolated values, so it is a
16
+ * fixed source string (no injection surface). Returns
17
+ * `JSON.stringify({ anchors, pageText })`.
18
+ */
19
+ export const buildSerpExtractionExpr = (): string => `
20
+ (() => {
21
+ const main = document.querySelector('#search, #rso, #main') || document.body;
22
+ const headings = Array.from(main.querySelectorAll('a h3'));
23
+ const seen = new Set();
24
+ const anchors = [];
25
+ for (const h3 of headings) {
26
+ const a = h3.closest('a[href]');
27
+ if (!a) continue;
28
+ const href = a.href;
29
+ if (!href || seen.has(href)) continue;
30
+ seen.add(href);
31
+ const container =
32
+ a.closest('div.g') ||
33
+ a.closest('div[data-hveid]') ||
34
+ a.parentElement?.parentElement ||
35
+ a.parentElement;
36
+ const containerText = container ? container.innerText || '' : '';
37
+ const heading = h3.innerText || h3.textContent || '';
38
+ const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500);
39
+ anchors.push({ href, heading, snippet });
40
+ }
41
+ const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT});
42
+ return JSON.stringify({ anchors, pageText });
43
+ })()
44
+ `;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Pure Google SERP parsing — no CDP, no browser. Everything here is a pure
3
+ * function over data captured from the page, so it is unit-testable against
4
+ * saved fixtures (see test/deep-research/serp-parser-test.ts).
5
+ *
6
+ * ADR-0001: results are extracted by structure/semantics (anchors carrying an
7
+ * <h3> heading in the main results column), never by brittle Google CSS class
8
+ * names, and Google redirect wrappers are unwound to the real target URL.
9
+ */
10
+
11
+ /** One raw anchor captured in-page: a link with a heading and nearby snippet. */
12
+ export type SerpAnchor = {
13
+ readonly href: string;
14
+ readonly heading: string;
15
+ readonly snippet: string;
16
+ };
17
+
18
+ /** The JSON payload the in-page extraction expression returns. */
19
+ export type SerpExtraction = {
20
+ readonly anchors: ReadonlyArray<SerpAnchor>;
21
+ readonly pageText: string;
22
+ };
23
+
24
+ /** A parsed, ranked search result — the tool's public output shape. */
25
+ export type SearchResult = {
26
+ readonly title: string;
27
+ readonly url: string;
28
+ readonly snippet: string;
29
+ readonly rank: number;
30
+ };
31
+
32
+ /** Why a SERP yielded no usable results, or that it did. */
33
+ export type SerpVerdict = "ok" | "captcha" | "no_results";
34
+
35
+ const CAPTCHA_MARKERS: ReadonlyArray<string> = [
36
+ "unusual traffic",
37
+ "not a robot",
38
+ "recaptcha",
39
+ "detected unusual",
40
+ "systems have detected",
41
+ "before you continue to google",
42
+ "our systems have detected unusual traffic",
43
+ ];
44
+
45
+ /**
46
+ * Decode a Google redirect wrapper (`/url?q=<real>&sa=...`) to its target.
47
+ * Returns the input unchanged when it is not a wrapper.
48
+ */
49
+ const unwrapRedirect = (href: string): string => {
50
+ const wrapper = href.match(/^(?:https?:\/\/[^/]*google\.[^/]*)?\/url\?/i);
51
+ if (!wrapper) return href;
52
+ const query = href.slice(href.indexOf("?") + 1);
53
+ const target = new URLSearchParams(query).get("q");
54
+ return target ?? href;
55
+ };
56
+
57
+ const isExternalResult = (url: string): boolean => {
58
+ if (!/^https?:\/\//i.test(url)) return false;
59
+ try {
60
+ const host = new URL(url).hostname.toLowerCase();
61
+ if (host === "google.com" || host.endsWith(".google.com")) return false;
62
+ if (host === "googleusercontent.com" || host.endsWith(".googleusercontent.com")) return false;
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ };
68
+
69
+ /** Normalized key for dedupe: origin + pathname, trailing slash and hash removed. */
70
+ const dedupeKey = (url: string): string => {
71
+ try {
72
+ const u = new URL(url);
73
+ return `${u.origin}${u.pathname.replace(/\/$/, "")}`.toLowerCase();
74
+ } catch {
75
+ return url.toLowerCase();
76
+ }
77
+ };
78
+
79
+ /**
80
+ * Parse captured anchors into ranked results: unwrap redirects, keep only
81
+ * external http(s) links, dedupe by normalized url (first wins), rank by input
82
+ * order, cap to `limit`.
83
+ */
84
+ export const parseGoogleSerp = (anchors: ReadonlyArray<SerpAnchor>, limit: number): ReadonlyArray<SearchResult> => {
85
+ const seen = new Set<string>();
86
+ const results: SearchResult[] = [];
87
+ for (const anchor of anchors) {
88
+ if (results.length >= limit) break;
89
+ const url = unwrapRedirect(anchor.href);
90
+ if (!isExternalResult(url)) continue;
91
+ const key = dedupeKey(url);
92
+ if (seen.has(key)) continue;
93
+ seen.add(key);
94
+ results.push({
95
+ title: anchor.heading.trim(),
96
+ url,
97
+ snippet: anchor.snippet.trim(),
98
+ rank: results.length + 1,
99
+ });
100
+ }
101
+ return results;
102
+ };
103
+
104
+ /**
105
+ * Decide whether the SERP is usable, blocked by a CAPTCHA, or genuinely empty.
106
+ * `resultCount` is the number of parsed results; `pageText` is a bounded slice
107
+ * of the page's visible text.
108
+ */
109
+ export const classifySerp = (pageText: string, resultCount: number): SerpVerdict => {
110
+ if (resultCount > 0) return "ok";
111
+ const lower = pageText.toLowerCase();
112
+ if (CAPTCHA_MARKERS.some((marker) => lower.includes(marker))) return "captcha";
113
+ return "no_results";
114
+ };
@@ -0,0 +1,116 @@
1
+ import { Type } from "typebox";
2
+ import { type Result, err, ok } from "../../util/result";
3
+ import { applyTruncation } from "../../util/truncate";
4
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
5
+ import {
6
+ closeIsolatedTab,
7
+ evalInIsolatedTab,
8
+ navigateIsolatedTab,
9
+ openIsolatedTab,
10
+ waitForIsolatedLoad,
11
+ } from "../isolated-tab";
12
+ import { renderExpandableText } from "../render";
13
+ import { buildSerpExtractionExpr } from "./extract";
14
+ import { classifySerp, parseGoogleSerp, type SearchResult, type SerpExtraction } from "./google-serp";
15
+
16
+ const LOAD_TIMEOUT_MS = 15_000;
17
+
18
+ const WebSearchArgs = Type.Object({
19
+ query: Type.String({ description: "The search query." }),
20
+ limit: Type.Optional(
21
+ Type.Integer({ default: 10, minimum: 1, maximum: 30, description: "Max results to return. Default: 10." }),
22
+ ),
23
+ });
24
+
25
+ const buildSerpUrl = (query: string, limit: number): string =>
26
+ `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`;
27
+
28
+ const isSerpExtraction = (value: unknown): value is SerpExtraction => {
29
+ if (typeof value !== "object" || value === null) return false;
30
+ const v = value as Record<string, unknown>;
31
+ return Array.isArray(v["anchors"]) && typeof v["pageText"] === "string";
32
+ };
33
+
34
+ const formatResults = (query: string, results: ReadonlyArray<SearchResult>): string => {
35
+ const lines = results.map((r) => `${r.rank}. ${r.title}\n ${r.url}\n ${r.snippet}`);
36
+ return `${results.length} result(s) for "${query}":\n\n${lines.join("\n\n")}`;
37
+ };
38
+
39
+ export const webSearchTool = defineBrowserTool({
40
+ name: "browser_web_search",
41
+ label: "Browser Web Search",
42
+ description:
43
+ "Search the web and return ranked results (title, url, snippet, rank). Scrapes a Google results page in the real Chrome — no API key. Returns links only; use browser_read_page to read a result's content.",
44
+ promptSnippet: "Search the web for a query",
45
+ promptGuidelines: [
46
+ "Returns ranked {title, url, snippet, rank} — links only, no page content. Follow up with browser_read_page.",
47
+ "On a CAPTCHA / bot wall the call fails with kind:'invalid_state' and details.reason:'captcha' (plus serpUrl) — surface it to the user; do not retry in a tight loop.",
48
+ "On zero results it fails with details.reason:'no_results' — rephrase the query and try again.",
49
+ "Runs in its own isolated tab and never disturbs the user's current tab.",
50
+ ],
51
+ parameters: WebSearchArgs,
52
+ async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
53
+ const limit = args.limit ?? 10;
54
+ const serpUrl = buildSerpUrl(args.query, limit);
55
+
56
+ const opened = await openIsolatedTab(client);
57
+ if (!opened.success) return err(opened.error);
58
+ const tab = opened.data;
59
+
60
+ try {
61
+ const navigated = await navigateIsolatedTab(client, tab, serpUrl);
62
+ if (!navigated.success) return err(navigated.error);
63
+
64
+ const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
65
+ if (!loaded.success) return err(loaded.error);
66
+
67
+ const extracted = await evalInIsolatedTab(client, tab, buildSerpExtractionExpr());
68
+ if (!extracted.success) return err(extracted.error);
69
+
70
+ const parsedJson = typeof extracted.data === "string" ? safeParse(extracted.data) : extracted.data;
71
+ if (!isSerpExtraction(parsedJson)) {
72
+ return err({ kind: "internal", message: "SERP extraction returned an unexpected shape" });
73
+ }
74
+
75
+ const results = parseGoogleSerp(parsedJson.anchors, limit);
76
+ const verdict = classifySerp(parsedJson.pageText, results.length);
77
+ if (verdict !== "ok") {
78
+ return err({
79
+ kind: "invalid_state",
80
+ message: verdict === "captcha" ? "Google served a CAPTCHA / bot wall" : "no results found",
81
+ details: { reason: verdict, serpUrl },
82
+ });
83
+ }
84
+
85
+ const body = formatResults(args.query, results);
86
+ const truncated = await applyTruncation(body, "search");
87
+ return ok({
88
+ text: truncated.text,
89
+ details: {
90
+ results,
91
+ engine: "google",
92
+ query: args.query,
93
+ render: {
94
+ summary: `${results.length} result(s) · google · "${args.query}"`,
95
+ body,
96
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
97
+ },
98
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
99
+ },
100
+ });
101
+ } finally {
102
+ await closeIsolatedTab(client, tab);
103
+ }
104
+ },
105
+ renderResult(result, expanded, theme) {
106
+ return renderExpandableText("web_search", result, expanded, theme);
107
+ },
108
+ });
109
+
110
+ const safeParse = (text: string): unknown => {
111
+ try {
112
+ return JSON.parse(text) as unknown;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ };
@@ -31,8 +31,10 @@ export const setupTool = defineBrowserTool({
31
31
  // ensureAlive:false — this tool IS the setup; it must bypass the socket guard
32
32
  ensureAlive: false,
33
33
  renderCall: () => new Text("🔧 Initializing browser...", 0, 0),
34
- async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
35
- const result = await performSetup(client);
34
+ async handler(_args, { client, extensionCtx }): Promise<Result<ToolOk, ToolErr>> {
35
+ // extensionCtx carries the UI, so the agent calling browser_setup gets the
36
+ // same first-run profile picker the user gets from /browser-setup.
37
+ const result = await performSetup(client, extensionCtx);
36
38
  if (result.success) {
37
39
  return ok({ text: result.data });
38
40
  }
package/src/index.ts CHANGED
@@ -12,8 +12,10 @@
12
12
  *
13
13
  * Commands:
14
14
  * /browser-setup — guided setup wizard
15
+ * /browser-profile — choose which browser profile the agent uses
15
16
  * /browser-status — show client status and current page
16
17
  * /browser-reload-daemon — restart the browser client
18
+ * /deep-research <q> — research a question on the web, write a cited report
17
19
  *
18
20
  * Flags:
19
21
  * --browser-namespace <name> — override namespace (default: auto)
@@ -24,6 +26,9 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
24
26
  import { type BrowserClient, createBrowserClient } from "./client";
25
27
  import { getBrowserSystemPrompt } from "./prompt";
26
28
  import { registerSetupCommand } from "./setup";
29
+ import { registerProfileCommand } from "./profile/command";
30
+ import { readPin } from "./profile/store";
31
+ import { registerDeepResearchCommand } from "./deep-research";
27
32
  import { type BrowserState, defaultState, persistState, restoreState } from "./state";
28
33
  import { registerAllTools } from "./registry";
29
34
  import { cleanupTempDirs } from "./util/truncate";
@@ -55,8 +60,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
55
60
  return;
56
61
  }
57
62
  const s = client.status();
63
+ const pin = client.profilePin();
58
64
  const lines = [
59
65
  `Browser: ${s.alive ? "🟢 Connected" : "🔴 Disconnected"}`,
66
+ `Profile: ${pin ? pin.label : "not selected (uses whichever window is focused) — run /browser-profile"}`,
60
67
  `Session: ${s.sessionId ?? "none"}`,
61
68
  ];
62
69
  if (s.remoteBrowserId) lines.push(`Browser ID: ${s.remoteBrowserId}`);
@@ -100,6 +107,11 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
100
107
  pi.on("session_start", async (_event, ctx) => {
101
108
  state = restoreState(ctx, state.namespace);
102
109
 
110
+ // The pinned profile lives on disk, not in the session, so it survives
111
+ // session termination. Re-read it every session start: another pi session
112
+ // may have changed it since this client was created.
113
+ const profilePin = await readPin();
114
+
103
115
  // Create the client lazily on first session. The daemon transport
104
116
  // is created but NOT connected — that only happens when the user
105
117
  // runs /browser-setup or a browser tool call finds an existing socket.
@@ -107,13 +119,15 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
107
119
  if (!client) {
108
120
  const transport = createDaemonTransport(state.namespace);
109
121
 
110
- const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
122
+ const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
111
123
  if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds;
112
124
  if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId;
125
+ if (state.harnessWindowId !== undefined) initialOwnership.harnessWindowId = state.harnessWindowId;
113
126
 
114
127
  client = createBrowserClient({
115
128
  namespace: state.namespace,
116
129
  transport,
130
+ profilePin,
117
131
  ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}),
118
132
  onOwnershipChange: (snap) => {
119
133
  state = {
@@ -122,6 +136,9 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
122
136
  ...(snap.harnessWindowTargetId !== undefined
123
137
  ? { harnessWindowTargetId: snap.harnessWindowTargetId }
124
138
  : {}),
139
+ ...(snap.harnessWindowId !== undefined
140
+ ? { harnessWindowId: snap.harnessWindowId }
141
+ : {}),
125
142
  };
126
143
  // Ownership changes can fire from CDP events at any time,
127
144
  // including after session replacement when pi's ctx is stale.
@@ -130,6 +147,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
130
147
  try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ }
131
148
  },
132
149
  });
150
+ } else {
151
+ // Client carried over from a previous session — adopt whatever pin is on
152
+ // disk now, which another pi session may have changed.
153
+ client.setProfilePin(profilePin);
133
154
  }
134
155
 
135
156
  // Do NOT call client.start() here — browser control is on-demand.
@@ -141,27 +162,32 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
141
162
  toolsRegistered = true;
142
163
  }
143
164
  registerSetupCommand(pi, client);
144
- ctx.ui.setStatus(
145
- "browser",
146
- client.status().alive ? "🟢 Browser connected" : "🔴 Browser — run /browser-setup",
147
- );
165
+ registerProfileCommand(pi, client);
166
+ registerDeepResearchCommand(pi);
148
167
  });
149
168
 
150
169
  pi.on("session_shutdown", async () => {
151
- persistState(pi, state);
152
170
  if (client) {
153
171
  try {
154
172
  // Detach from the page target (removes the "Chrome is being controlled"
155
173
  // banner) but keep the transport alive for the next session.
156
174
  await client.detach();
175
+ // Close every tab this session opened so no stale harness tabs linger
176
+ // in the user's browser between sessions. This also clears the persisted
177
+ // ownership + window binding via onOwnershipChange, so the next session
178
+ // starts with a fresh dedicated window rather than reattaching to leftovers.
179
+ await client.closeOwnedTabs();
157
180
  } catch (e) {
158
- console.warn("[pi-browser-harness] client.detach() failed during shutdown:", e);
181
+ console.warn("[pi-browser-harness] browser teardown failed during shutdown:", e);
159
182
  }
160
183
  // ponytail: keep the transport alive across sessions.
161
184
  // Do NOT call client.stop() or null out client — the daemon connection
162
185
  // persists and eliminates the per-session "Allow Remote Debugging" prompt.
163
186
  // Only Chrome restart or daemon death triggers a new prompt.
164
187
  }
188
+ // Persist last — closeOwnedTabs mutates `state` through onOwnershipChange,
189
+ // so this captures the cleared ownership set.
190
+ persistState(pi, state);
165
191
  await cleanupTempDirs();
166
192
  });
167
193