pi-browser-harness 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +5 -9
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
|
|
2
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3
|
+
|
|
4
|
+
export const asRecord = (v: unknown): Readonly<Record<string, unknown>> | undefined =>
|
|
5
|
+
isRecord(v) ? v : undefined;
|
|
6
|
+
|
|
7
|
+
export const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
8
|
+
|
|
9
|
+
export const asNumber = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
|
|
10
|
+
|
|
11
|
+
export const asBoolean = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined);
|
|
12
|
+
|
|
13
|
+
export const asArrayOf = <T>(
|
|
14
|
+
v: unknown,
|
|
15
|
+
item: (x: unknown) => T | undefined,
|
|
16
|
+
): ReadonlyArray<T> | undefined => {
|
|
17
|
+
if (!Array.isArray(v)) return undefined;
|
|
18
|
+
const out: T[] = [];
|
|
19
|
+
for (const x of v) {
|
|
20
|
+
const parsed = item(x);
|
|
21
|
+
if (parsed === undefined) return undefined;
|
|
22
|
+
out.push(parsed);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const errnoCode = (e: unknown): string | undefined => {
|
|
28
|
+
if (!(e instanceof Error) || !("code" in e)) return undefined;
|
|
29
|
+
return asString(e.code);
|
|
30
|
+
};
|
package/src/util/js-template.ts
CHANGED
|
@@ -1,19 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Build a JavaScript source string with safely-interpolated values.
|
|
3
|
-
* Every interpolated value is JSON.stringify'd, so strings, numbers,
|
|
4
|
-
* objects, and special characters all become valid JS literals.
|
|
5
|
-
*
|
|
6
|
-
* This is the ONLY supported way to interpolate untrusted values into
|
|
7
|
-
* evaluation source — never use raw template literals for JS that
|
|
8
|
-
* crosses the CDP boundary. The static parts of the template (the
|
|
9
|
-
* `strings` array) must remain hard-coded; only the `${...}` slots
|
|
10
|
-
* are sanitized.
|
|
11
|
-
*
|
|
12
|
-
* Values must be JSON-serializable: BigInt and Symbol throw at runtime;
|
|
13
|
-
* functions and undefined serialize to `undefined`/omitted, which yields
|
|
14
|
-
* a ReferenceError when evaluated. Pass strings, numbers, booleans,
|
|
15
|
-
* plain objects, or arrays.
|
|
16
|
-
*/
|
|
1
|
+
// The ONLY supported way to interpolate untrusted values into evaluation source — never use raw template literals for JS crossing the CDP boundary.
|
|
17
2
|
export const safeJs = (
|
|
18
3
|
strings: TemplateStringsArray,
|
|
19
4
|
...values: ReadonlyArray<unknown>
|
package/src/util/mutex.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight async mutex (~10 lines). Acquire returns a release function.
|
|
3
|
-
* When the mutex is free, acquire() resolves immediately. When held,
|
|
4
|
-
* callers are queued and execute serially in FIFO order.
|
|
5
|
-
*/
|
|
6
1
|
export type Mutex = {
|
|
7
2
|
acquire(): Promise<() => void>;
|
|
8
3
|
};
|
package/src/util/result.ts
CHANGED
|
@@ -7,9 +7,3 @@ export const err = <E>(error: E): Result<never, E> => ({ success: false, error }
|
|
|
7
7
|
|
|
8
8
|
export const map = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> =>
|
|
9
9
|
r.success ? ok(f(r.data)) : r;
|
|
10
|
-
|
|
11
|
-
export const andThen = <T, U, E>(r: Result<T, E>, f: (t: T) => Result<U, E>): Result<U, E> =>
|
|
12
|
-
r.success ? f(r.data) : r;
|
|
13
|
-
|
|
14
|
-
export const mapErr = <T, E, F>(r: Result<T, E>, f: (e: E) => F): Result<T, F> =>
|
|
15
|
-
r.success ? r : err(f(r.error));
|
package/src/util/sharp-shim.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// native module not installed in every workspace; we import dynamically and
|
|
3
|
-
// distinguish "missing" (no install) from "error" (installed but threw).
|
|
4
|
-
//
|
|
5
|
-
// We declare only the surface we use. The dynamic import returns `unknown`
|
|
6
|
-
// at the boundary; the cast inside loadSharp() is the documented escape
|
|
7
|
-
// hatch — every other consumer sees the typed SharpFactory.
|
|
1
|
+
// `sharp` is an optional dependency not installed in every workspace, so the import must stay dynamic and unresolvable to tsc.
|
|
8
2
|
|
|
9
3
|
export type SharpInstance = {
|
|
10
4
|
metadata(): Promise<{ width?: number; height?: number }>;
|
|
@@ -20,26 +14,25 @@ export type SharpLoad =
|
|
|
20
14
|
| { readonly kind: "missing" }
|
|
21
15
|
| { readonly kind: "error"; readonly message: string };
|
|
22
16
|
|
|
17
|
+
const SHARP_SPECIFIER: string = "sharp";
|
|
18
|
+
|
|
19
|
+
const MISSING_MODULE_MARKERS: ReadonlyArray<string> = [
|
|
20
|
+
"Cannot find module",
|
|
21
|
+
"MODULE_NOT_FOUND",
|
|
22
|
+
"ERR_MODULE_NOT_FOUND",
|
|
23
|
+
];
|
|
24
|
+
|
|
23
25
|
export const loadSharp = async (): Promise<SharpLoad> => {
|
|
24
26
|
let mod: unknown;
|
|
25
27
|
try {
|
|
26
|
-
|
|
27
|
-
// @ts-ignore — optional peer dependency not required at compile time
|
|
28
|
-
mod = await import("sharp");
|
|
28
|
+
mod = await import(SHARP_SPECIFIER);
|
|
29
29
|
} catch (e) {
|
|
30
30
|
const msg = e instanceof Error ? e.message : String(e);
|
|
31
|
-
if (
|
|
32
|
-
msg.includes("Cannot find module") ||
|
|
33
|
-
msg.includes("MODULE_NOT_FOUND") ||
|
|
34
|
-
msg.includes("ERR_MODULE_NOT_FOUND")
|
|
35
|
-
) {
|
|
36
|
-
return { kind: "missing" };
|
|
37
|
-
}
|
|
31
|
+
if (MISSING_MODULE_MARKERS.some((marker) => msg.includes(marker))) return { kind: "missing" };
|
|
38
32
|
return { kind: "error", message: msg };
|
|
39
33
|
}
|
|
40
34
|
if (mod === null || mod === undefined) return { kind: "missing" };
|
|
41
|
-
// The CommonJS sharp module's default export IS the factory
|
|
42
|
-
// sharp puts it on .default. Probe both.
|
|
35
|
+
// The CommonJS sharp module's default export IS the factory; ESM-wrapped sharp puts it on .default.
|
|
43
36
|
const m = mod as { default?: unknown };
|
|
44
37
|
const candidate: unknown = typeof m.default === "function" ? m.default : mod;
|
|
45
38
|
if (typeof candidate !== "function") {
|
package/src/util/tool.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { TSchema, Static } from "typebox";
|
|
2
2
|
import type {
|
|
3
3
|
AgentToolResult,
|
|
4
|
-
AgentToolUpdateCallback,
|
|
5
4
|
ExtensionAPI,
|
|
6
5
|
ExtensionContext,
|
|
7
6
|
Theme,
|
|
@@ -9,8 +8,7 @@ import type {
|
|
|
9
8
|
ToolRenderResultOptions,
|
|
10
9
|
} from "@mariozechner/pi-coding-agent";
|
|
11
10
|
import type { Component } from "@mariozechner/pi-tui";
|
|
12
|
-
import {
|
|
13
|
-
import { DAEMON_SOCKET_PATH } from "../daemon/protocol";
|
|
11
|
+
import { daemonSocketMayExist } from "../daemon/spawn";
|
|
14
12
|
import type { BrowserClient } from "../client";
|
|
15
13
|
import type { Result } from "./result";
|
|
16
14
|
|
|
@@ -55,17 +53,11 @@ export type BrowserToolDefinition<S extends TSchema> = {
|
|
|
55
53
|
readonly handler: ToolHandler<S>;
|
|
56
54
|
readonly renderCall?: (args: Static<S>, theme: Theme) => Component;
|
|
57
55
|
readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
|
|
58
|
-
|
|
59
|
-
* This ensures that two serialized tools never run concurrently,
|
|
60
|
-
* preventing race conditions on shared CDP session/page state. */
|
|
61
|
-
readonly serialized?: boolean;
|
|
56
|
+
readonly concurrency: "serialized" | "parallel";
|
|
62
57
|
readonly ensureAlive?: boolean;
|
|
63
58
|
};
|
|
64
59
|
|
|
65
|
-
//
|
|
66
|
-
// renderCall accepts `never` so any concrete args type satisfies it via
|
|
67
|
-
// function-parameter contravariance (a handler that takes SpecificT is
|
|
68
|
-
// assignable to one that takes never, because never is a subtype of everything).
|
|
60
|
+
// renderCall accepts `never` so any concrete args type satisfies it via function-parameter contravariance.
|
|
69
61
|
export type AnyBrowserToolDefinition = Omit<BrowserToolDefinition<TSchema>, "renderCall" | "renderResult"> & {
|
|
70
62
|
readonly renderCall?: (args: never, theme: Theme) => Component;
|
|
71
63
|
readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
|
|
@@ -78,10 +70,12 @@ export const defineBrowserTool = <S extends TSchema>(
|
|
|
78
70
|
type OkDetails = { readonly ok: true } & Readonly<Record<string, unknown>>;
|
|
79
71
|
type ErrDetails = { readonly ok: false; readonly kind: ToolErrKind; readonly message: string } & Readonly<Record<string, unknown>>;
|
|
80
72
|
|
|
73
|
+
type ErrorFlagged<D> = AgentToolResult<D> & { readonly isError?: boolean };
|
|
74
|
+
|
|
81
75
|
const toToolResult = (
|
|
82
76
|
r: Result<ToolOk, ToolErr>,
|
|
83
77
|
toolName: string,
|
|
84
|
-
):
|
|
78
|
+
): ErrorFlagged<OkDetails | ErrDetails> => {
|
|
85
79
|
if (r.success) {
|
|
86
80
|
const details: OkDetails = { ok: true, ...(r.data.details ?? {}) };
|
|
87
81
|
return {
|
|
@@ -96,10 +90,7 @@ const toToolResult = (
|
|
|
96
90
|
...(r.error.details ?? {}),
|
|
97
91
|
};
|
|
98
92
|
return {
|
|
99
|
-
|
|
100
|
-
// AgentToolResult<T> itself doesn't declare it but the runtime reads it via duck-typing.
|
|
101
|
-
// We cast through unknown to avoid widening the return type.
|
|
102
|
-
...(({ isError: true }) as unknown as object),
|
|
93
|
+
isError: true,
|
|
103
94
|
content: [{ type: "text", text: `${toolName} failed (${r.error.kind}): ${r.error.message}` }],
|
|
104
95
|
details,
|
|
105
96
|
};
|
|
@@ -110,10 +101,9 @@ export const registerBrowserTool = (
|
|
|
110
101
|
client: BrowserClient,
|
|
111
102
|
def: AnyBrowserToolDefinition,
|
|
112
103
|
): void => {
|
|
113
|
-
// AnyBrowserToolDefinition is type-erased; we recover the schema-typed surface
|
|
114
|
-
// by casting def to the generic form. The cast is safe because defineBrowserTool
|
|
115
|
-
// ensures the handler, parameters, and renderCall were all created with the same S.
|
|
116
104
|
type S = TSchema;
|
|
105
|
+
const defRenderCall = def.renderCall;
|
|
106
|
+
const defRenderResult = def.renderResult;
|
|
117
107
|
const td: ToolDefinition<S> = {
|
|
118
108
|
name: def.name,
|
|
119
109
|
label: def.label,
|
|
@@ -121,35 +111,26 @@ export const registerBrowserTool = (
|
|
|
121
111
|
promptSnippet: def.promptSnippet,
|
|
122
112
|
promptGuidelines: [...def.promptGuidelines],
|
|
123
113
|
parameters: def.parameters,
|
|
124
|
-
...(
|
|
114
|
+
...(defRenderCall
|
|
125
115
|
? {
|
|
126
116
|
renderCall: (args: Static<S>, theme: Theme, _ctx: Parameters<NonNullable<ToolDefinition<S>["renderCall"]>>[2]) =>
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
// args is always the concrete Static<S> value the tool was defined with.
|
|
130
|
-
(def.renderCall as (args: unknown, theme: Theme) => Component)(args, theme),
|
|
117
|
+
// Sanctioned cast: at runtime args is always the concrete Static<S> value the tool was defined with.
|
|
118
|
+
(defRenderCall as (args: unknown, theme: Theme) => Component)(args, theme),
|
|
131
119
|
}
|
|
132
120
|
: {}),
|
|
133
|
-
...(
|
|
121
|
+
...(defRenderResult
|
|
134
122
|
? {
|
|
135
123
|
renderResult: (
|
|
136
124
|
result: AgentToolResult<unknown>,
|
|
137
125
|
options: ToolRenderResultOptions,
|
|
138
126
|
theme: Theme,
|
|
139
127
|
_ctx: Parameters<NonNullable<ToolDefinition<S>["renderResult"]>>[3],
|
|
140
|
-
) =>
|
|
141
|
-
// Non-null assertion safe: this branch only runs when renderResult was provided.
|
|
142
|
-
def.renderResult!(result, options.expanded ?? false, theme),
|
|
128
|
+
) => defRenderResult(result, options.expanded ?? false, theme),
|
|
143
129
|
}
|
|
144
130
|
: {}),
|
|
145
131
|
async execute(_toolCallId, args, signal, onUpdate, extensionCtx) {
|
|
146
132
|
if (def.ensureAlive !== false) {
|
|
147
|
-
|
|
148
|
-
// If missing, the user hasn't run /browser-setup yet. Return a clear
|
|
149
|
-
// error so the agent knows to direct the user to initialize.
|
|
150
|
-
try {
|
|
151
|
-
await access(DAEMON_SOCKET_PATH);
|
|
152
|
-
} catch {
|
|
133
|
+
if (!(await daemonSocketMayExist())) {
|
|
153
134
|
return toToolResult(
|
|
154
135
|
{
|
|
155
136
|
success: false,
|
|
@@ -169,12 +150,8 @@ export const registerBrowserTool = (
|
|
|
169
150
|
);
|
|
170
151
|
}
|
|
171
152
|
}
|
|
172
|
-
// Serialized tools acquire the client's mutation mutex so that only one
|
|
173
|
-
// mutation tool runs at a time. Observation tools skip this and run freely
|
|
174
|
-
// in parallel. AbortSignal is checked after acquiring the lock so that a
|
|
175
|
-
// cancelled task doesn't hold the mutex forever.
|
|
176
153
|
let release: (() => void) | undefined;
|
|
177
|
-
if (def.
|
|
154
|
+
if (def.concurrency !== "parallel") {
|
|
178
155
|
const acquire = await client.mutationMutex().acquire();
|
|
179
156
|
release = acquire;
|
|
180
157
|
if (signal?.aborted) {
|
|
@@ -191,13 +168,11 @@ export const registerBrowserTool = (
|
|
|
191
168
|
signal,
|
|
192
169
|
onUpdate: (u) => {
|
|
193
170
|
if (onUpdate) {
|
|
194
|
-
// AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails.
|
|
195
171
|
const update: AgentToolResult<OkDetails> = {
|
|
196
172
|
content: [{ type: "text", text: u.text }],
|
|
197
173
|
details: { ok: true, ...(u.details ?? {}) },
|
|
198
174
|
};
|
|
199
|
-
|
|
200
|
-
(onUpdate as AgentToolUpdateCallback<OkDetails>)(update);
|
|
175
|
+
onUpdate(update);
|
|
201
176
|
}
|
|
202
177
|
},
|
|
203
178
|
extensionCtx,
|
package/src/util/truncate.ts
CHANGED
|
@@ -33,3 +33,9 @@ export const applyTruncation = async (output: string, prefix: string): Promise<T
|
|
|
33
33
|
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}]`;
|
|
34
34
|
return { text, fullOutputPath: file, wasTruncated: true };
|
|
35
35
|
};
|
|
36
|
+
|
|
37
|
+
export const formatBytes = (n: number): string => {
|
|
38
|
+
if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
|
|
39
|
+
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
40
|
+
return `${n} B`;
|
|
41
|
+
};
|
package/tsconfig.json
CHANGED
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
+
"extends": "./tsconfig.base.json",
|
|
2
3
|
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"strict": true,
|
|
7
|
-
"noImplicitAny": true,
|
|
8
|
-
"strictNullChecks": true,
|
|
9
|
-
"noUnusedLocals": true,
|
|
10
|
-
"noUnusedParameters": true,
|
|
11
|
-
"noImplicitReturns": true,
|
|
12
|
-
"noFallthroughCasesInSwitch": true,
|
|
13
|
-
"noUncheckedIndexedAccess": true,
|
|
14
|
-
"exactOptionalPropertyTypes": true,
|
|
15
|
-
"noPropertyAccessFromIndexSignature": true,
|
|
16
|
-
"forceConsistentCasingInFileNames": true,
|
|
17
|
-
"esModuleInterop": true,
|
|
18
|
-
"skipLibCheck": true,
|
|
19
4
|
"outDir": "./dist",
|
|
20
5
|
"rootDir": "./src",
|
|
21
6
|
"declaration": true,
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: deep-research
|
|
3
|
-
description: Use when the user asks you to research a topic on the web, do deep research, investigate something across multiple sources, or produce a cited report / literature review from the live web. Decomposes the question, fans out isolated web-search-researcher subagents (one per sub-question) that scrape the real browser, then synthesizes a source-cited Markdown report. Trigger phrases include "research", "deep research", "look this up and cite sources", "investigate", "compare X and Y with sources".
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Deep Research
|
|
7
|
-
|
|
8
|
-
Turn a research question into a **cited Markdown report** by fanning out to isolated `web-search-researcher` subagents that use the real browser, then synthesizing their distilled findings. Every claim in the final report carries a source link.
|
|
9
|
-
|
|
10
|
-
This runs on two browser tools — `browser_web_search` (ranked SERP, links only) and `browser_read_page` (URL → clean article text) — used *inside the subagents*, not by you directly. Both tools open their own isolated tabs, so **the fan-out never disturbs the user's tabs or current page.** You do not need `/browser-setup` to have been run by you; if the browser isn't connected the subagents' first tool call will report `not_connected` — relay that and ask the user to run `/browser-setup`.
|
|
11
|
-
|
|
12
|
-
## Workflow
|
|
13
|
-
|
|
14
|
-
### 1. Decompose
|
|
15
|
-
|
|
16
|
-
Break the question into **3–6 focused sub-questions**, each independently answerable from the web. Cover the distinct facets — definitions, current state, competing options, trade-offs, recent changes — not slight rewordings of the same thing. Aim for coverage: if you synthesized perfect answers to all sub-questions, the original question should be fully answered.
|
|
17
|
-
|
|
18
|
-
State the sub-questions to the user before dispatching (a short numbered list) so the plan is visible.
|
|
19
|
-
|
|
20
|
-
### 2. Fan out
|
|
21
|
-
|
|
22
|
-
Dispatch **one `web-search-researcher` subagent per sub-question**, in parallel where the runtime allows. Each subagent runs in isolated context and returns a distilled, source-linked finding (claims + urls) — not raw pages. Give each subagent exactly one sub-question plus any constraints (recency, specific ecosystem, authoritative sources to prefer).
|
|
23
|
-
|
|
24
|
-
Do not do the searching yourself — that is the subagents' job, and their isolation is what keeps your context clean enough to synthesize well.
|
|
25
|
-
|
|
26
|
-
### 3. Assess coverage — loop with a hard ceiling
|
|
27
|
-
|
|
28
|
-
After each round of findings, self-assess: **is every sub-question answered, or did findings surface new gaps worth a follow-up search?**
|
|
29
|
-
|
|
30
|
-
- If gaps remain and you are under the ceiling, dispatch a second round of researchers targeting only the gaps (rephrased or newly discovered sub-questions).
|
|
31
|
-
- **Hard ceiling:** at most **2 rounds** and **8 researcher subagents total**. When you hit either limit, stop searching, synthesize with what you have, and list the unresolved gaps explicitly in the report. Never loop indefinitely chasing completeness.
|
|
32
|
-
|
|
33
|
-
Handle subagent-reported tool failures:
|
|
34
|
-
- A subagent reporting a `browser_web_search` `invalid_state` with `reason: "captcha"` means a bot wall. **Surface this to the user immediately** and do not spawn a tight retry loop — ask whether to continue, wait, or narrow scope.
|
|
35
|
-
- `reason: "no_results"` is already handled inside the subagent (one rephrase); if it still comes back empty, treat that sub-question as a gap.
|
|
36
|
-
|
|
37
|
-
### 4. Synthesize a cited report
|
|
38
|
-
|
|
39
|
-
Merge the distilled findings into a **Markdown report written to a file** (e.g. `research-<topic-slug>.md` in the working directory — tell the user the path). Structure:
|
|
40
|
-
|
|
41
|
-
```
|
|
42
|
-
# {The research question}
|
|
43
|
-
|
|
44
|
-
## Summary
|
|
45
|
-
{A few paragraphs answering the question, synthesized across sources.}
|
|
46
|
-
|
|
47
|
-
## {Sub-topic / sub-question 1}
|
|
48
|
-
{Findings, in prose. Every claim carries a source link inline: … as shown in [Title](url).}
|
|
49
|
-
|
|
50
|
-
## {Sub-topic 2}
|
|
51
|
-
...
|
|
52
|
-
|
|
53
|
-
## Open questions / gaps
|
|
54
|
-
{Anything the ceiling left unresolved, or where sources conflicted.}
|
|
55
|
-
|
|
56
|
-
## Sources
|
|
57
|
-
{Deduplicated list of every URL cited, with titles.}
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
Rules for the report:
|
|
61
|
-
- **Every factual claim carries a source link** — inline `[title](url)`. No unsourced assertions.
|
|
62
|
-
- **Attribute conflicts.** Where sources disagree, present both and cite each.
|
|
63
|
-
- **Distinguish synthesis from quotation.** Don't fabricate precision the sources didn't provide.
|
|
64
|
-
- **Be honest about gaps.** The gaps section is mandatory when the ceiling cut off coverage — an incomplete-but-honest report beats a complete-looking-but-padded one.
|
|
65
|
-
|
|
66
|
-
After writing the file, give the user a short chat summary and the file path — don't paste the whole report into the conversation.
|
package/src/cdp/event-queue.ts
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared transport primitives for CdpTransport implementations.
|
|
3
|
-
*
|
|
4
|
-
* Both the WebSocket transport (transport.ts, talking to Chrome directly) and
|
|
5
|
-
* the daemon transport (daemon-transport.ts, talking to the daemon over a Unix
|
|
6
|
-
* socket) need an identical single-consumer async event queue plus the same
|
|
7
|
-
* pending-request bookkeeping. This module is the single source of truth for
|
|
8
|
-
* that shared machinery.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { type Result, err } from "../util/result";
|
|
12
|
-
import { type CdpError, cdpError } from "./errors";
|
|
13
|
-
import type { CdpEvent } from "./types";
|
|
14
|
-
|
|
15
|
-
export type CdpResult = Result<unknown, CdpError>;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* A single-consumer async queue of CDP events.
|
|
19
|
-
*
|
|
20
|
-
* `push` enqueues an event (dropped silently after `end`). `end` terminates the
|
|
21
|
-
* stream — any pending and future iterator `next()` calls resolve `done:true`.
|
|
22
|
-
* `iter` is the AsyncIterable consumed by one for-await loop; a second consumer
|
|
23
|
-
* would silently steal events from the first.
|
|
24
|
-
*/
|
|
25
|
-
export type EventQueue = {
|
|
26
|
-
readonly push: (e: CdpEvent) => void;
|
|
27
|
-
readonly end: () => void;
|
|
28
|
-
readonly iter: AsyncIterable<CdpEvent>;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export const makeEventQueue = (): EventQueue => {
|
|
32
|
-
const buf: CdpEvent[] = [];
|
|
33
|
-
const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
|
|
34
|
-
let ended = false;
|
|
35
|
-
return {
|
|
36
|
-
push(e) {
|
|
37
|
-
if (ended) return;
|
|
38
|
-
const w = waiters.shift();
|
|
39
|
-
if (w) w({ value: e, done: false });
|
|
40
|
-
else buf.push(e);
|
|
41
|
-
},
|
|
42
|
-
end() {
|
|
43
|
-
ended = true;
|
|
44
|
-
// value: undefined as unknown as CdpEvent is the correct iterator-protocol
|
|
45
|
-
// pattern: when done=true the value field is conventionally undefined but
|
|
46
|
-
// the TS type still requires CdpEvent.
|
|
47
|
-
for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
|
|
48
|
-
},
|
|
49
|
-
iter: {
|
|
50
|
-
[Symbol.asyncIterator]() {
|
|
51
|
-
return {
|
|
52
|
-
next: (): Promise<IteratorResult<CdpEvent>> =>
|
|
53
|
-
new Promise((resolve) => {
|
|
54
|
-
const next = buf.shift();
|
|
55
|
-
if (next) resolve({ value: next, done: false });
|
|
56
|
-
else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
|
|
57
|
-
else waiters.push(resolve);
|
|
58
|
-
}),
|
|
59
|
-
};
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
/** An in-flight CDP request awaiting its response or timeout. */
|
|
66
|
-
export type Pending = {
|
|
67
|
-
readonly resolve: (v: Result<unknown, CdpError>) => void;
|
|
68
|
-
readonly timer: ReturnType<typeof setTimeout>;
|
|
69
|
-
readonly method: string;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Reject every in-flight request with a transport_closed error and clear the
|
|
74
|
-
* map. Called from each transport's cleanup path when the underlying connection
|
|
75
|
-
* goes away.
|
|
76
|
-
*/
|
|
77
|
-
export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => {
|
|
78
|
-
for (const [, p] of pending) {
|
|
79
|
-
clearTimeout(p.timer);
|
|
80
|
-
p.resolve(err(cdpError("transport_closed", reason, p.method)));
|
|
81
|
-
}
|
|
82
|
-
pending.clear();
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Send a CDP request and return a promise that settles when the matching
|
|
87
|
-
* response arrives, the request times out, or the send throws.
|
|
88
|
-
*
|
|
89
|
-
* The transport supplies a `send` callback that puts the already-serialized
|
|
90
|
-
* payload onto its wire (WebSocket frame or Unix-socket line); everything else
|
|
91
|
-
* — registering the pending entry, the timeout timer, and synchronous send
|
|
92
|
-
* failure handling — is identical across transports and lives here.
|
|
93
|
-
*
|
|
94
|
-
* `timeoutLabel` is the transport's prefix for the timeout error message
|
|
95
|
-
* (e.g. "CDP" or "Daemon").
|
|
96
|
-
*/
|
|
97
|
-
export const sendWithTimeout = (
|
|
98
|
-
pending: Map<number, Pending>,
|
|
99
|
-
id: number,
|
|
100
|
-
method: string,
|
|
101
|
-
timeoutMs: number,
|
|
102
|
-
timeoutLabel: string,
|
|
103
|
-
send: () => void,
|
|
104
|
-
): Promise<CdpResult> =>
|
|
105
|
-
new Promise((resolve) => {
|
|
106
|
-
const timer = setTimeout(() => {
|
|
107
|
-
pending.delete(id);
|
|
108
|
-
resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method)));
|
|
109
|
-
}, timeoutMs);
|
|
110
|
-
pending.set(id, { resolve, timer, method });
|
|
111
|
-
try {
|
|
112
|
-
send();
|
|
113
|
-
} catch (e) {
|
|
114
|
-
clearTimeout(timer);
|
|
115
|
-
pending.delete(id);
|
|
116
|
-
resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Register a close listener, returning an unsubscribe function. Identical
|
|
122
|
-
* across transports, so it lives here.
|
|
123
|
-
*/
|
|
124
|
-
export const makeOnClose = (closeListeners: Set<() => void>) =>
|
|
125
|
-
(cb: () => void): (() => void) => {
|
|
126
|
-
closeListeners.add(cb);
|
|
127
|
-
return () => closeListeners.delete(cb);
|
|
128
|
-
};
|
package/src/cdp/transport.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import WebSocket from "ws";
|
|
2
|
-
import { type Result, err, ok } from "../util/result";
|
|
3
|
-
import { type CdpError, cdpError } from "./errors";
|
|
4
|
-
import { isCdpRawMessage } from "./types";
|
|
5
|
-
import type { CdpEvent, CdpRawMessage } from "./types";
|
|
6
|
-
import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
|
|
7
|
-
|
|
8
|
-
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
9
|
-
|
|
10
|
-
export type CdpTransport = {
|
|
11
|
-
connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
|
|
12
|
-
close(): Promise<void>;
|
|
13
|
-
request(
|
|
14
|
-
method: string,
|
|
15
|
-
params: Record<string, unknown>,
|
|
16
|
-
opts?: { sessionId?: string | null; timeoutMs?: number },
|
|
17
|
-
): Promise<Result<unknown, CdpError>>;
|
|
18
|
-
// Returns the current event queue's iterator. After close()+reconnect, callers
|
|
19
|
-
// must re-call events() — the old iterator will have received done:true.
|
|
20
|
-
events(): AsyncIterable<CdpEvent>;
|
|
21
|
-
state(): "open" | "closed" | "connecting";
|
|
22
|
-
onClose(cb: () => void): () => void;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export const createCdpTransport = (): CdpTransport => {
|
|
26
|
-
let ws: WebSocket | null = null;
|
|
27
|
-
let nextId = 1;
|
|
28
|
-
const pending = new Map<number, Pending>();
|
|
29
|
-
const closeListeners = new Set<() => void>();
|
|
30
|
-
|
|
31
|
-
let queue = makeEventQueue();
|
|
32
|
-
|
|
33
|
-
const handleMessage = (raw: string): void => {
|
|
34
|
-
let parsed: unknown;
|
|
35
|
-
try {
|
|
36
|
-
parsed = JSON.parse(raw);
|
|
37
|
-
} catch {
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
if (!isCdpRawMessage(parsed)) return;
|
|
41
|
-
const msg: CdpRawMessage = parsed;
|
|
42
|
-
if (msg.id === undefined) {
|
|
43
|
-
if (msg.method) {
|
|
44
|
-
queue.push({
|
|
45
|
-
method: msg.method,
|
|
46
|
-
params: msg.params,
|
|
47
|
-
...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const p = pending.get(msg.id);
|
|
53
|
-
if (!p) return;
|
|
54
|
-
pending.delete(msg.id);
|
|
55
|
-
clearTimeout(p.timer);
|
|
56
|
-
if (msg.error) {
|
|
57
|
-
const kind = msg.error.message.includes("Session with given id not found")
|
|
58
|
-
? "session_not_found"
|
|
59
|
-
: "remote_error";
|
|
60
|
-
p.resolve(err(cdpError(kind, msg.error.message, p.method)));
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
p.resolve(ok(msg.result));
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const cleanup = (reason: string): void => {
|
|
67
|
-
rejectAllPending(pending, reason);
|
|
68
|
-
queue.end();
|
|
69
|
-
queue = makeEventQueue();
|
|
70
|
-
for (const cb of closeListeners) cb();
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
connect(url, opts = {}): Promise<Result<void, CdpError>> {
|
|
75
|
-
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
76
|
-
return new Promise((resolve) => {
|
|
77
|
-
let settled = false;
|
|
78
|
-
const settle = (r: Result<void, CdpError>): void => {
|
|
79
|
-
if (settled) return;
|
|
80
|
-
settled = true;
|
|
81
|
-
resolve(r);
|
|
82
|
-
};
|
|
83
|
-
try {
|
|
84
|
-
ws = new WebSocket(url);
|
|
85
|
-
} catch (e) {
|
|
86
|
-
settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
const timer = setTimeout(() => {
|
|
90
|
-
ws?.close();
|
|
91
|
-
ws = null;
|
|
92
|
-
settle(err(cdpError("timeout", `CDP WebSocket connection timed out after ${timeoutMs}ms`)));
|
|
93
|
-
}, timeoutMs);
|
|
94
|
-
ws.onopen = () => {
|
|
95
|
-
clearTimeout(timer);
|
|
96
|
-
settle(ok(undefined));
|
|
97
|
-
};
|
|
98
|
-
ws.onmessage = (ev: WebSocket.MessageEvent) => {
|
|
99
|
-
const data = ev.data;
|
|
100
|
-
handleMessage(typeof data === "string" ? data : data.toString());
|
|
101
|
-
};
|
|
102
|
-
ws.onerror = () => {
|
|
103
|
-
clearTimeout(timer);
|
|
104
|
-
settle(err(cdpError("transport_closed", "CDP WebSocket error during connection")));
|
|
105
|
-
};
|
|
106
|
-
ws.onclose = () => {
|
|
107
|
-
clearTimeout(timer);
|
|
108
|
-
ws = null;
|
|
109
|
-
cleanup("WebSocket closed");
|
|
110
|
-
// If onopen already settled the connect promise, this settle is a no-op
|
|
111
|
-
// (guarded by `settled`). cleanup() above still runs unconditionally so
|
|
112
|
-
// pending requests are rejected and the event queue is rotated.
|
|
113
|
-
settle(err(cdpError("transport_closed", "CDP WebSocket closed")));
|
|
114
|
-
};
|
|
115
|
-
});
|
|
116
|
-
},
|
|
117
|
-
close(): Promise<void> {
|
|
118
|
-
if (ws) {
|
|
119
|
-
try { ws.close(1000, "Shutdown"); } catch { /* best effort */ }
|
|
120
|
-
ws = null;
|
|
121
|
-
}
|
|
122
|
-
cleanup("close() called");
|
|
123
|
-
return Promise.resolve();
|
|
124
|
-
},
|
|
125
|
-
request(method, params, opts = {}): Promise<Result<unknown, CdpError>> {
|
|
126
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
127
|
-
const sessionId = opts.sessionId ?? null;
|
|
128
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
129
|
-
return Promise.resolve(err(cdpError("transport_closed", "Browser not connected. Is Chrome running?", method)));
|
|
130
|
-
}
|
|
131
|
-
// Bind to a local const so the non-null is guaranteed even across microtasks
|
|
132
|
-
// between the readyState check and the send() call.
|
|
133
|
-
const sock = ws;
|
|
134
|
-
const id = nextId++;
|
|
135
|
-
const payload: Record<string, unknown> = { id, method, params };
|
|
136
|
-
if (sessionId) payload["sessionId"] = sessionId;
|
|
137
|
-
const json = JSON.stringify(payload);
|
|
138
|
-
return sendWithTimeout(pending, id, method, timeoutMs, "CDP", () => sock.send(json));
|
|
139
|
-
},
|
|
140
|
-
events(): AsyncIterable<CdpEvent> {
|
|
141
|
-
// Single-consumer: each connection's event stream may be iterated by
|
|
142
|
-
// exactly one for-await loop. Calling events() multiple times returns
|
|
143
|
-
// the same iterable; the second consumer will silently steal events
|
|
144
|
-
// from the first. After a reconnect, the previous iterator is ended
|
|
145
|
-
// and callers must re-call events() to receive new events.
|
|
146
|
-
return queue.iter;
|
|
147
|
-
},
|
|
148
|
-
state(): "open" | "closed" | "connecting" {
|
|
149
|
-
if (!ws) return "closed";
|
|
150
|
-
if (ws.readyState === WebSocket.CONNECTING) return "connecting";
|
|
151
|
-
if (ws.readyState === WebSocket.OPEN) return "open";
|
|
152
|
-
return "closed";
|
|
153
|
-
},
|
|
154
|
-
onClose: makeOnClose(closeListeners),
|
|
155
|
-
};
|
|
156
|
-
};
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type { BrowserClient } from "../client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Capture the real Chrome windowId of a freshly-created harness window and bind
|
|
5
|
-
* it as the session's durable window identity. Best-effort: a failed CDP call
|
|
6
|
-
* leaves the previous binding untouched. Call this on every code path that
|
|
7
|
-
* spawns a NEW dedicated window (newWindow:true), so the windowId never points
|
|
8
|
-
* at a window that has since been replaced.
|
|
9
|
-
*/
|
|
10
|
-
export const bindHarnessWindowId = async (client: BrowserClient, targetId: string): Promise<void> => {
|
|
11
|
-
const r = await client.session().callBrowser("Browser.getWindowForTarget", { targetId });
|
|
12
|
-
if (!r.success) return;
|
|
13
|
-
const wid = (r.data as { windowId?: number }).windowId;
|
|
14
|
-
if (typeof wid === "number") client.ownership().setHarnessWindowId(wid);
|
|
15
|
-
};
|