1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
|
# pi-castle-cdp-search
Web search for [pi](https://pi.dev) through a Chrome that is **already running**
on the wire network, driven over the Chrome DevTools Protocol.
It never launches a browser. It attaches to one a person or a launchd job
started — by default castle's shared Chrome on `10.88.0.25:9223` — opens a
background tab, reads the results page, and closes the tab again.
Because the search runs in a real browser with a real profile, it reaches pages
that reject datacentre traffic, and it costs nothing per query.
## The tool
One tool, `castle_cdp_search`. Deliberately **not** named `web_search`, so it
coexists with `pi-web-access` rather than shadowing it — both stay registered and
the model picks.
| Parameter | Type | Notes |
|---|---|---|
| `query` | string, required | As you would type it into a search box |
| `numResults` | integer, optional | 1–20, default 10 |
| `recency` | `day` \| `week` \| `month` \| `year`, optional | Omit for no time limit |
| `engine` | `google` \| `duckduckgo` \| `bing` \| `brave`, optional | Overrides the configured default for one call |
It returns titles, URLs and snippets — **search results only**. It does not fetch
the linked pages; follow up with a read/fetch tool for full content.
## Engines
Four, with independent indexes and independent rate limits. That last part is
the point: when one starts serving captchas, the others generally still work,
and the tool tells the agent so inside the error.
| Engine | Recency windows | Notes |
|---|---|---|
| `google` | day, week, month, year | Best results; blocks aggressively under automation |
| `duckduckgo` | day, week, month, year | No-JS endpoint — the most stable markup here, least likely to challenge |
| `bing` | day, week, month | No year window exists |
| `brave` | none | Independent index, unwrapped links; challenges quickly |
Aliases are accepted: `ddg`, `duck`, `g`, `b`.
**An unsupported recency window is refused, not ignored.** Asking Bing for the
past year raises `RecencyUnsupportedError` naming the engines that can do it.
Silently returning unfiltered results would be indistinguishable from success.
### Switching engines
Exactly like switching the CDP host, plus a per-call override:
```
/search-engine show the current engine and where it came from
/search-engine ddg persist a choice (machine-wide, survives restarts)
/search-engine default back to Google
CCS_SEARCH_ENGINE=bing pi … pin for one process
```
Precedence: **`engine` parameter > `CCS_SEARCH_ENGINE` > `/search-engine` >
Google.** The stored choice lives in
`~/.pi/agent/castle-cdp-search-engine.json`, written atomically.
Note the tool parameter sits *above* the environment variable, which is the
opposite of how the CDP target treats an explicit endpoint. That is deliberate:
for the browser, an env var must win because quietly driving a different machine
means automating the operator's own signed-in Chrome — a safety property.
Choosing a different search engine carries no such hazard, and letting the agent
fall back when one engine is blocked is the single most useful thing it can do
with this tool.
An unrecognised engine name is reported rather than skipped, so a typo in
`CCS_SEARCH_ENGINE` never silently searches Google instead.
## Which browser it drives
Exactly the same configuration as [`pi-browser-harness`](../pi-browser-harness),
on purpose: one decision governs both packages, so they can never end up driving
different browsers in the same session.
| `BU_CDP_HTTP` | Behaviour |
|---|---|
| unset | the `/browser-target` choice stored by pi-browser-harness, else castle (`10.88.0.25:9223`) |
| `host:port` | that endpoint |
| `local` (also `localhost`, `off`, `none`, `0`, `no`) | this machine's own browser on `127.0.0.1:9222` |
Precedence is `BU_CDP_HTTP` > `/browser-target` > built-in default, matching the
harness. The `/browser-target` file (`~/.pi/agent/browser-target.json`) is read
only, never written — the harness owns that setting.
**One deliberate difference from the harness.** When the configured endpoint is
unreachable, this extension reports that and stops. The harness falls back from
its built-in default to a local browser; here that fallback would mean silently
running searches through the operator's own signed-in Chrome without saying so.
Say `local` explicitly if that is what you want.
The endpoint is re-resolved from `/json/version` on every connect, never cached:
castle recycles Chrome hourly and Chrome re-mints its browser UUID each launch,
so a pinned `ws://` URL breaks on the first recycle. A dropped socket reconnects
transparently on the next search.
## Behaviour worth knowing
- **Nothing is dialled at load.** pi runs extension factories in invocations that
never start a session, so the connection is made on first search and closed by
an idempotent `session_shutdown` handler.
- **One fresh background tab per search**, closed on every path including errors
and aborts. `background: true` so it does not steal focus from whoever is
looking at that screen.
- **Two concurrent searches**, queued beyond that.
- **20-second deadline** per call, covering the queue wait, connect, navigate and
extract together. Esc aborts an in-flight navigation.
- **Every failure throws.** pi only sets `isError: true` when `execute()` throws;
a returned `{ error }` object would read to the model as a search that simply
found nothing.
## What it touches
The browser is shared and belongs to a person. Worth being aware of:
- Queries the agent runs land in that browser profile's history and cookies, and
in the search engine's account history if that profile is signed in.
- Tabs open and close on someone's screen. `background: true` keeps them from
stealing focus, but they are visible.
- Searching hard trips rate limiters for the whole host, and the challenge
affects the human using that browser too. Measured during development:
**Google** tolerated a lot, then roughly 30 queries in a few minutes cost about
90 minutes of blocking. **Brave** is far stricter — it challenges after as few
as one or two queries and clears again in roughly 15–20 minutes. **DuckDuckGo**
and **Bing** never challenged once across the whole of development, which is
why DuckDuckGo is the one to reach for when you need to run several searches
in a row.
- **A decaying block does not look like a block.** Once the challenge page stops
being served, engines return an *empty results page* for a while instead —
indistinguishable from a query with no hits. It surfaces as `NoResultsError`,
not `SearchChallengeError`. If one engine comes back empty and another answers
the same query, that is rate limiting.
## When a captcha appears
Every engine eventually serves an interstitial: Google's `/sorry/` page, Brave's
"Verifying you're not a bot", a consent wall, a Cloudflare challenge. The
extension detects these and throws `SearchChallengeError` naming the challenge,
the engine, the browser, and the URL a human would have to visit.
The error tells the agent to **try another engine first**, because a challenge on
one engine says nothing about the others and that is a fix it can apply itself.
Only when engines run out should it ask the user to clear the challenge in the
browser. The cookie is profile-wide, so one pass unblocks later searches.
The tab is closed rather than left open on the challenge page: leaving it would
let the operator solve it in place, but would also litter a shared browser with
abandoned tabs on every failure. The URL in the error is enough.
## Search parameters, and three traps
Everything below was observed against castle's real Chrome, not inferred. All
three fail *silently* — the results look entirely plausible, just wrong.
1. **Google: `tbs=qdr:*` renders an empty page.** That is the parameter Google's
own Tools menu writes. The older `as_qdr=*` works.
2. **Google: any date filter combined with `udm=14` renders that same empty
page.** So a time-limited search drops `udm` and uses the classic layout.
3. **Bing: `count=` silently cancels `filters=`.** With `ex1:"ez1"` alone every
result is hours old; add `count` in either order and months-old results come
back, unfiltered and unremarkable-looking. So Bing drops `count` whenever a
date filter is present and the caller slices the list instead.
Each has a regression test asserting the parameter combination, since none of
them would announce itself if it regressed.
## Result extraction
Two modes, because the engines genuinely differ:
- **items** — DuckDuckGo (`.result`), Bing (`li.b_algo`) and Brave
(`.snippet[data-type=web]`) each have a clean per-result container.
- **headings** — Google has no stable container; its class names (`MjjYud`,
`kb0PBd`, `yuRUbf`) are generated. Results are found from each `<h3>` outward,
climbing to the enclosing block but abandoning the climb if a second `<h3>`
comes into scope.
DuckDuckGo and Bing both route outbound links through redirectors
(`duckduckgo.com/l/?uddg=…`, `bing.com/ck/a?…&u=a1<base64url>`); both are
unwrapped in the page so the agent gets real URLs. Google and Brave link
straight out.
Markup will change. When it does, `NoResultsError` distinguishes "container
rendered but unparseable" (that engine's extractor needs updating) from "no
container at all" (query, or rate limiting).
## Install
```
pi install ssh://sz@10.88.0.25/Users/sz/repos/pi-castle-cdp-search.git
```
The ssh form is what pi-browser-harness uses, and it is the portable one: pi
records local-path installs by resolved absolute path, and `settings.json` is
shared across hosts through the dotfiles symlink.
To try it without installing:
```
pi -e ./src/index.ts
```
## Development
```
npm install
npm run typecheck
npm test # unit tests, no browser needed
```
The tests cover engine selection and precedence, URL building for all four
engines (including the three traps above), recency refusal, formatting, the
challenge error, and the concurrency semaphore. The semaphore tests point at an
unreachable endpoint on purpose — the questions there are about slot
bookkeeping, and hitting a real browser to test a counter would be slow, flaky,
and rude to whoever is using it.
Requires Node 22+, for the global `WebSocket`. There are no runtime
dependencies; pi supplies `@earendil-works/pi-coding-agent`,
`@earendil-works/pi-ai` and `typebox` to extensions through its own loader.
|