aboutsummaryrefslogtreecommitdiff
path: root/src/cdp
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-06 00:06:49 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-06 00:06:49 +0530
commit589cd294931c01b3748779635d17a13dd240af19 (patch)
treeb635f9fa443cd120de905660fe0d2665184e9a8c /src/cdp
parent20ceaa1c6ade4950ed7d16caa5269ad2f4805db4 (diff)
release: v0.4.0 — accessibility snapshot, network capture, snapshot-first prompts
Adds two new tools and pivots the agent's default workflow away from screenshots toward structured page data. browser_snapshot — CDP accessibility tree (roles, names, states, hierarchy) with click coordinates @(x,y) attached to every interactive element via DOM.getBoxModel (parallel, 1.5s aggregate budget). Pass directly to browser_click — no screenshot round-trip. Optional includeScreenshot:true for visual confirmation. format:"json" returns a slim structure with full box per node. browser_network_requests — replaces the deprecated browser_get_network_log placeholder. Filters by urlPattern (substring or /regex/), method, status, resource type, recency. Optional response-body capture under a 5s aggregate budget with 50KB per-body cap. Buffer is page-scoped (cleared on tab switch), 500 records/tab, with a bufferOverflowed flag on drain. CDP plumbing: in-process aggregator in src/cdp/network-buffer.ts wired into the existing single-consumer event loop. Accessibility domain enabled alongside Page/DOM/Runtime/Network on every attach. Ctrl+O expand/collapse on three tools' renderResult — browser_snapshot, browser_network_requests, browser_execute_js. Compact-by-default views with keyHint("app.tools.expand") that respects user keybinding remaps. Tool prompts pivoted snapshot-first: browser_screenshot is documented as LAST RESORT for visual verification only; browser_snapshot is the default for understanding pages; browser_execute_js is the default for surgical reads. browser_click and browser_open_urls guidance follows. SKILL.md collapsed from ~342 to ~53 lines — keeping only what isn't already in tool prompts: the decision tree, connection rules, browser_run_script daemon bindings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/cdp')
-rw-r--r--src/cdp/network-buffer.ts213
-rw-r--r--src/cdp/session.ts15
2 files changed, 227 insertions, 1 deletions
diff --git a/src/cdp/network-buffer.ts b/src/cdp/network-buffer.ts
new file mode 100644
index 0000000..f841719
--- /dev/null
+++ b/src/cdp/network-buffer.ts
@@ -0,0 +1,213 @@
+/**
+ * In-memory aggregator for CDP Network.* events.
+ *
+ * The CDP event stream is consumed by a single internal loop in session.ts.
+ * That loop hands Network.* event params to this module, which joins them into
+ * one record per requestId (request → response → finished/failed). Tools then
+ * read records via drain(), filtered to what the caller asked for.
+ *
+ * Bounded by a fixed capacity (default 500): on overflow the oldest record is
+ * evicted FIFO. The overflow flag is reported once per drain so tools can warn
+ * the LLM that data may be missing.
+ *
+ * This module is pure (no CDP dependency) so the joining/filtering logic stays
+ * trivially testable in isolation.
+ */
+
+export type NetworkRecord = {
+ requestId: string;
+ method: string;
+ url: string;
+ status?: number;
+ statusText?: string;
+ type: string;
+ mimeType?: string;
+ /** Wall-clock ms when requestWillBeSent fired, in Date.now() units. */
+ requestStartedMs: number;
+ durationMs?: number;
+ requestBodySize: number;
+ responseBodySize?: number;
+ failed?: boolean;
+ errorText?: string;
+ /** Populated by the tool layer via Network.getResponseBody, never by the aggregator. */
+ body?: string | null;
+};
+
+export type NetworkFilter = {
+ /** Substring match by default. Wrap in slashes (`/foo/`) for regex. */
+ urlPattern?: string;
+ methodFilter?: string[];
+ statusFilter?: { min?: number; max?: number };
+ resourceTypes?: string[];
+ /** Only requests whose start is at most N ms ago. */
+ sinceMs?: number;
+ /** Cap on the returned records. */
+ limit?: number;
+};
+
+export type DrainResult = {
+ readonly records: NetworkRecord[];
+ /** 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 NetworkBuffer = {
+ ingestRequestWillBeSent(p: unknown): void;
+ ingestResponseReceived(p: unknown): void;
+ ingestLoadingFinished(p: unknown): void;
+ ingestLoadingFailed(p: unknown): void;
+ drain(filter: NetworkFilter): DrainResult;
+ clear(): void;
+};
+
+const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
+ if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
+ try {
+ const re = new RegExp(pattern.slice(1, -1));
+ return (url) => re.test(url);
+ } catch {
+ // Fall through to substring match if the pattern is not a valid regex.
+ }
+ }
+ return (url) => url.includes(pattern);
+};
+
+export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
+ // Insertion-ordered map keyed by requestId. Map preserves insertion order in JS,
+ // so iteration gives us oldest-first eviction without a separate index.
+ const records = new Map<string, NetworkRecord>();
+ 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;
+ }
+ };
+
+ return {
+ ingestRequestWillBeSent(p) {
+ // CDP boundary cast: Network.requestWillBeSent
+ const params = p as
+ | {
+ requestId?: string;
+ request?: {
+ url?: string;
+ method?: string;
+ postData?: string;
+ headers?: Record<string, string>;
+ };
+ type?: string;
+ timestamp?: number;
+ wallTime?: number;
+ }
+ | undefined;
+ const id = params?.requestId;
+ const url = params?.request?.url;
+ const method = params?.request?.method;
+ if (!id || !url || !method) return;
+ // Re-fired requestWillBeSent for the same id (e.g. redirect) — refresh in place.
+ records.delete(id);
+ evictOldestIfFull();
+ const postData = params.request?.postData;
+ records.set(id, {
+ requestId: id,
+ method,
+ url,
+ type: params.type ?? "Other",
+ requestStartedMs: Date.now(),
+ requestBodySize: postData ? Buffer.byteLength(postData, "utf8") : 0,
+ });
+ },
+
+ ingestResponseReceived(p) {
+ // CDP boundary cast: Network.responseReceived
+ const params = p as
+ | {
+ requestId?: string;
+ response?: {
+ status?: number;
+ statusText?: string;
+ mimeType?: string;
+ };
+ type?: string;
+ }
+ | undefined;
+ const id = params?.requestId;
+ if (!id) return;
+ const r = records.get(id);
+ if (!r) return;
+ if (params?.response?.status !== undefined) r.status = params.response.status;
+ if (params?.response?.statusText !== undefined && params.response.statusText !== "") r.statusText = params.response.statusText;
+ if (params?.response?.mimeType !== undefined) r.mimeType = params.response.mimeType;
+ // type is more accurate on responseReceived (CDP sometimes refines it)
+ if (params?.type !== undefined) r.type = params.type;
+ },
+
+ ingestLoadingFinished(p) {
+ // CDP boundary cast: Network.loadingFinished
+ const params = p as
+ | { requestId?: string; encodedDataLength?: number }
+ | undefined;
+ const id = params?.requestId;
+ if (!id) return;
+ const r = records.get(id);
+ if (!r) return;
+ if (params?.encodedDataLength !== undefined) r.responseBodySize = params.encodedDataLength;
+ r.durationMs = Date.now() - r.requestStartedMs;
+ },
+
+ ingestLoadingFailed(p) {
+ // CDP boundary cast: Network.loadingFailed
+ const params = p as
+ | { requestId?: string; errorText?: string; canceled?: boolean }
+ | undefined;
+ const id = params?.requestId;
+ if (!id) return;
+ const r = records.get(id);
+ if (!r) return;
+ r.failed = true;
+ if (params?.errorText !== undefined) r.errorText = params.errorText;
+ r.durationMs = Date.now() - r.requestStartedMs;
+ },
+
+ drain(filter) {
+ const matchUrl = filter.urlPattern !== undefined ? compileUrlMatcher(filter.urlPattern) : undefined;
+ const methods = filter.methodFilter ? new Set(filter.methodFilter.map((m) => m.toUpperCase())) : undefined;
+ const types = filter.resourceTypes ? new Set(filter.resourceTypes.map((t) => t.toLowerCase())) : undefined;
+ const minStatus = filter.statusFilter?.min;
+ const maxStatus = filter.statusFilter?.max;
+ const cutoff = filter.sinceMs !== undefined ? Date.now() - filter.sinceMs : undefined;
+
+ const matched: NetworkRecord[] = [];
+ for (const r of records.values()) {
+ if (matchUrl && !matchUrl(r.url)) continue;
+ if (methods && !methods.has(r.method.toUpperCase())) continue;
+ if (types && !types.has(r.type.toLowerCase())) continue;
+ if (cutoff !== undefined && r.requestStartedMs < cutoff) continue;
+ if (minStatus !== undefined && (r.status === undefined || r.status < minStatus)) continue;
+ if (maxStatus !== undefined && (r.status === undefined || r.status > maxStatus)) continue;
+ // Clone so callers can mutate (e.g. attach body) without polluting the buffer.
+ matched.push({ ...r });
+ }
+
+ const total = matched.length;
+ const limit = Math.min(filter.limit ?? 50, 500);
+ const limited = matched.slice(-limit); // most recent N
+
+ const bufferOverflowed = overflowed;
+ overflowed = false;
+
+ return { records: limited, total, bufferOverflowed };
+ },
+
+ clear() {
+ records.clear();
+ overflowed = false;
+ },
+ };
+};
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index 42f6107..89bdfdd 100644
--- a/src/cdp/session.ts
+++ b/src/cdp/session.ts
@@ -3,6 +3,7 @@ import type { CdpError } from "./errors";
import type { DialogInfo } from "./types";
import type { OwnershipRegistry } from "./ownership";
import type { CdpTransport } from "./transport";
+import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
export type CdpSession = {
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
@@ -13,6 +14,7 @@ export type CdpSession = {
callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
takeDialog(): DialogInfo | null;
drainPageInfoInvalidations(): boolean;
+ drainNetworkBuffer(filter: NetworkFilter): DrainResult;
};
export const createCdpSession = (
@@ -23,6 +25,7 @@ export const createCdpSession = (
let targetId: string | null = null;
let dialog: DialogInfo | null = null;
let pageInfoDirty = false;
+ const networkBuffer = createNetworkBuffer();
let activeConsumer: Promise<void> = Promise.resolve();
@@ -48,6 +51,10 @@ export const createCdpSession = (
const params = ev.params as { targetId?: string } | undefined;
if (params?.targetId) ownership.remove(params.targetId);
}
+ 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);
}
};
@@ -75,7 +82,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"]) {
+ for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility"]) {
await transport.request(`${d}.enable`, {}, { sessionId: sid });
}
};
@@ -143,6 +150,9 @@ export const createCdpSession = (
sessionId = a.sessionId;
targetId = tid;
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();
await enableDomains(a.sessionId);
return ok(undefined);
},
@@ -168,5 +178,8 @@ export const createCdpSession = (
pageInfoDirty = false;
return dirty;
},
+ drainNetworkBuffer(filter) {
+ return networkBuffer.drain(filter);
+ },
};
};