all repos — sz-chrome-remote-cdp @ b67faca60b21b0cf74d5c967f0f8d215ad601e93

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

refactor(domains): strip comment noise

Removes module banners, step markers, and comments restating the code.
Keeps one-line notes for safeJs's trust boundary, the select-match null
sentinels, the TUI width clamp, the single-window tab rule, the input
synthesis quirks (char events, keyCode, mousemove, focus, compositor),
the stale AX value, ignored-wrapper recursion, and isolated-tab routing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
amankumarsingh77 amankumarsingh7702@gmail.com
Tue, 28 Jul 2026 20:30:49 +0530
commit

b67faca60b21b0cf74d5c967f0f8d215ad601e93

parent

a6ec1d0915106b127affbd6b5fcc9efec48ef712

M src/domains/click.ts → src/domains/click.ts

@@ -36,9 +36,7 @@ y: number,

button: "left" | "right" | "middle", count: number, ): Promise<Result<void, ToolErr>> => { - // Move the pointer to the target first. Some focus/hover handlers (and React - // synthetic events) only fire when a mousemove precedes the press, so without - // it a click can land without focusing the element. + // Some focus/hover handlers and React synthetic events only fire when a mousemove precedes the press, so without it a click lands without focusing. const moved = await cdpCall(client, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y }); if (!moved.success) return moved; const pressed = await cdpCall(client, "Input.dispatchMouseEvent", {
M src/domains/console.ts → src/domains/console.ts

@@ -182,7 +182,7 @@

const text = renderConsoleMarkdown(drained.records, { total: drained.total, bufferOverflowed: drained.bufferOverflowed, - expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans + expanded: true, nextCursor, }); const trunc = await applyTruncation(text, "console");
M src/domains/drag.ts → src/domains/drag.ts

@@ -40,7 +40,6 @@ type InputCall = readonly ["Input.dispatchMouseEvent" | "Input.dispatchDragEvent", Record<string, unknown>];

const calls: ReadonlyArray<InputCall> = [ ["Input.dispatchMouseEvent", { type: "mousePressed", x: args.startX, y: args.startY, button: "left", clickCount: 1 }], ["Input.dispatchDragEvent", { type: "dragEnter", x: args.startX, y: args.startY, data, modifiers: 0 }], - // 5 interpolated dragOver steps from start → end so the page sees a smooth drag. ...Array.from({ length: 5 }, (_, i): InputCall => { const t = (i + 1) / 5; return [
M src/domains/files.ts → src/domains/files.ts

@@ -17,8 +17,6 @@ selector: Type.Optional(Type.String({ description: "CSS selector of the file <input> (fallback when no ref)" })),

filePath: Type.String({ description: "Absolute path to the file to upload" }), }); -// Ref path: set files directly on the resolved backendNodeId via CDP — simplest -// and most reliable, no selector needed. const tryRefUpload = async ( client: BrowserClient, backendId: number,

@@ -115,7 +113,6 @@ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {

const readable = await verifyReadable(args.filePath); if (!readable.success) return readable; - // Ref path: resolve to backendNodeId and set files directly via CDP. if (args.ref !== undefined) { const backendId = resolveRefToBackendId(client, args.ref); if (!backendId.success) return backendId;
M src/domains/form.ts → src/domains/form.ts

@@ -18,16 +18,6 @@ selector: Type.Optional(Type.String({ description: "CSS selector of the form field to fill (fallback when no ref)" })),

value: Type.String({ description: "Value to set in the field" }), }); -/** - * Run an element-scoped function either against a CSS selector (querySelector + - * apply) or a ref (resolve to objectId, callFunctionOn with element as `this`). - * The shared `fnBody` is a function body where `this` is the target element and - * the single argument is the value — identical logic on both paths. Returns the - * function's return value (parsed) or a ToolErr. - * - * On the ref path a detached node yields a stale-ref error; on the selector path - * a missing element returns { status: "not_found" } from the function body. - */ const runOnElement = async ( client: BrowserClient, opts: {

@@ -60,9 +50,7 @@ }

if (opts.selector === undefined) { return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." }); } - // Selector path: bind the same trusted function body to the matched element - // via .call(). fnBody is harness-authored (not user input); only the selector - // and value are interpolated, and those go through safeJs. + // fnBody is harness-authored, not user input; only the selector and value are interpolated, and those go through safeJs. const prelude = safeJs` (() => { const el = document.querySelector(${opts.selector});

@@ -232,7 +220,6 @@ }

return false; }; -// `this` is the <select>; `arg` is { wv, wl, wi } (null = not provided). const SELECT_FN = ` const el = this; if (el.tagName !== "SELECT") return { status: "not_select", tag: el.tagName };
M src/domains/isolated-tab.ts → src/domains/isolated-tab.ts

@@ -1,15 +1,3 @@

-/** - * Isolated-attach tab lifecycle (ADR-0002 v1). Opens a harness-owned tab, - * attaches to it by its own CDP sessionId, and drives it via callOnTarget / - * evaluateJs(expr, sid) WITHOUT ever touching the global "current" tab — so a - * search or page-read never disturbs the tab the user is looking at, even when - * several subagents run concurrently. - * - * Mirrors the create/attach sequence in browser_open_urls - * (src/domains/navigate.ts:84-139). Every page evaluation is bounded by a - * timeout, so a JS dialog on the (uninstrumented) isolated tab surfaces as a - * typed timeout rather than hanging — see the dialog-hang caveat in plan.md. - */ import type { BrowserClient } from "../client"; import { type Result, err, ok } from "../util/result"; import { safeJs } from "../util/js-template";

@@ -17,7 +5,6 @@ import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";

import { evaluateJson } from "../cdp/evaluate"; import type { ToolErr } from "../util/tool"; -/** A tab attached by its own sessionId, ready to drive via callOnTarget. */ export type IsolatedTab = { readonly targetId: string; readonly sessionId: string;

@@ -28,14 +15,8 @@ const READY_POLL_INTERVAL_MS = 50;

const toToolErr = (message: string, kind: ToolErr["kind"] = "cdp_error"): ToolErr => ({ kind, message }); -/** - * Open a harness-owned tab and attach to it, returning its targetId + private - * sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done. - */ export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => { - // Routed through the target factory so an isolated tab lands in the pinned - // profile like every other harness tab — otherwise a search or page read - // would run with a different profile's cookies than the visible tabs. + // Routed through the target factory so an isolated tab lands in the pinned profile, not another profile's cookies. const window = await ensureHarnessWindow(client); if (!window.success) return err(toToolErr(window.error.message)); let targetId: string;

@@ -62,7 +43,6 @@ }

return ok({ targetId, sessionId }); }; -/** Navigate an isolated tab to a URL (does not wait for load). */ export const navigateIsolatedTab = async ( client: BrowserClient, tab: IsolatedTab,

@@ -75,7 +55,6 @@ if (!nav.success) return err(toToolErr(nav.error.message));

return ok(undefined); }; -/** Poll the isolated tab until document.readyState === 'complete' or timeout. */ export const waitForIsolatedLoad = async ( client: BrowserClient, tab: IsolatedTab,

@@ -108,7 +87,6 @@ if (!r.success) return err(toToolErr(r.error.message, r.error.kind === "timeout" ? "timeout" : "cdp_error"));

return ok(r.data); }; -/** Close an isolated tab and drop its ownership. Best-effort; never throws. */ export const closeIsolatedTab = async (client: BrowserClient, tab: IsolatedTab): Promise<void> => { await client.closeTab(tab.targetId).catch(() => {}); };
M src/domains/js.ts → src/domains/js.ts

@@ -18,17 +18,10 @@ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;

return `${n} B`; }; -// AsyncFunction constructor — the documented Node.js mechanism for compiling -// arbitrary user-supplied source. Equivalent to `new Function` but produces an -// async function we can `await`. The full RCE risk is contained at the source -// boundary: see the path allowlist + timeout below. A plain `unknown` cast -// would lose the constructor signature; we keep this single typed cast. +// The AsyncFunction constructor is the documented way to compile user source; a plain `unknown` cast would lose the constructor signature, so this typed cast stays. const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new (...args: ReadonlyArray<string>) => (...args: ReadonlyArray<unknown>) => Promise<unknown>; -// ESNext module — bare `require` is not in scope. createRequire provides the -// CJS resolver anchored to this file's URL so scripts can require() builtins -// and installed CJS packages. const requireFromHere = createRequire(import.meta.url); const ExecuteJsArgs = Type.Object({

@@ -58,8 +51,6 @@ const r = await client.evaluateJs(args.expression, args.targetId);

if (!r.success) return err({ kind: "cdp_error", message: r.error.message }); const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data); const truncated = await applyTruncation(valueStr, "js"); - // If the value is JSON, pretty-print for the expanded TUI view. Falls back - // to the raw string if parse fails (e.g. value is "undefined" sentinel). let pretty: string | undefined; try { const parsed: unknown = JSON.parse(valueStr);

@@ -71,8 +62,6 @@ return ok({

text: truncated.text, details: { valueLength: valueStr.length, - // Full value for the expanded renderer. Capped at 200 KB so a giant - // dump doesn't bloat the conversation transcript on disk. full: valueStr.length > 200_000 ? valueStr.slice(0, 200_000) : valueStr, ...(pretty !== undefined ? { pretty: pretty.length > 200_000 ? pretty.slice(0, 200_000) : pretty } : {}), ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),

@@ -210,7 +199,7 @@

try { const scriptPromise = executeFn( args.params ?? {}, - client, // bound as `daemon` — name preserved for back-compat + client, requireFromHere, ac.signal, (u: unknown) => {

@@ -221,7 +210,7 @@ const first: unknown = content[0];

if (!isRecord(first)) return; const txt = first["text"]; if (typeof txt !== "string") return; - try { onUpdate({ text: txt }); } catch { /* swallow */ } + try { onUpdate({ text: txt }); } catch {} }, extensionCtx ?? { cwd: process.cwd() }, console, fetch, JSON, Buffer, setTimeout, clearTimeout,
M src/domains/keyboard.ts → src/domains/keyboard.ts

@@ -97,9 +97,7 @@ ...(isChar ? { text: k, unmodifiedText: k } : {}),

}; const down = await cdpCall(client, "Input.dispatchKeyEvent", downParams); if (!down.success) return down; - // For a printable character with no command modifier (Ctrl/Meta/Alt = 1|2|4), - // emit a `char` event so the page receives keypress/textInput and the - // character is actually inserted. Shift (8) is allowed (capitals/symbols). + // A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable. const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0; if (isChar && !hasCommandModifier) { const charEv = await cdpCall(client, "Input.dispatchKeyEvent", { type: "char", text: k, key: k });

@@ -143,12 +141,8 @@ concurrency: "serialized",

async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { const eventType = args.eventType ?? "keydown"; const target = args.ref ?? args.selector ?? ""; - // Legacy handlers commonly branch on e.keyCode/e.which (e.g. === 13 for - // Enter) rather than e.key, so populate both. virtualKeyCode maps named - // keys and single chars; 0 for anything unknown. + // Legacy handlers branch on e.keyCode/e.which rather than e.key, so populate both. const code = virtualKeyCode(args.key); - // Ref path: dispatch on the single resolved node. Selector path: dispatch on - // all matches (preserves the original multi-match behavior). if (args.ref !== undefined) { const objectId = await resolveRefToObjectId(client, args.ref); if (!objectId.success) return objectId;
M src/domains/navigate.ts → src/domains/navigate.ts

@@ -88,28 +88,21 @@ concurrency: "serialized",

renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0), async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> { const total = args.urls.length; - // Phase 1: create tabs. All must land in ONE window. If the harness window - // already exists, open every url as its child in parallel. If it doesn't - // (fresh/closed), create the FIRST url as a new dedicated window (binding - // its windowId) and open the rest as children of it — a parallel - // newWindow:true per url would otherwise scatter tabs across N windows. + // All tabs must land in ONE window: a parallel newWindow:true per url would scatter them across N windows, so the first url creates the window and the rest open as its children. const url0 = args.urls[0]; if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" }); const window = await ensureHarnessWindow(client); if (!window.success) return err({ kind: "cdp_error", message: window.error.message }); const seededWindow = window.data.targetId; - // A freshly created window's seed tab serves as the first URL's tab. const seedResult: TabResult | undefined = window.data.freshlyCreated ? { url: url0, targetId: seededWindow, ok: true } : undefined; const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => { - // Reuse the seed tab (created above) for the first url instead of duplicating it. if (i === 0 && seedResult) return seedResult; const r = await openHarnessTab(client, seededWindow); if (!r.success) return { url, targetId: "", ok: false, error: r.error.message }; return { url, targetId: r.data, ok: true }; })); - // Phase 2: attach + navigate each created tab in parallel. let completed = 0; const settled = await Promise.all( created.filter((t) => t.ok).map(async (tab): Promise<TabResult> => {

@@ -121,8 +114,6 @@ const enabled = await client.session().callOnTarget("Page.enable", {}, sid);

if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message }; const nav = await client.session().callOnTarget("Page.navigate", { url: tab.url }, sid); if (!nav.success) return { ...tab, ok: false, error: nav.error.message }; - // Mark the tab with a green circle so the user can see which - // tabs the agent opened. Best-effort; don't fail on CSP blocks. client.evaluateJs( safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`, sid

@@ -130,11 +121,10 @@ ).catch(() => {});

return tab; } finally { completed++; - // onUpdate must never escape: a renderer crash would otherwise - // tear down the tool call. + // onUpdate must never escape: a renderer crash would otherwise tear down the tool call. try { onUpdate({ text: `Opening URLs… ${completed}/${created.filter((t) => t.ok).length} navigated` }); - } catch { /* swallow */ } + } catch {} } }), );

@@ -142,7 +132,6 @@ const failures = created.filter((t) => !t.ok);

const all: ReadonlyArray<TabResult> = [...settled, ...failures]; const okTabs = all.filter((r) => r.ok); const failTabs = all.filter((r) => !r.ok); - // Phase 3: activate the last successfully-opened tab so the user sees something. if (okTabs.length > 0) { const last = okTabs[okTabs.length - 1]; if (last) {
M src/domains/network.ts → src/domains/network.ts

@@ -131,7 +131,6 @@ const more = records.length > 5 ? `\n … ${records.length - 5} more` : "";

return `${headerLines.join("\n")}\n\n${rows}${more}`; } - // Expanded: full table + optional body sections. const tableHeader = `| # | Method | Status | URL | Type | ms |\n|---|---|---|---|---|---|`; const tableRows = records .map((r, i) => {

@@ -269,7 +268,7 @@ const text = renderNetworkMarkdown(drained.records, {

includeBodies: args.includeResponseBodies ?? false, total: drained.total, bufferOverflowed: drained.bufferOverflowed, - expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans + expanded: true, }); const trunc = await applyTruncation(text, "network");
M src/domains/readpage/capture.ts → src/domains/readpage/capture.ts

@@ -1,17 +1,6 @@

-/** - * The JavaScript source evaluated in a loaded page to capture candidate content - * blocks + metadata for the pure readability extractor (readability.ts). Crosses - * the CDP boundary via Runtime.evaluate (returnByValue) and returns a JSON - * string shaped like PageCapture. - * - * The DOM walk lives here (unavoidable); all scoring/selection is pure and lives - * in readability.ts, so the decision logic stays unit-testable. - */ -/** Max bytes of body-text fallback captured for thin/structure-less pages. */ const BODY_TEXT_LIMIT = 20_000; -/** Build the capture expression. No interpolated values ⇒ no injection surface. */ export const buildPageCaptureExpr = (): string => ` (() => { const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']);
M src/domains/readpage/readability.ts → src/domains/readpage/readability.ts

@@ -1,35 +1,18 @@

-/** - * Pure reader-mode extraction — no CDP, no DOM. The in-page script - * (see capture.ts) serializes candidate content blocks; these pure functions - * score them and assemble clean article text, so the selection logic is - * unit-testable against saved fixtures (test/deep-research/readability-test.ts). - * - * Dependency-free by design (no jsdom / Readability): the harness ships no new - * runtime deps. The heuristic favors blocks with high text density and low link - * density, which is what separates article body from nav/ads/boilerplate. - */ -/** One candidate content block captured in-page. */ export type ContentBlock = { - /** Semantic tag: paragraph | heading | listitem | blockquote | other. */ readonly kind: "paragraph" | "heading" | "listitem" | "blockquote" | "other"; readonly text: string; - /** Chars of text that sit inside <a> tags — high ratio ⇒ nav/menu, not prose. */ readonly linkTextLength: number; - /** True when an ancestor is nav/header/footer/aside — structural boilerplate. */ readonly inBoilerplate: boolean; }; -/** The JSON payload capture.ts returns from the page. */ export type PageCapture = { readonly url: string; readonly title: string; readonly blocks: ReadonlyArray<ContentBlock>; - /** Bounded innerText fallback when block extraction finds nothing usable. */ readonly bodyText: string; }; -/** The tool's public output shape. */ export type ReadablePage = { readonly title: string; readonly url: string;

@@ -46,7 +29,6 @@ if (block.text.length === 0) return 1;

return block.linkTextLength / block.text.length; }; -/** A block is article prose if it is substantive, text-dense, and not chrome. */ const isArticleBlock = (block: ContentBlock): boolean => { if (block.inBoilerplate) return false; if (linkDensity(block) > MAX_LINK_DENSITY) return false;

@@ -69,11 +51,6 @@ const trimmed = text.trim();

return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length; }; -/** - * Assemble readable article text from a page capture. Keeps text-dense, - * non-boilerplate blocks; falls back to the bounded body text when the article - * is too thin (e.g. a page with no clear content structure). - */ export const extractReadable = (capture: PageCapture): ReadablePage => { const articleText = capture.blocks.filter(isArticleBlock).map(render).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
M src/domains/ref-resolve.ts → src/domains/ref-resolve.ts

@@ -10,12 +10,6 @@ message: `Ref ${ref} is unknown or stale — re-run browser_snapshot to get fresh refs.`,

details: { ref }, }); -/** - * Resolve a ref to its CDP objectId (a handle to the live JS node). Use when a - * tool needs to run JS against the element (fill, focus, select, dispatch_key). - * Re-resolves through the backendNodeId at call time, so it reflects the current - * DOM even after re-renders — and fails loudly if the node was detached. - */ export const resolveRefToObjectId = async ( client: BrowserClient, ref: string,

@@ -24,8 +18,6 @@ const backendId = client.session().resolveRef(ref);

if (backendId === undefined) return err(staleErr(ref)); const resolved = await client.session().call("DOM.resolveNode", { backendNodeId: backendId }); if (!resolved.success) { - // A detached node (removed by a re-render) fails to resolve — surface it as - // a stale ref rather than a raw CDP error so the agent knows to re-snapshot. return err(staleErr(ref)); } const objectId = resolved.data.object.objectId;

@@ -33,18 +25,12 @@ if (!objectId) return err(staleErr(ref));

return ok(objectId); }; -/** Resolve a ref to its current backendNodeId (for CDP calls that take one, e.g. DOM.setFileInputFiles). */ export const resolveRefToBackendId = (client: BrowserClient, ref: string): Result<number, ToolErr> => { const backendId = client.session().resolveRef(ref); if (backendId === undefined) return err(staleErr(ref)); return ok(backendId); }; -/** - * Resolve a ref to the current center of its bounding box, in viewport CSS - * pixels. Use for browser_click. Fetched fresh via DOM.getBoxModel, so a - * re-rendered/moved element is clicked at its new position. - */ export const resolveRefToBox = async ( client: BrowserClient, ref: string,

@@ -59,19 +45,6 @@

const sigOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}|${n.value ?? ""}|${n.state ?? ""}`; const keyOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}`; -/** - * Recompute the interactive elements after a mutation and return a compact, - * human/LLM-readable diff vs. the prior snapshot's baseline — changed values, - * newly-appeared elements (marked `*`), and removed ones. Re-publishes the fresh - * ref map so subsequent ref-based calls target the post-mutation DOM. - * - * Deliberately cheap: it re-reads the AX tree (no per-node box fetch — the - * expensive part of a full snapshot) and emits a few lines, not a full tree, so - * a long action sequence doesn't blow up the context. - * - * Returns "" on any failure or when nothing changed — callers append it only if - * non-empty, so a diff is best-effort and never blocks the primary action. - */ export const interactiveDiff = async (client: BrowserClient): Promise<string> => { const session = client.session(); const prev = session.refSignatures();

@@ -82,7 +55,6 @@ const rawNodes = axRes.data.nodes;

const slim = buildTree(rawNodes, { interestingOnly: true, maxNodes: 1000 }); const targets = collectInteractiveTargets(slim); - // Re-publish fresh refs so later ref calls resolve against the new DOM. const priorRefs = session.refMappings(); const refByBackendId = new Map<number, string>(); for (const [ref, backendId] of priorRefs) refByBackendId.set(backendId, ref);

@@ -104,8 +76,6 @@ refSig.set(ref, sigOf(node));

} session.setRefMap(refMap, refSig); - // Compare by stable identity (role|name) rather than ref position, since refs - // renumber when the element set changes. Build prev/current identity → value. const prevByKey = new Map<string, string>(); for (const sig of prev.values()) { const parts = sig.split("|");
M src/domains/render.ts → src/domains/render.ts

@@ -1,12 +1,3 @@

-/** - * Shared TUI renderer for text-heavy tool output (browser_web_search, - * browser_read_page). Gives the native pi expand/collapse (Ctrl+O) behavior: - * a compact preview by default, the full body when expanded — mirroring the - * pattern in browser_execute_js (src/domains/js.ts:81-109). - * - * The full text always goes to the LLM via ToolOk.text; this only governs the - * human-facing TUI view. - */ import { Markdown, Text } from "@mariozechner/pi-tui"; import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import type { Component } from "@mariozechner/pi-tui";

@@ -15,13 +6,9 @@ import { isRecord } from "../util/guards";

const COMPACT_PREVIEW_LINES = 6; -/** Structured payload a text-heavy tool stores so renderResult can rebuild views. */ export type ExpandableText = { - /** One-line summary shown above the body in both states (e.g. "5 results · google"). */ readonly summary: string; - /** The full rendered body (markdown/plain). */ readonly body: string; - /** Path to the on-disk full output when the LLM text was truncated, if any. */ readonly fullOutputPath?: string; };

@@ -31,10 +18,6 @@

const extractRender = (details: unknown): unknown => isRecord(details) ? details["render"] : undefined; -/** - * Build a renderResult over an ExpandableText stored in `details.render`. - * `label` names the tool for the error fallback (e.g. "web_search"). - */ export const renderExpandableText = ( label: string, result: { readonly details?: unknown },
M src/domains/screenshot.ts → src/domains/screenshot.ts

@@ -110,10 +110,7 @@ maxWidthCells: 80,

maxHeightCells: 24, filename: filePath, }); - // Image's text fallback (when terminal lacks inline-image support) does - // not respect the rendered width, so a long file path can blow past the - // terminal width and crash the host TUI. Wrap the component to truncate - // each rendered line to fit. + // Image's text fallback does not respect the rendered width, so an unwrapped long path can blow past the terminal width and crash the host TUI. return { invalidate: () => image.invalidate(), render: (width: number) =>

@@ -151,7 +148,6 @@ await load.sharp(cap.data.path).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).toFile(tmp);

await rename(tmp, cap.data.path); return ok({ path: cap.data.path }); } catch { - // Crosshair overlay is best-effort; on failure return the plain screenshot. return ok({ path: cap.data.path }); } };
M src/domains/scroll.ts → src/domains/scroll.ts

@@ -38,13 +38,10 @@ const cx = args.x ?? Math.round(info.data.width / 2);

const cy = args.y ?? Math.round(info.data.height / 2); const dx = args.deltaX ?? 0; const dy = args.deltaY ?? 300; - // The page target must be focused or Input events are dropped silently - // (mouseWheel hangs; synthesizeScrollGesture returns ok but no scroll). - // Page.bringToFront makes the target the active one in the browser. + // The page target must be focused or Input events are dropped silently: mouseWheel hangs and synthesizeScrollGesture returns ok without scrolling. const front = await cdpCall(client, "Page.bringToFront", {}); if (!front.success) return front; - // Establish compositor mouse position before mouseWheel; without this, - // CDP sometimes drops the wheel event with a timeout. + // Establish compositor mouse position first, or CDP sometimes drops the wheel event with a timeout. const moved = await cdpCall(client, "Input.dispatchMouseEvent", { type: "mouseMoved", x: cx, y: cy, button: "none", buttons: 0, });
M src/domains/search/extract.ts → src/domains/search/extract.ts

@@ -1,21 +1,6 @@

-/** - * The JavaScript source evaluated inside the Google SERP page to capture raw - * anchors + page text. This string crosses the CDP boundary via - * Runtime.evaluate (returnByValue) and returns a JSON string that - * `parseGoogleSerp` / `classifySerp` consume on the harness side. - * - * ADR-0001: anchors are selected structurally (a link carrying an <h3>), not by - * Google CSS class names, so cosmetic markup churn does not break extraction. - */ -/** Max bytes of page text captured for CAPTCHA / no-results classification. */ const PAGE_TEXT_LIMIT = 4000; -/** - * Build the extraction expression. Takes no interpolated values, so it is a - * fixed source string (no injection surface). Returns - * `JSON.stringify({ anchors, pageText })`. - */ export const buildSerpExtractionExpr = (): string => ` (() => { const main = document.querySelector('#search, #rso, #main') || document.body;
M src/domains/search/google-serp.ts → src/domains/search/google-serp.ts

@@ -1,27 +1,15 @@

-/** - * Pure Google SERP parsing — no CDP, no browser. Everything here is a pure - * function over data captured from the page, so it is unit-testable against - * saved fixtures (see test/deep-research/serp-parser-test.ts). - * - * ADR-0001: results are extracted by structure/semantics (anchors carrying an - * <h3> heading in the main results column), never by brittle Google CSS class - * names, and Google redirect wrappers are unwound to the real target URL. - */ -/** One raw anchor captured in-page: a link with a heading and nearby snippet. */ export type SerpAnchor = { readonly href: string; readonly heading: string; readonly snippet: string; }; -/** The JSON payload the in-page extraction expression returns. */ export type SerpExtraction = { readonly anchors: ReadonlyArray<SerpAnchor>; readonly pageText: string; }; -/** A parsed, ranked search result — the tool's public output shape. */ export type SearchResult = { readonly title: string; readonly url: string;

@@ -29,7 +17,6 @@ readonly snippet: string;

readonly rank: number; }; -/** Why a SERP yielded no usable results, or that it did. */ export type SerpVerdict = "ok" | "captcha" | "no_results"; const CAPTCHA_MARKERS: ReadonlyArray<string> = [

@@ -42,10 +29,6 @@ "before you continue to google",

"our systems have detected unusual traffic", ]; -/** - * Decode a Google redirect wrapper (`/url?q=<real>&sa=...`) to its target. - * Returns the input unchanged when it is not a wrapper. - */ const unwrapRedirect = (href: string): string => { const wrapper = href.match(/^(?:https?:\/\/[^/]*google\.[^/]*)?\/url\?/i); if (!wrapper) return href;

@@ -66,7 +49,6 @@ return false;

} }; -/** Normalized key for dedupe: origin + pathname, trailing slash and hash removed. */ const dedupeKey = (url: string): string => { try { const u = new URL(url);

@@ -76,11 +58,6 @@ return url.toLowerCase();

} }; -/** - * Parse captured anchors into ranked results: unwrap redirects, keep only - * external http(s) links, dedupe by normalized url (first wins), rank by input - * order, cap to `limit`. - */ export const parseGoogleSerp = (anchors: ReadonlyArray<SerpAnchor>, limit: number): ReadonlyArray<SearchResult> => { const seen = new Set<string>(); const results: SearchResult[] = [];

@@ -101,11 +78,6 @@ }

return results; }; -/** - * Decide whether the SERP is usable, blocked by a CAPTCHA, or genuinely empty. - * `resultCount` is the number of parsed results; `pageText` is a bounded slice - * of the page's visible text. - */ export const classifySerp = (pageText: string, resultCount: number): SerpVerdict => { if (resultCount > 0) return "ok"; const lower = pageText.toLowerCase();
M src/domains/setup.ts → src/domains/setup.ts

@@ -1,10 +1,3 @@

-/** - * browser_setup tool — agent-callable browser initialization. - * - * The agent can call this directly when browser tools fail with - * "not initialized" instead of asking the user. Idempotent: safe - * to call even when already connected. - */ import { Type } from "typebox"; import { Text } from "@mariozechner/pi-tui";

@@ -29,12 +22,9 @@ "After browser_setup succeeds, retry the browser tool that failed.",

], parameters: SetupArgs, concurrency: "parallel", - // ensureAlive:false — this tool IS the setup; it must bypass the socket guard ensureAlive: false, renderCall: () => new Text("🔧 Initializing browser...", 0, 0), async handler(_args, { client, extensionCtx }): Promise<Result<ToolOk, ToolErr>> { - // extensionCtx carries the UI, so the agent calling browser_setup gets the - // same first-run profile picker the user gets from /browser-setup. const result = await performSetup(client, extensionCtx); if (result.success) { return ok({ text: result.data });
M src/domains/snapshot.ts → src/domains/snapshot.ts

@@ -40,11 +40,8 @@ value?: string;

description?: string; state?: string; children: SlimNode[]; - /** Internal: kept for the post-build box fetch. Stripped before returning to the LLM. */ _backendId?: number; - /** Stable handle (e.g. "e12") for interaction tools. Survives re-renders via backendNodeId. */ ref?: string; - /** Click target — center of the element's bounding box, in viewport CSS pixels. */ box?: Box; };

@@ -63,9 +60,7 @@ "searchbox",

"spinbutton", ]); -// Roles whose live DOM value/checked state should override the (often stale) -// AX-tree value. The AX `value` is unreliable for freshly-typed or controlled -// inputs, so for these we read the real DOM property at snapshot time. +// The AX `value` is stale for freshly-typed or controlled inputs, so for these roles the live DOM property is read instead. const VALUE_ROLES = new Set([ "textbox", "searchbox",

@@ -91,7 +86,6 @@ for (const p of props) {

const name = p.name; if (!name) continue; const raw = p.value?.value; - // Boolean flags worth surfacing in the outline. if (raw === true && (name === "focused" || name === "required" || name === "disabled" || name === "checked" || name === "expanded" || name === "selected" || name === "pressed" || name === "modal")) { flags.push(name); }

@@ -104,12 +98,9 @@ export const buildTree = (

rawNodes: ReadonlyArray<RawAxNode>, opts: { interestingOnly: boolean; maxNodes: number }, ): SlimNode[] => { - // Index for parent/child lookups. const byId = new Map<string, RawAxNode>(); for (const n of rawNodes) byId.set(n.nodeId, n); - // CDP doesn't mark a single root explicitly; the root nodes are the ones - // whose parentId isn't in the index (typically just one — RootWebArea). const rootIds: string[] = []; for (const n of rawNodes) { if (!n.parentId || !byId.has(n.parentId)) rootIds.push(n.nodeId);

@@ -120,8 +111,7 @@

const slim = (node: RawAxNode): SlimNode | undefined => { if (budget <= 0) return undefined; if (opts.interestingOnly && node.ignored) { - // Skip the ignored node but recurse through its children — Chrome often - // ignores wrapper divs but the meaningful descendants are still there. + // Chrome often ignores wrapper divs while their descendants remain meaningful, so recurse through an ignored node's children. const out: SlimNode[] = []; for (const cid of node.childIds ?? []) { const child = byId.get(cid);

@@ -129,10 +119,6 @@ if (!child) continue;

const slimChild = slim(child); if (slimChild) out.push(slimChild); } - // Hoist children into the parent. We return undefined; the parent's - // recursion will see them by re-walking — but that double-counts. Instead - // we handle hoisting at the parent level. So here just signal "skip me" - // by returning undefined; the parent will hoist by checking ignored flag. return out.length > 0 ? { role: "_hoist", children: out } : undefined; } budget--;

@@ -147,7 +133,6 @@ const child = byId.get(cid);

if (!child) continue; const slimChild = slim(child); if (!slimChild) continue; - // Hoist any synthetic _hoist nodes' children into this level. if (slimChild.role === "_hoist") children.push(...slimChild.children); else children.push(slimChild); }

@@ -187,7 +172,6 @@ walk(n.children);

} }; walk(nodes); - // Group landmarks (roles that are top-level structural). const landmarkRoles = new Set(["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region"]); let landmarks = 0; for (const [role, c] of counts) if (landmarkRoles.has(role)) landmarks += c;

@@ -211,8 +195,6 @@ let line = `${indent}- ${n.role}`;

if (n.name) line += ` "${n.name}"`; if (n.value && n.value !== n.name) line += ` = ${JSON.stringify(n.value)}`; if (n.state) line += ` (${n.state})`; - // Surface the stable ref + click coordinates for interactive nodes. Agents - // pass `ref` to interaction tools (survives re-renders); @(x,y) is a fallback. if (n.ref) line += ` [${n.ref}]`; if (n.box && INTERACTIVE_ROLES.has(n.role)) { line += ` @(${n.box.cx},${n.box.cy})`;

@@ -225,18 +207,12 @@ walk(nodes, 0);

return lines.join("\n"); }; -/** Strip internal _backendId from a slim tree before returning to callers. */ const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] => nodes.map((n) => { const { _backendId: _bid, ...rest } = n; return { ...rest, children: stripInternals(n.children) }; }); -/** - * Walk all slim nodes and collect (slimNode, backendId) pairs for interactive - * roles. Caller fetches DOM.getBoxModel for each and writes box back into the - * slim node by reference. - */ export const collectInteractiveTargets = ( nodes: ReadonlyArray<SlimNode>, ): Array<{ node: SlimNode; backendId: number }> => {

@@ -253,13 +229,6 @@ walk(nodes);

return out; }; -/** - * Read the live DOM value for a form control by backend node id. Resolves the - * node to a JS object then reads the property that matters for its type: - * `.value` for inputs/selects/textareas, `.checked` (→ "checked"/"unchecked") - * for checkboxes/radios. Returns undefined on any failure so the caller falls - * back to the AX value. Mirrors the box-fetch error handling: never throws. - */ const liveValue = async (session: CdpSession, backendId: number): Promise<string | undefined> => { const resolved = await session.call("DOM.resolveNode", { backendNodeId: backendId }); if (!resolved.success) return undefined;

@@ -309,33 +278,22 @@

async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { const session = client.session(); - // 1. AX tree const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {}); if (!axRes.success) return axRes; const rawNodes = axRes.data.nodes; - // 2. Page url/title const piRes = await client.pageInfo(); if (!piRes.success) return err({ kind: "cdp_error", message: piRes.error.message }); const pageUrl = "dialog" in piRes.data ? "" : piRes.data.url; const pageTitle = "dialog" in piRes.data ? "" : piRes.data.title; - // 3. Slim + format const slim = buildTree(rawNodes, { interestingOnly: args.interestingOnly ?? true, maxNodes: args.maxNodes ?? 1000, }); - // 4. Fetch bounding boxes for interactive nodes so the agent can click - // without a screenshot round-trip. Capped budget so a slow page can't - // wedge the call. const targets = collectInteractiveTargets(slim); - // Assign stable refs (e1, e2, …) to every interactive target. Interaction - // tools resolve these refs to backendNodeIds at call time, so they stay - // valid across re-renders. The ref → backendNodeId map (and the per-ref - // signature baseline for the post-mutation diff) is published below, after - // the box/live-value fetch so the signature reflects the live DOM value. const refMap = new Map<string, number>(); targets.forEach(({ node, backendId }, i) => { const ref = `e${i + 1}`;

@@ -353,10 +311,6 @@ const box = await boxOf(client, backendId);

if (!box.success) return; if (box.data.x < 0 || box.data.y < 0) return; node.box = box.data; - // Override the AX value with the live DOM property for form controls. - // The AX `value` is stale for freshly-typed / controlled inputs, so the - // agent can't trust it as a read-back signal. Reading .value/.checked - // from the actual element fixes that. if (ac.signal.aborted || !VALUE_ROLES.has(node.role)) return; const live = await liveValue(session, backendId); if (live !== undefined) node.value = live;

@@ -365,9 +319,6 @@ );

clearTimeout(timer); } - // Publish refs to the active tab. The signature (role|name|value|state) is - // the baseline the post-mutation auto-diff compares against, so it must use - // the live values resolved above. const refSig = new Map<string, string>(); for (const { node } of targets) { if (node.ref === undefined) continue;

@@ -379,7 +330,6 @@ const stripped = stripInternals(slim);

const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripped, null, 2); const trunc = await applyTruncation(text, "snapshot"); - // 4. Optional screenshot — JPEG q=80 to match screenshotTool's default. let shotPath: string | undefined; if (args.includeScreenshot) { const shot = await session.call("Page.captureScreenshot", { format: "jpeg", quality: 80 });

@@ -429,9 +379,6 @@ ].filter((l) => l !== "").join("\n");

return new Markdown(md, 0, 0, getMarkdownTheme()); } - // Expanded view: read truncated text from result.content (the spilled file - // is too large to inline) — the same `text` we returned from handler is in - // result.content[0]. Fall back to a placeholder if shape is unexpected. const content = result.content[0]; const treeText = content && content.type === "text" ? content.text : "";

@@ -451,7 +398,6 @@ const treeBlock = new Markdown(headerMd, 0, 0, getMarkdownTheme());

if (!details.screenshotPath) return treeBlock; - // Mirror screenshotTool's width-clamp wrapper for the inline image. try { const buf = readFileSync(details.screenshotPath); const b64 = buf.toString("base64");
M src/domains/tabs.ts → src/domains/tabs.ts

@@ -87,8 +87,6 @@ ],

parameters: SwitchTabArgs, concurrency: "serialized", async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { - // Resolve to a concrete targetId (exact, or unique hex prefix). We resolve - // first so the ownership check sees the canonical id, not the prefix. let resolved = args.targetId; if (!client.owns(resolved)) { const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);