pi-browser-harness 0.8.1 → 0.9.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 CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to pi-browser-harness will be documented in this file.
4
4
 
5
+ ## 0.9.0 — 2026-07-24
6
+
7
+ ### Added
8
+
9
+ - **`browser_web_search`** — new tool. Query → ranked links by scraping a real Google SERP in the user's own Chrome, so no API key or search subscription is involved. Runs in an isolated tab whose lifecycle mirrors `browser_open_urls` (never touches the user's current tab). The SERP parser is pure and fixture-tested (9 scenarios), extracts results semantically (`a h3`) and unwraps redirect URLs. CAPTCHA walls and empty result sets surface as `invalid_state` with `details.reason` rather than an empty list.
10
+ - **`browser_read_page`** — new tool. URL (or an owned `targetId`) → clean main-article text with nav/ad/footer boilerplate stripped, via a dependency-free readability heuristic. The DOM walk runs as an in-page capture expression; scoring and selection are a pure, fixture-tested function (link-density plus boilerplate-ancestor filtering, with a body-text fallback for pages that have no article structure). Registered unserialized, so concurrent reads are safe.
11
+ - **`deep-research` skill and `/deep-research` command.** Fans out to isolated `web-search-researcher` subagents, runs a coverage-driven loop with a hard iteration ceiling, and synthesizes a cited Markdown report. The researcher agent now uses `browser_web_search` + `browser_read_page`; it previously referenced `web_search`/`web_fetch` tools that do not exist in this harness.
12
+ - **Forms domain with a universal field setter.** `browser_fill`, `browser_fill_form`, `browser_select_option`, and `browser_set_checked` now live in `src/domains/forms.ts` behind one setter that auto-detects the element type and fires framework-compatible input events (the React native-setter trick), so controlled inputs register the change instead of silently reverting.
13
+ - **Durable window binding.** Every new-window code path now captures the real Chrome `windowId` and binds it as the session's window identity, so per-session tab ownership survives navigation and tab churn instead of being inferred fresh each time.
14
+ - **Brave support and additional Chrome channels.** `checkChromeRunning()` recognizes Brave (macOS `brave browser`, Linux `brave`/`brave-browser`, Windows `brave.exe`) alongside Chrome/Chromium/Edge, and now excludes `gpu`/`updater` sub-processes that linger after a browser quits. Profile discovery covers Brave Stable/Beta/Nightly/Dev and Chrome Beta/Dev/Canary/Chromium across macOS, Linux, and Windows.
15
+ - **Expand/collapse (Ctrl+O) rendering for `browser_web_search` and `browser_read_page`,** via a shared `renderExpandableText` helper (`src/domains/render.ts`) mirroring `browser_execute_js`. Compact preview by default, full body on expand; the complete text still reaches the model regardless of render state. The `web_search` summary line surfaces engine and result count.
16
+
17
+ ### Fixed
18
+
19
+ - **Daemon setup now works on Windows.** The Unix-socket daemon introduced in 0.6.0 carried several POSIX-only assumptions that broke setup entirely on Windows: `DAEMON_SOCKET_PATH` is now a named pipe (`\\.\pipe\pi-browser-daemon`) on win32, since a `/tmp` path is not a valid `net` listen/connect target there; `spawnDaemon()` resolves `tsx.cmd`/`npx.cmd` and runs them through a shell with per-token quoting (paths with spaces) and `windowsVerbatimArguments`, because npm's `.cmd` shims fail with EINVAL/ENOENT when spawned directly; `isDaemonRunning()` skips the `fs.access` pre-check and probes the pipe directly, as named pipes are not filesystem entries and `access()` always fails on them; and stale-socket `unlink()` is a no-op on Windows, where pipes self-clean. (#7)
20
+ - **Stale `DevToolsActivePort` files no longer break CDP discovery.** Discovery previously trusted the first readable port file — a browser that has quit leaves its file behind, so when another browser later bound the same port (e.g. 9222), discovery returned a WS URL carrying the dead browser's UUID against the live browser's server and the connection failed. `discoverWsUrl()` now collects all readable candidates, keeps the most recently written file when candidates share a port, skips ports that aren't live via a fast single-shot probe (no 30s `waitForPort` block on stale files), and asks each live browser for its canonical `webSocketDebuggerUrl` via `/json/version`, falling back to the file's WS path when that endpoint is disabled. Well-known ports (9222, plus `BU_CDP_PORTS`) are probed both when no profile file is readable — covering sandboxed harnesses hitting EPERM/EACCES and non-default install locations — and after all discovered candidates prove stale. Ports parsed from a truncated or corrupt file are validated before use, since `net.connect` throws `ERR_SOCKET_BAD_PORT` synchronously for `NaN`/out-of-range values, which previously escaped as an unhandled rejection. (#4)
21
+ - **Footer browser status indicator removed.** The chip set during `session_start` went stale: a successful `/browser-setup` only called `ctx.ui.notify()` and never updated `ctx.ui.setStatus("browser", …)`, so the red "Browser — run /browser-setup" nudge persisted while the browser was in fact connected. Browser control is on-demand and setup already reports its own outcome, so the persistent chip was removed rather than resynced. (#10)
22
+
23
+ ### Changed
24
+
25
+ - **Setup guidance** now mentions `brave://inspect` / `edge://inspect` and the `--remote-debugging-port` launch flag.
26
+
27
+ ## 0.8.3 — 2026-07-08
28
+
29
+ ### Fixed
30
+
31
+ - **Stale socket detection no longer blocks daemon spawn after a crash.** `isDaemonRunning()` only checked that the Unix socket file existed; a dead daemon leaves a stale socket behind, so `ensureDaemon()` skipped spawning and `client.start()` failed with "Chrome not connected". It now runs a liveness probe — connect, register, verify the ack, disconnect — and cleans up the stale socket on failure. The bridge's `handleRequest` is also async, polling for a Chrome connection for up to 15s before rejecting.
32
+
5
33
  ## 0.8.1 — 2026-07-05
6
34
 
7
35
  ### Fixed
package/README.md CHANGED
@@ -85,6 +85,13 @@ Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. **No screensh
85
85
  | `browser_page_info` | URL, title, viewport, scroll position, or pending dialog. |
86
86
  | `browser_http_get` | Direct HTTP GET outside the browser — 10-50× faster for APIs. |
87
87
 
88
+ ### Research & reading
89
+
90
+ | Tool | Purpose |
91
+ |------|---------|
92
+ | `browser_web_search` | Search the web — scrapes a Google results page in the real Chrome (no API key), returns ranked `{title, url, snippet, rank}`. **Links only** — follow up with `browser_read_page`. Runs in its own isolated tab. |
93
+ | `browser_read_page` | Reader mode — a `url` (opened + closed in an isolated tab) or an owned `targetId` → clean main-article text with nav/ads/boilerplate stripped. Use over snapshot/execute_js when you want an article's content. |
94
+
88
95
  ### Visual (last resort)
89
96
 
90
97
  | Tool | Purpose |
@@ -173,16 +180,29 @@ browser_network_requests({
173
180
 
174
181
  ### Research workflow
175
182
 
183
+ Search → read, with each tool running in its own isolated tab (your current tab is never disturbed):
184
+
176
185
  ```
177
- browser_navigate({ url: "https://google.com/search?q=..." })
178
- browser_open_urls({ urls: ["url1", "url2", "url3"] })
179
- browser_list_tabs()
180
- browser_switch_tab({ targetId: "..." })
181
- browser_wait_for_load()
182
- browser_snapshot()
183
- browser_execute_js({ expression: "document.querySelector('.content').innerText" })
186
+ browser_web_search({ query: "chrome devtools protocol overview", limit: 5 })
187
+ # → ranked {title, url, snippet, rank} — links only
188
+ browser_read_page({ url: "<the best result's url>" })
189
+ # → clean main-article text, boilerplate stripped
184
190
  ```
185
191
 
192
+ ### Deep research
193
+
194
+ For a question that needs multiple sources synthesized into a cited report, run:
195
+
196
+ ```
197
+ /deep-research <your question>
198
+ ```
199
+
200
+ This triggers the **deep-research skill**: it decomposes the question into sub-questions, fans out
201
+ one isolated `web-search-researcher` subagent per sub-question (each using `browser_web_search` +
202
+ `browser_read_page`), loops for coverage under a hard ceiling, and writes a **cited Markdown report**
203
+ file where every claim carries a source link. The skill also auto-triggers on research-shaped
204
+ requests ("research X and cite sources", "compare X and Y with sources") without the command.
205
+
186
206
  ### Visual verification (only when pixels matter)
187
207
 
188
208
  ```
@@ -271,6 +291,7 @@ Scripts are written to disk — auditable and re-runnable.
271
291
  | `/browser-setup` | Connect pi to Chrome (run once) |
272
292
  | `/browser-status` | Show daemon health and current page |
273
293
  | `/browser-reload-daemon` | Restart the connection |
294
+ | `/deep-research <q>` | Research a question on the web and write a cited Markdown report |
274
295
 
275
296
  ---
276
297
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,66 @@
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.
@@ -21,6 +21,12 @@ What do you need to know?
21
21
  ├─ Network behavior on the current page?
22
22
  │ → browser_network_requests
23
23
  │
24
+ ├─ Find pages on the web about a topic?
25
+ │ → browser_web_search (ranked SERP — links only; follow up with browser_read_page)
26
+ │
27
+ ├─ An article's main content as clean text?
28
+ │ → browser_read_page (reader mode — a url or an owned targetId → boilerplate stripped)
29
+ │
24
30
  ├─ JS errors / why did nothing happen after an action?
25
31
  │ → browser_console (DIAGNOSTIC — only when something looks broken)
26
32
  │
@@ -30,6 +36,8 @@ What do you need to know?
30
36
 
31
37
  Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot round-trip.
32
38
 
39
+ `browser_web_search` and `browser_read_page` each run in their own isolated tab and never touch the user's current tab. For a multi-source question that needs a synthesized, cited report, use the **deep-research** skill (or `/deep-research <question>`): it fans out isolated `web-search-researcher` subagents over both tools and writes a source-cited Markdown report.
40
+
33
41
  ## Connection Setup
34
42
 
35
43
  Browser control is **on-demand** — the daemon does NOT start automatically.
@@ -1,7 +1,8 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { connect as netConnect } from "node:net";
5
+ import { request as httpRequest } from "node:http";
5
6
  import { type Result, err, ok } from "../util/result";
6
7
  import { type CdpError, cdpError } from "./errors";
7
8
 
@@ -11,23 +12,45 @@ const PORT_PROBE_INTERVAL_MS = 1_000;
11
12
  const profileDirs = (): ReadonlyArray<string> => {
12
13
  const home = homedir();
13
14
  return [
15
+ // macOS — Chrome (all channels), Chromium, Brave, Edge
14
16
  join(home, "Library/Application Support/Google/Chrome"),
17
+ join(home, "Library/Application Support/Google/Chrome Beta"),
18
+ join(home, "Library/Application Support/Google/Chrome Dev"),
19
+ join(home, "Library/Application Support/Google/Chrome Canary"),
20
+ join(home, "Library/Application Support/Chromium"),
21
+ join(home, "Library/Application Support/BraveSoftware/Brave-Browser"),
22
+ join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Beta"),
23
+ join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Nightly"),
24
+ join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Dev"),
15
25
  join(home, "Library/Application Support/Microsoft Edge"),
16
26
  join(home, "Library/Application Support/Microsoft Edge Beta"),
17
27
  join(home, "Library/Application Support/Microsoft Edge Dev"),
18
28
  join(home, "Library/Application Support/Microsoft Edge Canary"),
29
+ // Linux — Chrome (all channels), Chromium, Brave, Edge
19
30
  join(home, ".config/google-chrome"),
31
+ join(home, ".config/google-chrome-beta"),
32
+ join(home, ".config/google-chrome-unstable"),
20
33
  join(home, ".config/chromium"),
21
34
  join(home, ".config/chromium-browser"),
35
+ join(home, ".config/BraveSoftware/Brave-Browser"),
36
+ join(home, ".config/BraveSoftware/Brave-Browser-Beta"),
37
+ join(home, ".config/BraveSoftware/Brave-Browser-Nightly"),
22
38
  join(home, ".config/microsoft-edge"),
23
39
  join(home, ".config/microsoft-edge-beta"),
24
40
  join(home, ".config/microsoft-edge-dev"),
41
+ // Linux flatpak
25
42
  join(home, ".var/app/org.chromium.Chromium/config/chromium"),
26
43
  join(home, ".var/app/com.google.Chrome/config/google-chrome"),
27
44
  join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
28
45
  join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
46
+ // Windows — Chrome, Chromium, Brave, Edge
29
47
  join(home, "AppData/Local/Google/Chrome/User Data"),
48
+ join(home, "AppData/Local/Google/Chrome Beta/User Data"),
49
+ join(home, "AppData/Local/Google/Chrome Dev/User Data"),
50
+ join(home, "AppData/Local/Google/Chrome SxS/User Data"),
30
51
  join(home, "AppData/Local/Chromium/User Data"),
52
+ join(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data"),
53
+ join(home, "AppData/Local/BraveSoftware/Brave-Browser-Beta/User Data"),
31
54
  join(home, "AppData/Local/Microsoft/Edge/User Data"),
32
55
  join(home, "AppData/Local/Microsoft/Edge Beta/User Data"),
33
56
  join(home, "AppData/Local/Microsoft/Edge Dev/User Data"),
@@ -66,17 +89,94 @@ const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
66
89
  ));
67
90
  };
68
91
 
92
+ // Fast, single-shot TCP check (no retry loop). Used to skip stale
93
+ // DevToolsActivePort files left behind by a browser that has since quit —
94
+ // unlike waitForPort, this returns immediately instead of blocking ~30s.
95
+ const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
96
+
97
+ // Ask the live browser for its canonical webSocketDebuggerUrl via
98
+ // http://127.0.0.1:<port>/json/version. This is authoritative: it avoids
99
+ // trusting a path from a stale DevToolsActivePort file that may belong to a
100
+ // different browser which has since exited (e.g. Chrome's stale file pointing
101
+ // at 9222 while Brave actually owns the port now). Returns null if the endpoint
102
+ // is unreachable, non-200, or doesn't expose a WS URL.
103
+ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> =>
104
+ new Promise<string | null>((resolve) => {
105
+ const req = httpRequest(
106
+ { host: "127.0.0.1", port, path: "/json/version", method: "GET", timeout: timeoutMs },
107
+ (res) => {
108
+ if (res.statusCode !== 200) {
109
+ res.resume();
110
+ resolve(null);
111
+ return;
112
+ }
113
+ const chunks: Buffer[] = [];
114
+ res.on("data", (c: Buffer) => chunks.push(c));
115
+ res.on("end", () => {
116
+ try {
117
+ const json = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
118
+ const ws = json["webSocketDebuggerUrl"];
119
+ resolve(typeof ws === "string" ? ws : null);
120
+ } catch {
121
+ resolve(null);
122
+ }
123
+ });
124
+ },
125
+ );
126
+ req.on("timeout", () => { req.destroy(); resolve(null); });
127
+ req.on("error", () => resolve(null));
128
+ req.end();
129
+ });
130
+
131
+ // Well-known CDP ports to probe when no DevToolsActivePort file is readable
132
+ // (sandboxed harness, restricted filesystem permissions, or an unusual profile
133
+ // location not on the profileDirs list). The default Chromium remote-debugging
134
+ // port is 9222; users can extend via BU_CDP_PORTS="9222,9333".
135
+ const fallbackPorts = (): ReadonlyArray<number> => {
136
+ const ports = new Set<number>([9222]);
137
+ const fromEnv = process.env["BU_CDP_PORTS"];
138
+ if (fromEnv) {
139
+ for (const tok of fromEnv.split(",")) {
140
+ const n = Number(tok.trim());
141
+ if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n);
142
+ }
143
+ }
144
+ return Array.from(ports);
145
+ };
146
+
147
+ type Candidate = { readonly port: number; readonly path: string; readonly mtimeMs: number };
148
+
69
149
  export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
70
150
  const dirs = profileDirs();
151
+
152
+ // Phase 1: collect every readable DevToolsActivePort candidate up front,
153
+ // rather than committing to the first one found. A single browser writes one
154
+ // such file, but stale files from quit browsers linger, so we must
155
+ // disambiguate below instead of trusting discovery order.
156
+ const candidates: Candidate[] = [];
157
+ const readErrors: string[] = [];
71
158
  for (const base of dirs) {
72
159
  const portFile = join(base, "DevToolsActivePort");
73
160
  let raw: string;
161
+ let mtimeMs = 0;
74
162
  try {
75
163
  raw = await readFile(portFile, "utf8");
164
+ try {
165
+ mtimeMs = (await stat(portFile)).mtimeMs;
166
+ } catch {
167
+ // mtime is only a tiebreaker; a stat failure just leaves it at 0.
168
+ }
76
169
  } catch (e) {
77
- // Node fs errors carry .code; see ErrnoException
170
+ // Node fs errors carry .code; see ErrnoException. ENOENT is normal — the
171
+ // dir is on our list but the user hasn't installed that browser. EPERM/
172
+ // EACCES is common under sandboxes; remember it and fall back to network
173
+ // probing rather than failing the whole discovery.
78
174
  const code = (e as NodeJS.ErrnoException).code;
79
175
  if (code === "ENOENT" || code === undefined) continue;
176
+ if (code === "EPERM" || code === "EACCES") {
177
+ readErrors.push(`${portFile}: ${code}`);
178
+ continue;
179
+ }
80
180
  return err(cdpError("discovery_failed", `failed to read ${portFile}: ${e instanceof Error ? e.message : String(e)}`));
81
181
  }
82
182
  const lines = raw.trim().split("\n");
@@ -84,12 +184,74 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
84
184
  const port = lines[0]?.trim();
85
185
  const path = lines[1]?.trim();
86
186
  if (!port || !path) continue;
87
- const ready = await waitForPort(Number(port));
88
- if (!ready.success) return ready;
89
- return ok(`ws://127.0.0.1:${port}${path}`);
187
+ // Guard against a truncated/corrupt port file (e.g. a non-numeric first
188
+ // line): an out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT
189
+ // synchronously, which would escape discoverWsUrl instead of surfacing as
190
+ // a Result error. Skip such entries.
191
+ const portNum = Number(port);
192
+ if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
193
+ candidates.push({ port: portNum, path, mtimeMs });
90
194
  }
91
- return err(cdpError(
92
- "discovery_failed",
93
- `DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.`,
94
- ));
195
+
196
+ // Phase 2: no readable candidate — probe well-known ports directly. Covers
197
+ // sandboxed harnesses that can't read profile dirs and non-default install
198
+ // locations. Requires the browser to have been launched with
199
+ // --remote-debugging-port so /json/version is served.
200
+ if (candidates.length === 0) {
201
+ for (const port of fallbackPorts()) {
202
+ if (!(await isPortLive(port))) continue;
203
+ const live = await queryLiveWsUrl(port);
204
+ if (live) return ok(live);
205
+ }
206
+ const permHint = readErrors.length > 0
207
+ ? `\n\nDevToolsActivePort reads were denied (${readErrors.join(", ")}); if you're running in a sandbox, grant read access to those files or set BU_CDP_WS / BU_CDP_PORTS to bypass file discovery.`
208
+ : "";
209
+ return err(cdpError(
210
+ "discovery_failed",
211
+ `DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging (or brave://inspect, edge://inspect) in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.${permHint}`,
212
+ ));
213
+ }
214
+
215
+ // Phase 3: disambiguate candidates sharing a port (one browser running, and
216
+ // another's file left behind on the default 9222): keep the most-recently-
217
+ // written file — that's the currently-running browser.
218
+ const byPort = new Map<number, Candidate>();
219
+ for (const c of candidates) {
220
+ const prev = byPort.get(c.port);
221
+ if (!prev || c.mtimeMs > prev.mtimeMs) byPort.set(c.port, c);
222
+ }
223
+ const unique = Array.from(byPort.values());
224
+
225
+ // Prefer candidates whose port is actually accepting connections; this skips
226
+ // stale files whose browser has quit (avoiding a ~30s waitForPort block).
227
+ const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port)));
228
+ const liveCandidates = unique.filter((_, i) => liveChecks[i]);
229
+ const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
230
+
231
+ // Phase 4: for each candidate, prefer the browser's own canonical WS URL from
232
+ // /json/version. Some browsers disable that HTTP endpoint when remote
233
+ // debugging is toggled only via chrome://inspect (rather than a launch flag),
234
+ // so fall back to the WS path written in DevToolsActivePort.
235
+ let lastErr: Result<string, CdpError> | null = null;
236
+ for (const c of ordered) {
237
+ const ready = await waitForPort(c.port);
238
+ if (!ready.success) {
239
+ lastErr = err(ready.error);
240
+ continue;
241
+ }
242
+ const liveUrl = await queryLiveWsUrl(c.port);
243
+ return ok(liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`);
244
+ }
245
+
246
+ // Every discovered candidate was stale/unreachable. Before giving up, try the
247
+ // well-known fallback ports — a user with a leftover DevToolsActivePort file
248
+ // may still have a live browser reachable via 9222 / BU_CDP_PORTS. (Skip ports
249
+ // already covered by a candidate above so we don't re-probe them.)
250
+ for (const port of fallbackPorts()) {
251
+ if (byPort.has(port) || !(await isPortLive(port))) continue;
252
+ const liveUrl = await queryLiveWsUrl(port);
253
+ if (liveUrl) return ok(liveUrl);
254
+ }
255
+
256
+ return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
95
257
  };
@@ -20,14 +20,24 @@ export type OwnershipRegistry = {
20
20
  replaceAll(ids: ReadonlyArray<string>): void;
21
21
  setHarnessWindow(targetId: string | undefined): void;
22
22
  harnessWindow(): string | undefined;
23
+ /** The real Chrome windowId of the dedicated harness window — the durable
24
+ * identity of "the window this session initialized", surviving seed-tab
25
+ * closure. Undefined until the window is created (or after teardown). */
26
+ setHarnessWindowId(windowId: number | undefined): void;
27
+ harnessWindowId(): number | undefined;
23
28
  onChange(cb: () => void): void;
24
29
  };
25
30
 
26
31
  export const createOwnershipRegistry = (
27
- initial?: { readonly ownedTargetIds?: ReadonlyArray<string>; readonly harnessWindowTargetId?: string },
32
+ initial?: {
33
+ readonly ownedTargetIds?: ReadonlyArray<string>;
34
+ readonly harnessWindowTargetId?: string;
35
+ readonly harnessWindowId?: number;
36
+ },
28
37
  ): OwnershipRegistry => {
29
38
  const owned = new Set<string>(initial?.ownedTargetIds ?? []);
30
39
  let harnessWindow: string | undefined = initial?.harnessWindowTargetId;
40
+ let harnessWindowId: number | undefined = initial?.harnessWindowId;
31
41
  let listener: (() => void) | null = null;
32
42
 
33
43
  const notify = (): void => {
@@ -65,6 +75,14 @@ export const createOwnershipRegistry = (
65
75
  harnessWindow() {
66
76
  return harnessWindow;
67
77
  },
78
+ setHarnessWindowId(windowId) {
79
+ if (harnessWindowId === windowId) return;
80
+ harnessWindowId = windowId;
81
+ notify();
82
+ },
83
+ harnessWindowId() {
84
+ return harnessWindowId;
85
+ },
68
86
  onChange(cb) {
69
87
  listener = cb;
70
88
  },
@@ -92,6 +92,17 @@ export const createCdpSession = (
92
92
  const tab = targetId ? tabs.get(targetId) : undefined;
93
93
  if (tab) tab.pageInfoDirty = true;
94
94
  }
95
+ if (ev.method === "Target.targetCreated" && ownership) {
96
+ // Adopt tabs opened BY a tab we already own (window.open, target=_blank,
97
+ // popups). Chrome sets openerId to the opener target; if that opener is
98
+ // owned, the child belongs to this session's window and must be
99
+ // controllable + cleaned up. Targets the user opens themselves have no
100
+ // owned opener, so they are never adopted.
101
+ const info = (ev.params as { targetInfo?: { targetId?: string; type?: string; openerId?: string } } | undefined)?.targetInfo;
102
+ if (info?.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) {
103
+ ownership.add(info.targetId);
104
+ }
105
+ }
95
106
  if (ev.method === "Target.targetDestroyed" && ownership) {
96
107
  const params = ev.params as { targetId?: string } | undefined;
97
108
  if (params?.targetId) {
@@ -179,6 +190,43 @@ export const createCdpSession = (
179
190
  if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
180
191
  const hw = ownership.harnessWindow();
181
192
  if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
193
+
194
+ if (survivors.length === 0) {
195
+ // No owned tab survived — the harness window is gone (user closed it,
196
+ // or Chrome restarted and reassigned all ids). The persisted windowId
197
+ // is now meaningless and could even collide with one of the user's
198
+ // windows, so drop it. attachFirstPage will mint a fresh window below.
199
+ ownership.setHarnessWindowId(undefined);
200
+ } else {
201
+ // Self-heal ownership from the window itself. We anchor on a tab we
202
+ // KNOW we own (a survivor) and re-adopt only its live window-mates —
203
+ // popups/children from last session, tabs reopened in our window.
204
+ // Ownership is thus DERIVED from the real window, never from the bare
205
+ // persisted integer (which Chrome may reassign to a user window on
206
+ // restart — anchoring on a survivor makes that misfire impossible).
207
+ const anchorId = survivors[0]!;
208
+ const anchorWin = await transport.request("Browser.getWindowForTarget", { targetId: anchorId }, { sessionId: null });
209
+ if (anchorWin.success) {
210
+ const anchorWindowId = (anchorWin.data as { windowId?: number }).windowId;
211
+ if (typeof anchorWindowId === "number") {
212
+ ownership.setHarnessWindowId(anchorWindowId); // refresh to the live id
213
+ const windows = await Promise.all(
214
+ allPages.map((p) =>
215
+ p.targetId === anchorId
216
+ ? Promise.resolve(anchorWin)
217
+ : transport.request("Browser.getWindowForTarget", { targetId: p.targetId }, { sessionId: null }),
218
+ ),
219
+ );
220
+ allPages.forEach((p, i) => {
221
+ const r = windows[i];
222
+ if (r?.success && (r.data as { windowId?: number }).windowId === anchorWindowId) {
223
+ ownership.add(p.targetId);
224
+ }
225
+ });
226
+ }
227
+ }
228
+ if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId);
229
+ }
182
230
  }
183
231
  // Prune per-tab state for pages that no longer exist
184
232
  const liveTargetIds = new Set(allPages.map((p) => p.targetId));
@@ -208,6 +256,13 @@ export const createCdpSession = (
208
256
  if (ownership) {
209
257
  ownership.setHarnessWindow(c.targetId);
210
258
  ownership.add(c.targetId);
259
+ // Capture the real Chrome windowId — the durable identity of the
260
+ // window this session initialized (survives seed-tab closure).
261
+ const win = await transport.request("Browser.getWindowForTarget", { targetId: c.targetId }, { sessionId: null });
262
+ if (win.success) {
263
+ const wid = (win.data as { windowId?: number }).windowId;
264
+ if (typeof wid === "number") ownership.setHarnessWindowId(wid);
265
+ }
211
266
  }
212
267
  }
213
268
 
@@ -0,0 +1,15 @@
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
+ };