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

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

refactor: merge nine files whose reason to exist was gone

src/ goes from 84 files to 75 at the same line count — every merge below
had exactly one consumer.

cdp/event-queue.ts and cdp/pending-requests.ts become daemon/queue.ts.
I split these in the daemon-internals commit on the grounds that they
held two unrelated concerns, but the transport commit had already deleted
the second transport, so both halves had one consumer and the split
bought nothing. They also sat in cdp/ while only daemon/ used them.

cdp/transport.ts held only the CdpTransport interface after the second
transport went; five of its six consumers import it type-only, so it
moves to cdp/types.ts. cdp/evaluate.ts moves into cdp/session.ts rather
than into its lone consumer isolated-tab.ts — it reaches session.call
directly, so under src/domains/ it would trip the raw-cdp-call rule.

util/keycodes.ts and util/time.ts fold into their only callers
(keyboard.ts, page.ts); util/bytes.ts into util/truncate.ts, which
already owns output-size formatting; readpage/capture.ts and
search/extract.ts into the tools beside them; profile/picker.ts into
profile/command.ts.

Kept deliberately: mutex.ts and domains/target.ts each have their own
test suite, and folding target.ts into click.ts would make a tool file
export a helper. sharp-shim.ts is an isolation boundary carrying two
allowlisted casts. result.ts, guards.ts, js-template.ts and paths.ts are
small but have 44, 12, 8 and 3 consumers.

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

dcb79fc62bdd4d9ec0cbdb2c97d3eb2b2dacdf22

parent

b484c017807acb902eaaaa4e242f8b79f8e8eba6

M docs/ARCHITECTURE.md → docs/ARCHITECTURE.md

@@ -36,6 +36,7 @@ cdp/session.ts typed call/callOnTarget/callBrowser, event routing,

console + network buffers | daemon/transport.ts -> daemon/* out-of-process daemon owning the Chrome socket + (CdpTransport is declared in cdp/types.ts) | Chrome (CDP over WebSocket) ```

@@ -46,7 +47,7 @@ | Directory | Role | Who may import it |

|---|---|---| | `profile/*` | decides *which* Chrome and which profile to drive | `index.ts`, `setup.ts`, `client.ts` — never a domain | | `schemas/*` | shared typebox arg fragments (`Coords`, `MouseButton`) and `parseJson` | anything | -| `util/*` | leaves — they import nothing from this repo except each other (`result`, `guards`, `mutex`, `time`, `keycodes`, `truncate`, `paths`, `js-template`, `sharp-shim`) | anything | +| `util/*` | leaves — they import nothing from this repo except each other (`result`, `guards`, `mutex`, `truncate`, `paths`, `js-template`, `sharp-shim`, `debug`) | anything | `src/util/tool.ts` sits in `util/` for locality but is not a leaf — it is the tool runtime layer in the diagram above and imports `client` and `daemon/spawn`.

@@ -54,10 +55,10 @@

### The import rule for domains A `domains/*` file may import from `schemas/`, `util/`, other `domains/`, the `BrowserClient` -type, and the *typed, session-level* parts of `cdp/` (`cdp/commands` types, `cdp/evaluate`, -`cdp/target-factory`, the buffers, the `CdpSession` type). +type, and the *typed, session-level* parts of `cdp/` (`cdp/commands` types, `cdp/target-factory`, +the buffers, and `cdp/session` for the `CdpSession` type and `evaluateJson`). -**It must never import from `daemon/`, `cdp/transport`, or `profile/`.** A domain that reaches +**It must never import from `daemon/` or `profile/`.** A domain that reaches the raw transport has escaped the validated boundary in section 2; a domain that reaches `profile/` has coupled a tool to Chrome-launch policy.
D src/cdp/evaluate.ts

@@ -1,30 +0,0 @@

-import { type Result, err, ok } from "../util/result"; -import { type CdpError, cdpError } from "./errors"; -import type { CdpSession } from "./session"; - -export const evaluateJson = async <T>( - session: CdpSession, - expression: string, - check: (v: unknown) => v is T, - opts?: { sessionId?: string | undefined; timeoutMs?: number | undefined }, -): Promise<Result<T, CdpError>> => { - const params = { - expression, - returnByValue: true, - awaitPromise: true, - }; - const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}; - const r = opts?.sessionId !== undefined - ? await session.callOnTarget("Runtime.evaluate", params, opts.sessionId, callOpts) - : await session.call("Runtime.evaluate", params, callOpts); - if (!r.success) return r; - const { result, exceptionDetails } = r.data; - if (exceptionDetails !== undefined) { - return err(cdpError("remote_error", exceptionDetails.text, "Runtime.evaluate")); - } - const value = result.value; - if (!check(value)) { - return err(cdpError("invalid_response", `unexpected evaluate shape (type: ${result.type}) for: ${expression.slice(0, 60)}`, "Runtime.evaluate")); - } - return ok(value); -};
D src/cdp/event-queue.ts

@@ -1,38 +0,0 @@

-import type { CdpEvent } from "./types"; - -export type EventQueue = { - readonly push: (e: CdpEvent) => void; - readonly end: () => void; - readonly iter: AsyncIterable<CdpEvent>; -}; - -export const makeEventQueue = (): EventQueue => { - const buf: CdpEvent[] = []; - const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = []; - let ended = false; - return { - push(e) { - if (ended) return; - const w = waiters.shift(); - if (w) w({ value: e, done: false }); - else buf.push(e); - }, - end() { - ended = true; - for (const w of waiters.splice(0)) w({ value: undefined, done: true }); - }, - iter: { - [Symbol.asyncIterator](): AsyncIterator<CdpEvent, undefined, undefined> { - return { - next: (): Promise<IteratorResult<CdpEvent, undefined>> => - new Promise((resolve) => { - const next = buf.shift(); - if (next) resolve({ value: next, done: false }); - else if (ended) resolve({ value: undefined, done: true }); - else waiters.push(resolve); - }), - }; - }, - }, - }; -};
D src/cdp/pending-requests.ts

@@ -1,47 +0,0 @@

-import { type Result, err } from "../util/result"; -import { type CdpError, cdpError } from "./errors"; - -export type CdpResult = Result<unknown, CdpError>; - -export type Pending = { - readonly resolve: (v: Result<unknown, CdpError>) => void; - readonly timer: ReturnType<typeof setTimeout>; - readonly method: string; -}; - -export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => { - for (const [, p] of pending) { - clearTimeout(p.timer); - p.resolve(err(cdpError("transport_closed", reason, p.method))); - } - pending.clear(); -}; - -export const sendWithTimeout = ( - pending: Map<number, Pending>, - id: number, - method: string, - timeoutMs: number, - timeoutLabel: string, - send: () => void, -): Promise<CdpResult> => - new Promise((resolve) => { - const timer = setTimeout(() => { - pending.delete(id); - resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method))); - }, timeoutMs); - pending.set(id, { resolve, timer, method }); - try { - send(); - } catch (e) { - clearTimeout(timer); - pending.delete(id); - resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method))); - } - }); - -export const makeOnClose = (closeListeners: Set<() => void>) => - (cb: () => void): (() => void) => { - closeListeners.add(cb); - return () => closeListeners.delete(cb); - };
M src/cdp/session.ts → src/cdp/session.ts

@@ -1,8 +1,8 @@

-import { map, type Result, ok } from "../util/result"; -import type { CdpError } from "./errors"; +import { map, type Result, err, ok } from "../util/result"; +import { type CdpError, cdpError } from "./errors"; import type { DialogInfo } from "./types"; import type { OwnershipRegistry } from "./ownership"; -import type { CdpTransport } from "./transport"; +import type { CdpTransport } from "./types"; import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands"; import { decodeEvent } from "./events"; import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";

@@ -374,3 +374,30 @@ },

}; return session; }; + +export const evaluateJson = async <T>( + session: CdpSession, + expression: string, + check: (v: unknown) => v is T, + opts?: { sessionId?: string | undefined; timeoutMs?: number | undefined }, +): Promise<Result<T, CdpError>> => { + const params = { + expression, + returnByValue: true, + awaitPromise: true, + }; + const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}; + const r = opts?.sessionId !== undefined + ? await session.callOnTarget("Runtime.evaluate", params, opts.sessionId, callOpts) + : await session.call("Runtime.evaluate", params, callOpts); + if (!r.success) return r; + const { result, exceptionDetails } = r.data; + if (exceptionDetails !== undefined) { + return err(cdpError("remote_error", exceptionDetails.text, "Runtime.evaluate")); + } + const value = result.value; + if (!check(value)) { + return err(cdpError("invalid_response", `unexpected evaluate shape (type: ${result.type}) for: ${expression.slice(0, 60)}`, "Runtime.evaluate")); + } + return ok(value); +};
D src/cdp/transport.ts

@@ -1,18 +0,0 @@

-import type { Result } from "../util/result"; -import type { CdpError } from "./errors"; -import type { CdpEvent } from "./types"; - -export const DEFAULT_TIMEOUT_MS = 15_000; - -export type CdpTransport = { - connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>; - close(): Promise<void>; - request( - method: string, - params: Record<string, unknown>, - opts?: { sessionId?: string | null; timeoutMs?: number }, - ): Promise<Result<unknown, CdpError>>; - events(): AsyncIterable<CdpEvent>; - state(): "open" | "closed" | "connecting"; - onClose(cb: () => void): () => void; -};
M src/cdp/types.ts → src/cdp/types.ts

@@ -1,4 +1,6 @@

+import type { Result } from "../util/result"; import { isRecord } from "../util/guards"; +import type { CdpError } from "./errors"; export type CdpEvent = { readonly method: string;

@@ -63,3 +65,18 @@ if (code !== undefined && typeof code !== "number") return false;

} return true; }; + +export const DEFAULT_TIMEOUT_MS = 15_000; + +export type CdpTransport = { + connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>; + close(): Promise<void>; + request( + method: string, + params: Record<string, unknown>, + opts?: { sessionId?: string | null; timeoutMs?: number }, + ): Promise<Result<unknown, CdpError>>; + events(): AsyncIterable<CdpEvent>; + state(): "open" | "closed" | "connecting"; + onClose(cb: () => void): () => void; +};
M src/client.ts → src/client.ts

@@ -6,7 +6,7 @@ import { safeJs } from "./util/js-template";

import { type Mutex, createMutex } from "./util/mutex"; import { discoverEndpoint } from "./cdp/discovery"; import { type CdpError, cdpError } from "./cdp/errors"; -import type { CdpTransport } from "./cdp/transport"; +import type { CdpTransport } from "./cdp/types"; import { type CdpSession, createCdpSession } from "./cdp/session"; import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership"; import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
A src/daemon/queue.ts

@@ -0,0 +1,85 @@

+import { type Result, err } from "../util/result"; +import { type CdpError, cdpError } from "../cdp/errors"; +import type { CdpEvent } from "../cdp/types"; + +export type EventQueue = { + readonly push: (e: CdpEvent) => void; + readonly end: () => void; + readonly iter: AsyncIterable<CdpEvent>; +}; + +export const makeEventQueue = (): EventQueue => { + const buf: CdpEvent[] = []; + const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = []; + let ended = false; + return { + push(e) { + if (ended) return; + const w = waiters.shift(); + if (w) w({ value: e, done: false }); + else buf.push(e); + }, + end() { + ended = true; + for (const w of waiters.splice(0)) w({ value: undefined, done: true }); + }, + iter: { + [Symbol.asyncIterator](): AsyncIterator<CdpEvent, undefined, undefined> { + return { + next: (): Promise<IteratorResult<CdpEvent, undefined>> => + new Promise((resolve) => { + const next = buf.shift(); + if (next) resolve({ value: next, done: false }); + else if (ended) resolve({ value: undefined, done: true }); + else waiters.push(resolve); + }), + }; + }, + }, + }; +}; + +export type CdpResult = Result<unknown, CdpError>; + +export type Pending = { + readonly resolve: (v: Result<unknown, CdpError>) => void; + readonly timer: ReturnType<typeof setTimeout>; + readonly method: string; +}; + +export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => { + for (const [, p] of pending) { + clearTimeout(p.timer); + p.resolve(err(cdpError("transport_closed", reason, p.method))); + } + pending.clear(); +}; + +export const sendWithTimeout = ( + pending: Map<number, Pending>, + id: number, + method: string, + timeoutMs: number, + timeoutLabel: string, + send: () => void, +): Promise<CdpResult> => + new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(id); + resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method))); + }, timeoutMs); + pending.set(id, { resolve, timer, method }); + try { + send(); + } catch (e) { + clearTimeout(timer); + pending.delete(id); + resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method))); + } + }); + +export const makeOnClose = (closeListeners: Set<() => void>) => + (cb: () => void): (() => void) => { + closeListeners.add(cb); + return () => closeListeners.delete(cb); + };
M src/daemon/transport.ts → src/daemon/transport.ts

@@ -2,10 +2,9 @@ import { createConnection, type Socket } from "node:net";

import { createInterface } from "node:readline"; import { type Result, err, ok } from "../util/result"; import { type CdpError, cdpError, classifyRemoteError } from "../cdp/errors"; -import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/transport"; +import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/types"; import type { CdpEvent } from "../cdp/types"; -import { makeEventQueue } from "../cdp/event-queue"; -import { type Pending, makeOnClose, rejectAllPending, sendWithTimeout } from "../cdp/pending-requests"; +import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./queue"; import { DAEMON_SOCKET_PATH, type WireRequest,
M src/domains/isolated-tab.ts → src/domains/isolated-tab.ts

@@ -2,7 +2,7 @@ import type { BrowserClient } from "../client";

import { type Result, err, ok } from "../util/result"; import { safeJs } from "../util/js-template"; import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory"; -import { evaluateJson } from "../cdp/evaluate"; +import { evaluateJson } from "../cdp/session"; import type { ToolErr } from "../util/tool"; import { cdpCallBrowser, cdpCallOnTarget, cdpErrToToolErr, evalJs } from "./cdp-call";
M src/domains/js.ts → src/domains/js.ts

@@ -6,7 +6,7 @@ import { Type } from "typebox";

import { Markdown, Text } from "@mariozechner/pi-tui"; import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import { type Result, err, ok } from "../util/result"; -import { formatBytes } from "../util/bytes"; +import { formatBytes } from "../util/truncate"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { evalJs } from "./cdp-call"; import { applyTruncation } from "../util/truncate";
M src/domains/keyboard.ts → src/domains/keyboard.ts

@@ -1,10 +1,18 @@

import { Type } from "typebox"; import { safeJs } from "../util/js-template"; -import { virtualKeyCode } from "../util/keycodes"; import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { cdpCall, evalJs } from "./cdp-call"; import { resolveRefToObjectId } from "./ref-resolve"; + +const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = { + Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32, + ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, + Home: 36, End: 35, PageUp: 33, PageDown: 34, +}; + +const virtualKeyCode = (key: string): number => + VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0); const TypeArgs = Type.Object({ text: Type.String({ description: "Text to type" }),
M src/domains/network.ts → src/domains/network.ts

@@ -2,7 +2,7 @@ import { Type } from "typebox";

import { Markdown, Text } from "@mariozechner/pi-tui"; import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import { type Result, err, ok } from "../util/result"; -import { formatBytes } from "../util/bytes"; +import { formatBytes } from "../util/truncate"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { cdpCall } from "./cdp-call"; import { applyTruncation } from "../util/truncate";
M src/domains/page.ts → src/domains/page.ts

@@ -2,8 +2,24 @@ import { Type } from "typebox";

import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { evalJs } from "./cdp-call"; -import { sleep } from "../util/time"; import { safeJs } from "../util/js-template"; + +const sleep = (ms: number, signal?: AbortSignal): Promise<void> => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("aborted")); + return; + } + const onAbort = (): void => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("aborted")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); export const pageInfoTool = defineBrowserTool({ name: "browser_page_info",
D src/domains/readpage/capture.ts

@@ -1,34 +0,0 @@

-const BODY_TEXT_LIMIT = 20_000; - -export const buildPageCaptureExpr = (): string => ` - (() => { - const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']); - const inBoilerplate = (el) => { - for (let n = el; n; n = n.parentElement) { - if (BOILERPLATE.has(n.tagName)) return true; - const role = n.getAttribute && n.getAttribute('role'); - if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true; - } - return false; - }; - const kindOf = (tag) => { - if (/^H[1-6]$/.test(tag)) return 'heading'; - if (tag === 'LI') return 'listitem'; - if (tag === 'BLOCKQUOTE') return 'blockquote'; - if (tag === 'P') return 'paragraph'; - return 'other'; - }; - const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote'; - const root = document.querySelector('article, main, [role=main]') || document.body; - const blocks = []; - for (const el of root.querySelectorAll(selector)) { - const text = (el.innerText || '').replace(/\\s+/g, ' ').trim(); - if (!text) continue; - let linkTextLength = 0; - for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length; - blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) }); - } - const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT}); - return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText }); - })() -`;
M src/domains/readpage/read-page.ts → src/domains/readpage/read-page.ts

@@ -15,7 +15,6 @@ openIsolatedTab,

waitForIsolatedLoad, } from "../isolated-tab"; import { renderExpandableText } from "../render"; -import { buildPageCaptureExpr } from "./capture"; import { extractReadable, type PageCapture, type ReadablePage } from "./readability"; const LOAD_TIMEOUT_MS = 15_000;

@@ -48,6 +47,41 @@ bodyText: Type.String(),

}); const pageCaptureValidator = Compile(PageCaptureSchema); + +const BODY_TEXT_LIMIT = 20_000; + +const buildPageCaptureExpr = (): string => ` + (() => { + const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']); + const inBoilerplate = (el) => { + for (let n = el; n; n = n.parentElement) { + if (BOILERPLATE.has(n.tagName)) return true; + const role = n.getAttribute && n.getAttribute('role'); + if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true; + } + return false; + }; + const kindOf = (tag) => { + if (/^H[1-6]$/.test(tag)) return 'heading'; + if (tag === 'LI') return 'listitem'; + if (tag === 'BLOCKQUOTE') return 'blockquote'; + if (tag === 'P') return 'paragraph'; + return 'other'; + }; + const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote'; + const root = document.querySelector('article, main, [role=main]') || document.body; + const blocks = []; + for (const el of root.querySelectorAll(selector)) { + const text = (el.innerText || '').replace(/\\s+/g, ' ').trim(); + if (!text) continue; + let linkTextLength = 0; + for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length; + blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) }); + } + const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT}); + return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText }); + })() +`; const toPageCapture = (raw: unknown): PageCapture | undefined => { if (typeof raw === "string") {
D src/domains/search/extract.ts

@@ -1,28 +0,0 @@

-const PAGE_TEXT_LIMIT = 4000; - -export const buildSerpExtractionExpr = (): string => ` - (() => { - const main = document.querySelector('#search, #rso, #main') || document.body; - const headings = Array.from(main.querySelectorAll('a h3')); - const seen = new Set(); - const anchors = []; - for (const h3 of headings) { - const a = h3.closest('a[href]'); - if (!a) continue; - const href = a.href; - if (!href || seen.has(href)) continue; - seen.add(href); - const container = - a.closest('div.g') || - a.closest('div[data-hveid]') || - a.parentElement?.parentElement || - a.parentElement; - const containerText = container ? container.innerText || '' : ''; - const heading = h3.innerText || h3.textContent || ''; - const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500); - anchors.push({ href, heading, snippet }); - } - const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT}); - return JSON.stringify({ anchors, pageText }); - })() -`;
M src/domains/search/web-search.ts → src/domains/search/web-search.ts

@@ -13,7 +13,6 @@ openIsolatedTab,

waitForIsolatedLoad, } from "../isolated-tab"; import { renderExpandableText } from "../render"; -import { buildSerpExtractionExpr } from "./extract"; import { classifySerp, parseGoogleSerp, type SearchResult, type SerpExtraction } from "./google-serp"; const LOAD_TIMEOUT_MS = 15_000;

@@ -24,6 +23,35 @@ limit: Type.Optional(

Type.Integer({ default: 10, minimum: 1, maximum: 30, description: "Max results to return. Default: 10." }), ), }); + +const PAGE_TEXT_LIMIT = 4000; + +const buildSerpExtractionExpr = (): string => ` + (() => { + const main = document.querySelector('#search, #rso, #main') || document.body; + const headings = Array.from(main.querySelectorAll('a h3')); + const seen = new Set(); + const anchors = []; + for (const h3 of headings) { + const a = h3.closest('a[href]'); + if (!a) continue; + const href = a.href; + if (!href || seen.has(href)) continue; + seen.add(href); + const container = + a.closest('div.g') || + a.closest('div[data-hveid]') || + a.parentElement?.parentElement || + a.parentElement; + const containerText = container ? container.innerText || '' : ''; + const heading = h3.innerText || h3.textContent || ''; + const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500); + anchors.push({ href, heading, snippet }); + } + const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT}); + return JSON.stringify({ anchors, pageText }); + })() +`; const buildSerpUrl = (query: string, limit: number): string => `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`;
M src/profile/command.ts → src/profile/command.ts

@@ -4,8 +4,54 @@ import { discoverEndpoint } from "../cdp/discovery";

import { type Result, err, ok } from "../util/result"; import { type BrowserProfile, listProfiles } from "./list"; import { browserNameForUserDataDir, userDataDirCandidates } from "./paths"; -import { pinFor, promptForProfile } from "./picker"; -import { clearPin, readPin, writePin } from "./store"; +import { clearPin, type ProfilePin, readPin, writePin } from "./store"; + +// `ctx.ui.select` resolves to undefined both on Escape and in non-interactive modes, so "no answer" must mean "change nothing". + +const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —"; + +type ProfileChoice = + | { readonly kind: "profile"; readonly profile: BrowserProfile } + | { readonly kind: "clear" } + | { readonly kind: "cancelled" }; + +const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => { + const counts = new Map<string, number>(); + for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1); + return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label)); +}; + +const promptForProfile = async ( + ctx: ExtensionContext, + profiles: ReadonlyArray<BrowserProfile>, + currentPin: ProfilePin | null, + title = "Select browser profile", +): Promise<ProfileChoice> => { + if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" }; + + const labels = disambiguate(profiles); + const rows = labels.map((label, i) => { + const isPinned = currentPin?.profileDir === profiles[i]?.dir; + return isPinned ? `✓ ${label}` : ` ${label}`; + }); + const options = [...rows, CLEAR_SELECTION_LABEL]; + + const answer = await ctx.ui.select(title, options); + if (answer === undefined) return { kind: "cancelled" }; + if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" }; + + const index = options.indexOf(answer); + const profile = index >= 0 ? profiles[index] : undefined; + if (!profile) return { kind: "cancelled" }; + return { kind: "profile", profile }; +}; + +const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({ + userDataDir, + profileDir: profile.dir, + label: profile.label, + savedAt: new Date().toISOString(), +}); const resolveUserDataDir = async (client: BrowserClient): Promise<Result<string, string>> => { const connected = client.userDataDir();
D src/profile/picker.ts

@@ -1,50 +0,0 @@

-// `ctx.ui.select` resolves to undefined both on Escape and in non-interactive modes, so "no answer" must mean "change nothing". - -import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; -import type { BrowserProfile } from "./list"; -import type { ProfilePin } from "./store"; - -const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —"; - -export type ProfileChoice = - | { readonly kind: "profile"; readonly profile: BrowserProfile } - | { readonly kind: "clear" } - | { readonly kind: "cancelled" }; - -const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => { - const counts = new Map<string, number>(); - for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1); - return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label)); -}; - -export const promptForProfile = async ( - ctx: ExtensionContext, - profiles: ReadonlyArray<BrowserProfile>, - currentPin: ProfilePin | null, - title = "Select browser profile", -): Promise<ProfileChoice> => { - if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" }; - - const labels = disambiguate(profiles); - const rows = labels.map((label, i) => { - const isPinned = currentPin?.profileDir === profiles[i]?.dir; - return isPinned ? `✓ ${label}` : ` ${label}`; - }); - const options = [...rows, CLEAR_SELECTION_LABEL]; - - const answer = await ctx.ui.select(title, options); - if (answer === undefined) return { kind: "cancelled" }; - if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" }; - - const index = options.indexOf(answer); - const profile = index >= 0 ? profiles[index] : undefined; - if (!profile) return { kind: "cancelled" }; - return { kind: "profile", profile }; -}; - -export const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({ - userDataDir, - profileDir: profile.dir, - label: profile.label, - savedAt: new Date().toISOString(), -});
D src/util/bytes.ts

@@ -1,5 +0,0 @@

-export const formatBytes = (n: number): string => { - if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`; - if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${n} B`; -};
D src/util/keycodes.ts

@@ -1,8 +0,0 @@

-const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = { - Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32, - ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, - Home: 36, End: 35, PageUp: 33, PageDown: 34, -}; - -export const virtualKeyCode = (key: string): number => - VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
D src/util/time.ts

@@ -1,16 +0,0 @@

-export const sleep = (ms: number, signal?: AbortSignal): Promise<void> => - new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason ?? new Error("aborted")); - return; - } - const onAbort = (): void => { - clearTimeout(timer); - reject(signal?.reason ?? new Error("aborted")); - }; - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - signal?.addEventListener("abort", onAbort, { once: true }); - });
M src/util/truncate.ts → src/util/truncate.ts

@@ -33,3 +33,9 @@ const omitted = t.totalBytes - t.outputBytes;

const text = `${t.content}\n\n[Output truncated: ${t.outputLines} of ${t.totalLines} lines (${formatSize(t.outputBytes)} of ${formatSize(t.totalBytes)}). ${formatSize(omitted)} omitted. Full output: ${file}]`; return { text, fullOutputPath: file, wasTruncated: true }; }; + +export const formatBytes = (n: number): string => { + if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`; + if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${n} B`; +};
M test/cdp/evaluate-test.ts → test/cdp/session-evaluate-test.ts

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

import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { evaluateJson } from "../../src/cdp/evaluate"; +import { evaluateJson } from "../../src/cdp/session"; import type { CdpSession } from "../../src/cdp/session"; import { err, ok } from "../../src/util/result";
M test/cdp/event-queue-test.ts → test/daemon/queue-test.ts

@@ -1,7 +1,6 @@

import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { makeEventQueue } from "../../src/cdp/event-queue"; -import { rejectAllPending, sendWithTimeout, type Pending } from "../../src/cdp/pending-requests"; +import { makeEventQueue, rejectAllPending, sendWithTimeout, type Pending } from "../../src/daemon/queue"; describe("event queue iteration", () => { test("yields pushed events in order", async () => {
M test/cdp/session-call-test.ts → test/cdp/session-call-test.ts

@@ -2,7 +2,7 @@ import { test, describe } from "node:test";

import assert from "node:assert/strict"; import { decodeResult } from "../../src/cdp/commands"; import { createCdpSession } from "../../src/cdp/session"; -import type { CdpTransport } from "../../src/cdp/transport"; +import type { CdpTransport } from "../../src/cdp/types"; import type { CdpEvent } from "../../src/cdp/types"; import { type Result, ok, err } from "../../src/util/result"; import { type CdpError, cdpError } from "../../src/cdp/errors";
M test/domains/fake-client.ts → test/domains/fake-client.ts

@@ -1,7 +1,7 @@

import { createCdpSession, type CdpSession } from "../../src/cdp/session"; import { createOwnershipRegistry, type OwnershipRegistry } from "../../src/cdp/ownership"; import { type CdpError, cdpError } from "../../src/cdp/errors"; -import type { CdpTransport } from "../../src/cdp/transport"; +import type { CdpTransport } from "../../src/cdp/types"; import type { CdpEvent } from "../../src/cdp/types"; import type { BrowserClient } from "../../src/client"; import { createMutex } from "../../src/util/mutex";
M test/manual/window-binding-test.ts → test/manual/window-binding-test.ts

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

import { createBrowserClient } from "../../src/client"; import { createOwnershipRegistry } from "../../src/cdp/ownership"; -import type { CdpTransport } from "../../src/cdp/transport"; +import type { CdpTransport } from "../../src/cdp/types"; import type { CdpEvent } from "../../src/cdp/types"; import { ok, type Result } from "../../src/util/result"; import type { CdpError } from "../../src/cdp/errors";