aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md16
-rw-r--r--package.json2
-rw-r--r--skills/pi-browser-harness/SKILL.md7
-rw-r--r--src/cdp/console-buffer.ts265
-rw-r--r--src/cdp/session.ts11
-rw-r--r--src/domains/console.ts173
-rw-r--r--src/prompt.ts1
-rw-r--r--src/registry.ts2
8 files changed, 475 insertions, 2 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbdef28..473930e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
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
diff --git a/package.json b/package.json
index 61a52c2..a316917 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/skills/pi-browser-harness/SKILL.md b/skills/pi-browser-harness/SKILL.md
index 7de9800..69105a8 100644
--- a/skills/pi-browser-harness/SKILL.md
+++ b/skills/pi-browser-harness/SKILL.md
@@ -21,6 +21,9 @@ What do you need to know?
├─ 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)
```
@@ -31,6 +34,10 @@ Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot
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
When a workflow repeats 3+ times or needs Node.js APIs, write a script to disk and run it with `browser_run_script`. Scripts get a `daemon` binding for direct CDP access — much faster than chaining tool calls.
diff --git a/src/cdp/console-buffer.ts b/src/cdp/console-buffer.ts
new file mode 100644
index 0000000..002ccd6
--- /dev/null
+++ b/src/cdp/console-buffer.ts
@@ -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.
+ },
+ };
+};
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index 89bdfdd..c839842 100644
--- a/src/cdp/session.ts
+++ b/src/cdp/session.ts
@@ -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 @@ export type CdpSession = {
takeDialog(): DialogInfo | null;
drainPageInfoInvalidations(): boolean;
drainNetworkBuffer(filter: NetworkFilter): DrainResult;
+ drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
};
export const createCdpSession = (
@@ -26,6 +28,7 @@ export const createCdpSession = (
let dialog: DialogInfo | null = null;
let pageInfoDirty = false;
const networkBuffer = createNetworkBuffer();
+ const consoleBuffer = createConsoleBuffer();
let activeConsumer: Promise<void> = Promise.resolve();
@@ -55,6 +58,8 @@ export const createCdpSession = (
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 @@ export const createCdpSession = (
// 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 @@ export const createCdpSession = (
// 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);
},
@@ -181,5 +187,8 @@ export const createCdpSession = (
drainNetworkBuffer(filter) {
return networkBuffer.drain(filter);
},
+ drainConsoleBuffer(filter) {
+ return consoleBuffer.drain(filter);
+ },
};
};
diff --git a/src/domains/console.ts b/src/domains/console.ts
new file mode 100644
index 0000000..f9814d6
--- /dev/null
+++ b/src/domains/console.ts
@@ -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());
+ },
+});
diff --git a/src/prompt.ts b/src/prompt.ts
index 1a2a4c1..850d36f 100644
--- a/src/prompt.ts
+++ b/src/prompt.ts
@@ -36,6 +36,7 @@ to interact with pages visually, and other tools alongside them.
| 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 |
diff --git a/src/registry.ts b/src/registry.ts
index df912b1..8f625ae 100644
--- a/src/registry.ts
+++ b/src/registry.ts
@@ -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 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
{ ...dragAndDropTool, serialized: true },
httpGetTool,
networkRequestsTool,
+ consoleTool,
snapshotTool,
executeJsTool,
runScriptTool,