release: v0.5.0 — browser_console tool for JS error/console diagnostics
@@ -2,6 +2,22 @@ # Changelog
All notable changes to pi-browser-harness will be documented in this file. +## 0.5.0 — 2026-05-07 + +### Added + +- **`browser_console`** — new tool. Reads JS errors and console messages from the active tab via two CDP sources merged into one buffer: `Runtime.consoleAPICalled` (page `console.*` calls and uncaught exceptions) and `Log.entryAdded` (browser-level entries — CSP violations, mixed content, deprecations, network errors). Filters: `levels` (log/info/warn/error/debug), `textPattern` (substring; wrap in slashes for regex), `sinceMs`, `limit` (default 50, cap 500). Each record carries a monotonic `seq`; the response includes `nextCursor` so callers can pass `sinceSeq` to see only what's new since the previous drain — the cursor pattern that makes "what did this action cause?" answerable in one call. Buffer is page-scoped (cleared on tab switch) and bounded at 500 records; `bufferOverflowed` flag reports drops since the last drain. Stack traces (top 3 frames) are preserved for error/warn records. Per-arg cap of 2 KB prevents a single `console.log(hugeBlob)` from blowing the buffer. +- **`Log` CDP domain enabled** alongside Page/DOM/Runtime/Network/Accessibility on every attach. (`Runtime` was already enabled, so `Runtime.consoleAPICalled` was reachable; this adds the missing domain for browser-level entries.) +- **`browser_console` ships with a custom `renderResult`** following the established pattern — collapsed: header counts (`3 errors · 2 warnings · 47 logs`) + last 5 rows. Expanded: full list with stack traces inline as code fences. Appends `keyHint("app.tools.expand", ...)` so the binding label adapts to user remaps. + +### Internal + +- New pure module `src/cdp/console-buffer.ts` mirrors the `network-buffer.ts` pattern (insertion-ordered Map, FIFO eviction at capacity 500, overflow flag reset per drain). Wired into the existing single-consumer event loop in `src/cdp/session.ts`. + +### Known follow-ups + +- No tests added in this release. Verification is manual against real Chrome. + ## 0.4.0 — 2026-05-06 ### Added
@@ -1,6 +1,6 @@
{ "name": "pi-browser-harness", - "version": "0.4.0", + "version": "0.5.0", "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP", "keywords": [ "pi-package",
@@ -21,6 +21,9 @@ │
├─ Network behavior on the current page? │ → browser_network_requests │ + ├─ JS errors / why did nothing happen after an action? + │ → browser_console (DIAGNOSTIC — only when something looks broken) + │ └─ Visual rendering (layout / colors / chart drew correctly)? → browser_screenshot (LAST RESORT — pixels only) ```@@ -30,6 +33,10 @@
## Connection You're attached to the user's real Chrome — never launch your own. If auth is required, stop and ask the user. If `browser_page_info` returns a dialog, handle it first with `browser_handle_dialog`. + +## Diagnosing a "nothing happened" moment + +When an action runs but the page didn't change, capture `browser_console`'s `nextCursor` *before* the action, take the action, then call `browser_console({ sinceSeq: <cursor> })` after — this isolates what your action caused from what was already there. Pair with `browser_network_requests({ sinceMs: 5000 })` to see if an API call fired and failed. The console buffer is page-scoped: it clears on tab switch, capacity 500. ## Temporary scripts
@@ -0,0 +1,265 @@
+/** + * In-memory aggregator for CDP Runtime.consoleAPICalled and Log.entryAdded events. + * + * Mirrors network-buffer.ts: the single CDP event loop in session.ts hands raw + * params to ingestConsoleApi / ingestLogEntry; tools later read records via drain(). + * + * Bounded by capacity (default 500). On overflow the oldest record is evicted FIFO, + * and the overflow flag is reported once per drain so tools can warn the LLM that + * data may be missing. + * + * Each record carries a monotonic `seq`. Tools expose the last drained seq as + * `nextCursor`; callers pass it back as `sinceSeq` to read only what's new since + * the previous drain. This is the cursor pattern that makes "what did this action + * cause?" answerable in one tool call. + * + * Pure module (no CDP dependency) — joining/filtering logic stays trivially testable. + */ + +export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug"; + +export type ConsoleRecord = { + seq: number; + /** Wall-clock ms when the event was ingested, in Date.now() units. */ + timestampMs: number; + level: ConsoleLevel; + /** Joined args (console-api) or the entry text (log-entry). Per-arg truncated. */ + text: string; + source: "console-api" | "log-entry"; + url?: string; + lineNumber?: number; + /** Top frames only, joined with newlines. Populated for error/warn when CDP provides one. */ + stackTrace?: string; +}; + +export type ConsoleFilter = { + levels?: ConsoleLevel[]; + /** Substring match by default. Wrap in slashes (`/foo/`) for regex. */ + textPattern?: string; + /** Only records with seq strictly greater than this. */ + sinceSeq?: number; + /** Only records with timestamp at most N ms ago. */ + sinceMs?: number; + /** Cap on returned records (default 50, max 500). */ + limit?: number; +}; + +export type ConsoleDrainResult = { + readonly records: ConsoleRecord[]; + /** Total matches before `limit` was applied. */ + readonly total: number; + /** True if the ring buffer evicted at least one record since the last drain. */ + readonly bufferOverflowed: boolean; +}; + +export type ConsoleBuffer = { + ingestConsoleApi(p: unknown): void; + ingestLogEntry(p: unknown): void; + drain(filter: ConsoleFilter): ConsoleDrainResult; + clear(): void; +}; + +/** Per-arg cap to stop a single console.log(hugeBlob) from blowing the buffer. */ +const PER_ARG_CAP = 2000; +/** Max stack frames preserved per record. */ +const MAX_STACK_FRAMES = 3; + +const compileTextMatcher = (pattern: string): ((text: string) => boolean) => { + if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) { + try { + const re = new RegExp(pattern.slice(1, -1)); + return (text) => re.test(text); + } catch { + // Fall through to substring match if the pattern is not a valid regex. + } + } + return (text) => text.includes(pattern); +}; + +const truncateArg = (s: string): string => + s.length > PER_ARG_CAP ? s.slice(0, PER_ARG_CAP) + "…" : s; + +/** Best-effort stringification of one Runtime.RemoteObject. */ +const stringifyRemoteObject = ( + arg: { type?: string; subtype?: string; value?: unknown; description?: string; unserializableValue?: string }, +): string => { + if (arg.unserializableValue !== undefined) return arg.unserializableValue; + if (arg.value !== undefined) { + if (typeof arg.value === "string") return arg.value; + try { + return JSON.stringify(arg.value); + } catch { + return String(arg.value); + } + } + if (arg.description !== undefined) return arg.description; + return arg.type ?? ""; +}; + +/** Map CDP console-API method names to our normalized levels. */ +const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => { + switch (type) { + case "error": + case "assert": + return "error"; + case "warning": + return "warn"; + case "debug": + case "trace": + return "debug"; + case "info": + return "info"; + default: + return "log"; + } +}; + +/** Map Log.LogEntry.level values ("verbose","info","warning","error") to our levels. */ +const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => { + switch (level) { + case "error": + return "error"; + case "warning": + return "warn"; + case "verbose": + return "debug"; + default: + return "info"; + } +}; + +const formatStackTrace = ( + st: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> } | undefined, +): string | undefined => { + const frames = st?.callFrames; + if (!frames || frames.length === 0) return undefined; + const top = frames.slice(0, MAX_STACK_FRAMES); + const rendered = top.map((f) => { + const fn = f.functionName !== undefined && f.functionName !== "" ? f.functionName : "<anonymous>"; + const loc = f.url !== undefined && f.url !== "" ? `${f.url}:${(f.lineNumber ?? 0) + 1}` : "<unknown>"; + return ` at ${fn} (${loc})`; + }); + return rendered.join("\n"); +}; + +export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => { + // Insertion-ordered map keyed by seq. Map preserves insertion order in JS, + // so iteration gives us oldest-first eviction without a separate index. + const records = new Map<number, ConsoleRecord>(); + let nextSeq = 1; + let overflowed = false; + + const evictOldestIfFull = (): void => { + while (records.size >= capacity) { + const oldest = records.keys().next(); + if (oldest.done) return; + records.delete(oldest.value); + overflowed = true; + } + }; + + const insert = (rec: ConsoleRecord): void => { + evictOldestIfFull(); + records.set(rec.seq, rec); + }; + + return { + ingestConsoleApi(p) { + // CDP boundary cast: Runtime.consoleAPICalled + const params = p as + | { + type?: string; + args?: ReadonlyArray<{ + type?: string; + subtype?: string; + value?: unknown; + description?: string; + unserializableValue?: string; + }>; + stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> }; + } + | undefined; + if (!params) return; + const level = mapConsoleApiLevel(params.type); + const argTexts = (params.args ?? []).map((a) => truncateArg(stringifyRemoteObject(a))); + const text = argTexts.join(" "); + const stack = level === "error" || level === "warn" ? formatStackTrace(params.stackTrace) : undefined; + const top = params.stackTrace?.callFrames?.[0]; + const rec: ConsoleRecord = { + seq: nextSeq++, + timestampMs: Date.now(), + level, + text, + source: "console-api", + ...(top?.url !== undefined && top.url !== "" ? { url: top.url } : {}), + ...(top?.lineNumber !== undefined ? { lineNumber: top.lineNumber + 1 } : {}), + ...(stack !== undefined ? { stackTrace: stack } : {}), + }; + insert(rec); + }, + + ingestLogEntry(p) { + // CDP boundary cast: Log.entryAdded + const params = p as + | { + entry?: { + level?: string; + text?: string; + url?: string; + lineNumber?: number; + stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> }; + }; + } + | undefined; + const entry = params?.entry; + if (!entry) return; + const level = mapLogEntryLevel(entry.level); + const text = truncateArg(entry.text ?? ""); + const stack = level === "error" || level === "warn" ? formatStackTrace(entry.stackTrace) : undefined; + const rec: ConsoleRecord = { + seq: nextSeq++, + timestampMs: Date.now(), + level, + text, + source: "log-entry", + ...(entry.url !== undefined && entry.url !== "" ? { url: entry.url } : {}), + ...(entry.lineNumber !== undefined ? { lineNumber: entry.lineNumber + 1 } : {}), + ...(stack !== undefined ? { stackTrace: stack } : {}), + }; + insert(rec); + }, + + drain(filter) { + const matchText = filter.textPattern !== undefined ? compileTextMatcher(filter.textPattern) : undefined; + const levels = filter.levels && filter.levels.length > 0 ? new Set(filter.levels) : undefined; + const cutoff = filter.sinceMs !== undefined ? Date.now() - filter.sinceMs : undefined; + const sinceSeq = filter.sinceSeq; + + const matched: ConsoleRecord[] = []; + for (const r of records.values()) { + if (sinceSeq !== undefined && r.seq <= sinceSeq) continue; + if (levels && !levels.has(r.level)) continue; + if (cutoff !== undefined && r.timestampMs < cutoff) continue; + if (matchText && !matchText(r.text)) continue; + matched.push({ ...r }); + } + + const total = matched.length; + const limit = Math.min(filter.limit ?? 50, 500); + const limited = matched.slice(-limit); + + const bufferOverflowed = overflowed; + overflowed = false; + + return { records: limited, total, bufferOverflowed }; + }, + + clear() { + records.clear(); + overflowed = false; + // nextSeq is intentionally NOT reset: the cursor remains monotonic across + // tab switches so a stale sinceSeq from a previous tab can't accidentally + // match new records. + }, + }; +};
@@ -4,6 +4,7 @@ import type { DialogInfo } from "./types";
import type { OwnershipRegistry } from "./ownership"; import type { CdpTransport } from "./transport"; import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer"; +import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer"; export type CdpSession = { attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;@@ -15,6 +16,7 @@ callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
takeDialog(): DialogInfo | null; drainPageInfoInvalidations(): boolean; drainNetworkBuffer(filter: NetworkFilter): DrainResult; + drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult; }; export const createCdpSession = (@@ -26,6 +28,7 @@ let targetId: string | null = null;
let dialog: DialogInfo | null = null; let pageInfoDirty = false; const networkBuffer = createNetworkBuffer(); + const consoleBuffer = createConsoleBuffer(); let activeConsumer: Promise<void> = Promise.resolve();@@ -55,6 +58,8 @@ if (ev.method === "Network.requestWillBeSent") networkBuffer.ingestRequestWillBeSent(ev.params);
else if (ev.method === "Network.responseReceived") networkBuffer.ingestResponseReceived(ev.params); else if (ev.method === "Network.loadingFinished") networkBuffer.ingestLoadingFinished(ev.params); else if (ev.method === "Network.loadingFailed") networkBuffer.ingestLoadingFailed(ev.params); + else if (ev.method === "Runtime.consoleAPICalled") consoleBuffer.ingestConsoleApi(ev.params); + else if (ev.method === "Log.entryAdded") consoleBuffer.ingestLogEntry(ev.params); } };@@ -82,7 +87,7 @@ // TODO(perf): the four enable calls are sequential here for predictability.
// Switching to Promise.all over a single WS pipelines the round-trips and // saves ~3× on tab-switch latency. Defer until session.ts has tests. const enableDomains = async (sid: string): Promise<void> => { - for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility"]) { + for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) { await transport.request(`${d}.enable`, {}, { sessionId: sid }); } };@@ -153,6 +158,7 @@ pageInfoDirty = true;
// Buffer is page-scoped: on switch, the network history of the previous // tab is no longer relevant to what the agent is now looking at. networkBuffer.clear(); + consoleBuffer.clear(); await enableDomains(a.sessionId); return ok(undefined); },@@ -180,6 +186,9 @@ return dirty;
}, drainNetworkBuffer(filter) { return networkBuffer.drain(filter); + }, + drainConsoleBuffer(filter) { + return consoleBuffer.drain(filter); }, }; };
@@ -0,0 +1,173 @@
+import { Type } from "typebox"; +import { Markdown, Text } from "@mariozechner/pi-tui"; +import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; +import { type Result, ok } from "../util/result"; +import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; +import { applyTruncation } from "../util/truncate"; +import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer"; + +const ConsoleArgs = Type.Object({ + levels: Type.Optional( + Type.Array( + Type.Union( + [ + Type.Literal("log"), + Type.Literal("info"), + Type.Literal("warn"), + Type.Literal("error"), + Type.Literal("debug"), + ], + ), + { description: 'Filter to these levels only, e.g. ["error","warn"]. Default: all levels.' }, + ), + ), + textPattern: Type.Optional( + Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /TypeError/) for regex." }), + ), + sinceSeq: Type.Optional( + Type.Integer({ minimum: 0, description: "Only records with seq strictly greater than this. Use the previous call's nextCursor to see only what's new." }), + ), + sinceMs: Type.Optional( + Type.Integer({ minimum: 0, description: "Only records from the last N ms." }), + ), + limit: Type.Optional( + Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }), + ), +}); + +const levelTag = (lvl: ConsoleLevel): string => { + switch (lvl) { + case "error": return "[error]"; + case "warn": return "[warn] "; + case "info": return "[info] "; + case "debug": return "[debug]"; + default: return "[log] "; + } +}; + +const formatLocation = (r: ConsoleRecord): string => { + if (r.url === undefined || r.url === "") return ""; + const short = r.url.length > 60 ? "…" + r.url.slice(-59) : r.url; + return r.lineNumber !== undefined ? `${short}:${r.lineNumber}` : short; +}; + +const renderConsoleMarkdown = ( + records: ReadonlyArray<ConsoleRecord>, + opts: { total: number; bufferOverflowed: boolean; expanded: boolean; nextCursor: number | undefined }, +): string => { + if (records.length === 0) { + if (opts.bufferOverflowed) return "_No matching console records. (Buffer overflowed since last drain — some events were dropped.)_"; + return "_No matching console records captured yet._"; + } + + const counts = records.reduce<Record<ConsoleLevel, number>>( + (acc, r) => { acc[r.level]++; return acc; }, + { log: 0, info: 0, warn: 0, error: 0, debug: 0 }, + ); + + const summaryParts: string[] = []; + if (counts.error > 0) summaryParts.push(`${counts.error} error${counts.error === 1 ? "" : "s"}`); + if (counts.warn > 0) summaryParts.push(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`); + if (counts.info > 0) summaryParts.push(`${counts.info} info`); + if (counts.log > 0) summaryParts.push(`${counts.log} log${counts.log === 1 ? "" : "s"}`); + if (counts.debug > 0) summaryParts.push(`${counts.debug} debug`); + + const headerLines: string[] = [`**${summaryParts.join(" · ")}**`]; + if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older entries dropped"); + if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`); + + const renderRow = (r: ConsoleRecord, compact: boolean): string => { + const tag = levelTag(r.level); + const loc = formatLocation(r); + const maxText = compact ? 80 : 200; + const text = r.text.length > maxText ? r.text.slice(0, maxText - 1) + "…" : r.text; + const locSuffix = loc !== "" ? ` _at ${loc}_` : ""; + const main = `\`${tag}\` ${text}${locSuffix}`; + if (!compact && r.stackTrace !== undefined) return `${main}\n\`\`\`\n${r.stackTrace}\n\`\`\``; + return main; + }; + + const visible = !opts.expanded && records.length > 5 ? records.slice(-5) : records; + const rows = visible.map((r) => renderRow(r, !opts.expanded)).join(opts.expanded ? "\n\n" : "\n"); + + const more = !opts.expanded && records.length > 5 ? `\n … ${records.length - 5} earlier (expand to see all)` : ""; + const cursor = opts.nextCursor !== undefined ? `\n\n_nextCursor=${opts.nextCursor}_` : ""; + + return `${headerLines.join("\n")}\n\n${rows}${more}${cursor}`; +}; + +type ConsoleDetails = { + total: number; + returned: number; + bufferOverflowed: boolean; + truncated: boolean; + fullOutputPath?: string; + records: ReadonlyArray<ConsoleRecord>; + nextCursor?: number; +}; + +export const consoleTool = defineBrowserTool({ + name: "browser_console", + label: "Browser Console", + description: + "Read JS errors and console messages from the active tab. Diagnostic — use when an action looks broken or silent.", + promptSnippet: "Read JS errors / console output on the current tab", + promptGuidelines: [ + "Use after a click, submit, or navigate produced no visible change — errors often explain it.", + "Pass `sinceSeq` from the previous call's `nextCursor` to see only new messages after an action.", + "Buffer is reset on tab switch (capacity 500 records). bufferOverflowed:true means older entries were dropped.", + "Don't use as a default observation tool — prefer browser_snapshot for page structure.", + ], + parameters: ConsoleArgs, + + async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + const session = client.session(); + const drained = session.drainConsoleBuffer({ + ...(args.levels !== undefined ? { levels: args.levels } : {}), + ...(args.textPattern !== undefined ? { textPattern: args.textPattern } : {}), + ...(args.sinceSeq !== undefined ? { sinceSeq: args.sinceSeq } : {}), + ...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}), + limit: args.limit ?? 50, + }); + + const last = drained.records.at(-1); + const nextCursor = last?.seq; + + 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 + nextCursor, + }); + const trunc = await applyTruncation(text, "console"); + + const details: ConsoleDetails = { + total: drained.total, + returned: drained.records.length, + bufferOverflowed: drained.bufferOverflowed, + truncated: trunc.wasTruncated, + records: drained.records, + ...(nextCursor !== undefined ? { nextCursor } : {}), + }; + if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath; + + return ok({ text: trunc.text, details }); + }, + + renderResult(result, expanded, theme) { + const details = result.details as ConsoleDetails | undefined; + if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0); + + const md = renderConsoleMarkdown(details.records, { + total: details.total, + bufferOverflowed: details.bufferOverflowed, + expanded, + nextCursor: details.nextCursor, + }); + const tail = expanded + ? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}` + : `\n\n${keyHint("app.tools.expand", "to expand")}`; + + return new Markdown(md + tail, 0, 0, getMarkdownTheme()); + }, +});
@@ -36,6 +36,7 @@ | browser_current_tab | Get current tab info |
| browser_switch_tab | Switch to a different tab by targetId | | browser_execute_js | Execute JavaScript and return the result | | browser_http_get | Direct HTTP GET (outside browser, for APIs) | +| browser_console | Read JS errors / console output on the current tab (diagnostic — use when an action looks broken; pass sinceSeq from the previous nextCursor to see only new messages) | | browser_wait | Wait N seconds | | browser_wait_for_load | Wait for document.readyState === 'complete' (returns a typed timeout error if the page doesn't reach readyState=complete in N seconds, default 15) | | browser_handle_dialog | Accept or dismiss a JS dialog |
@@ -14,6 +14,7 @@ import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files";
import { viewportResizeTool } from "./domains/viewport"; import { dragAndDropTool } from "./domains/drag"; import { httpGetTool, networkRequestsTool } from "./domains/network"; +import { consoleTool } from "./domains/console"; import { snapshotTool } from "./domains/snapshot"; import { executeJsTool, runScriptTool } from "./domains/js";@@ -48,6 +49,7 @@ { ...viewportResizeTool, serialized: true },
{ ...dragAndDropTool, serialized: true }, httpGetTool, networkRequestsTool, + consoleTool, snapshotTool, executeJsTool, runScriptTool,