pi-browser-harness 0.8.3 → 0.10.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 +50 -0
- package/README.md +242 -181
- package/package.json +1 -1
- package/skills/deep-research/SKILL.md +66 -0
- package/skills/pi-browser-harness/SKILL.md +23 -0
- package/src/cdp/discovery.ts +183 -37
- package/src/cdp/ownership.ts +19 -1
- package/src/cdp/session.ts +74 -0
- package/src/cdp/target-factory.ts +223 -0
- package/src/cdp/window-binding.ts +15 -0
- package/src/client.ts +114 -27
- package/src/daemon/protocol.ts +12 -2
- package/src/daemon/spawn.ts +46 -13
- package/src/deep-research.ts +41 -0
- package/src/domains/forms.ts +355 -0
- package/src/domains/isolated-tab.ts +120 -0
- package/src/domains/navigate.ts +21 -13
- package/src/domains/readpage/capture.ts +46 -0
- package/src/domains/readpage/read-page.ts +119 -0
- package/src/domains/readpage/readability.ts +89 -0
- package/src/domains/render.ts +64 -0
- package/src/domains/search/extract.ts +44 -0
- package/src/domains/search/google-serp.ts +114 -0
- package/src/domains/search/web-search.ts +116 -0
- package/src/domains/setup.ts +4 -2
- package/src/index.ts +33 -7
- package/src/profile/bind.ts +138 -0
- package/src/profile/browser-process.ts +371 -0
- package/src/profile/command.ts +168 -0
- package/src/profile/launch.ts +90 -0
- package/src/profile/list.ts +171 -0
- package/src/profile/paths.ts +166 -0
- package/src/profile/picker.ts +73 -0
- package/src/profile/store.ts +105 -0
- package/src/prompt.ts +92 -116
- package/src/registry.ts +6 -0
- package/src/setup.ts +28 -44
- package/src/state.ts +1 -0
|
@@ -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.
|
|
@@ -46,6 +54,21 @@ connects to Chrome, and opens a test tab. The user sees a single "Allow Remote
|
|
|
46
54
|
Debugging" prompt the first time. After that, all sessions reuse the same connection.
|
|
47
55
|
`browser_setup` is idempotent — safe to call even when already connected.
|
|
48
56
|
|
|
57
|
+
## Browser profile
|
|
58
|
+
|
|
59
|
+
Every harness tab opens in one chosen browser profile, which determines the logins,
|
|
60
|
+
cookies, and extensions you're working with. The user picks it once via
|
|
61
|
+
`/browser-profile`; the choice persists across sessions in `~/.pi/agent/`.
|
|
62
|
+
|
|
63
|
+
The first `browser_setup` in a fresh install shows that picker, so the tool may pause
|
|
64
|
+
briefly on user input — this is expected, and the result appears in the tool output as
|
|
65
|
+
`Browser profile: <name> (<email>)`.
|
|
66
|
+
|
|
67
|
+
If setup reports `couldn't open a window in "…" automatically`, the harness could not
|
|
68
|
+
open the pinned profile's window. Tell the user to open that profile from their browser's
|
|
69
|
+
profile menu and retry, or to run `/browser-profile` to choose another. Never work around
|
|
70
|
+
it by opening tabs elsewhere — a different profile means different accounts.
|
|
71
|
+
|
|
49
72
|
## Connection
|
|
50
73
|
|
|
51
74
|
You're attached to the user's real Chrome — never launch your own. If auth is required, stop and ask the user. If `browser_page_info` returns a dialog, handle it first with `browser_handle_dialog`.
|
package/src/cdp/discovery.ts
CHANGED
|
@@ -1,38 +1,24 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { homedir } from "node:os";
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { connect as netConnect } from "node:net";
|
|
4
|
+
import { request as httpRequest } from "node:http";
|
|
5
5
|
import { type Result, err, ok } from "../util/result";
|
|
6
|
+
import { userDataDirCandidates } from "../profile/paths";
|
|
6
7
|
import { type CdpError, cdpError } from "./errors";
|
|
7
8
|
|
|
8
9
|
const PORT_PROBE_DEADLINE_MS = 30_000;
|
|
9
10
|
const PORT_PROBE_INTERVAL_MS = 1_000;
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
join(home, ".config/chromium-browser"),
|
|
22
|
-
join(home, ".config/microsoft-edge"),
|
|
23
|
-
join(home, ".config/microsoft-edge-beta"),
|
|
24
|
-
join(home, ".config/microsoft-edge-dev"),
|
|
25
|
-
join(home, ".var/app/org.chromium.Chromium/config/chromium"),
|
|
26
|
-
join(home, ".var/app/com.google.Chrome/config/google-chrome"),
|
|
27
|
-
join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
|
|
28
|
-
join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
|
|
29
|
-
join(home, "AppData/Local/Google/Chrome/User Data"),
|
|
30
|
-
join(home, "AppData/Local/Chromium/User Data"),
|
|
31
|
-
join(home, "AppData/Local/Microsoft/Edge/User Data"),
|
|
32
|
-
join(home, "AppData/Local/Microsoft/Edge Beta/User Data"),
|
|
33
|
-
join(home, "AppData/Local/Microsoft/Edge Dev/User Data"),
|
|
34
|
-
join(home, "AppData/Local/Microsoft/Edge SxS/User Data"),
|
|
35
|
-
];
|
|
12
|
+
/**
|
|
13
|
+
* A live CDP endpoint plus, when discovery went through a DevToolsActivePort
|
|
14
|
+
* file, the user-data-dir that file lives in. That directory is the browser's
|
|
15
|
+
* profile registry (`Local State`) — Chromium writes DevToolsActivePort to
|
|
16
|
+
* chrome::DIR_USER_DATA — so it is what profile selection enumerates.
|
|
17
|
+
* Undefined when the endpoint came from BU_CDP_WS or a bare port probe.
|
|
18
|
+
*/
|
|
19
|
+
export type CdpEndpoint = {
|
|
20
|
+
readonly wsUrl: string;
|
|
21
|
+
readonly userDataDir?: string;
|
|
36
22
|
};
|
|
37
23
|
|
|
38
24
|
const probePort = (port: number): Promise<Result<void, CdpError>> =>
|
|
@@ -66,17 +52,104 @@ const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
|
|
|
66
52
|
));
|
|
67
53
|
};
|
|
68
54
|
|
|
69
|
-
|
|
70
|
-
|
|
55
|
+
// Fast, single-shot TCP check (no retry loop). Used to skip stale
|
|
56
|
+
// DevToolsActivePort files left behind by a browser that has since quit —
|
|
57
|
+
// unlike waitForPort, this returns immediately instead of blocking ~30s.
|
|
58
|
+
const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
|
|
59
|
+
|
|
60
|
+
// Ask the live browser for its canonical webSocketDebuggerUrl via
|
|
61
|
+
// http://127.0.0.1:<port>/json/version. This is authoritative: it avoids
|
|
62
|
+
// trusting a path from a stale DevToolsActivePort file that may belong to a
|
|
63
|
+
// different browser which has since exited (e.g. Chrome's stale file pointing
|
|
64
|
+
// at 9222 while Brave actually owns the port now). Returns null if the endpoint
|
|
65
|
+
// is unreachable, non-200, or doesn't expose a WS URL.
|
|
66
|
+
const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> =>
|
|
67
|
+
new Promise<string | null>((resolve) => {
|
|
68
|
+
const req = httpRequest(
|
|
69
|
+
{ host: "127.0.0.1", port, path: "/json/version", method: "GET", timeout: timeoutMs },
|
|
70
|
+
(res) => {
|
|
71
|
+
if (res.statusCode !== 200) {
|
|
72
|
+
res.resume();
|
|
73
|
+
resolve(null);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const chunks: Buffer[] = [];
|
|
77
|
+
res.on("data", (c: Buffer) => chunks.push(c));
|
|
78
|
+
res.on("end", () => {
|
|
79
|
+
try {
|
|
80
|
+
const json = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
|
|
81
|
+
const ws = json["webSocketDebuggerUrl"];
|
|
82
|
+
resolve(typeof ws === "string" ? ws : null);
|
|
83
|
+
} catch {
|
|
84
|
+
resolve(null);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
90
|
+
req.on("error", () => resolve(null));
|
|
91
|
+
req.end();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Well-known CDP ports to probe when no DevToolsActivePort file is readable
|
|
95
|
+
// (sandboxed harness, restricted filesystem permissions, or an unusual profile
|
|
96
|
+
// location not on the profileDirs list). The default Chromium remote-debugging
|
|
97
|
+
// port is 9222; users can extend via BU_CDP_PORTS="9222,9333".
|
|
98
|
+
const fallbackPorts = (): ReadonlyArray<number> => {
|
|
99
|
+
const ports = new Set<number>([9222]);
|
|
100
|
+
const fromEnv = process.env["BU_CDP_PORTS"];
|
|
101
|
+
if (fromEnv) {
|
|
102
|
+
for (const tok of fromEnv.split(",")) {
|
|
103
|
+
const n = Number(tok.trim());
|
|
104
|
+
if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return Array.from(ports);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type Candidate = {
|
|
111
|
+
readonly port: number;
|
|
112
|
+
readonly path: string;
|
|
113
|
+
readonly mtimeMs: number;
|
|
114
|
+
/** The user-data-dir whose DevToolsActivePort produced this candidate. */
|
|
115
|
+
readonly userDataDir: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Locate a live CDP endpoint, reporting the user-data-dir it belongs to when
|
|
120
|
+
* that is knowable. `discoverWsUrl` is the URL-only view of the same walk.
|
|
121
|
+
*/
|
|
122
|
+
export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
|
|
123
|
+
const dirs = userDataDirCandidates();
|
|
124
|
+
|
|
125
|
+
// Phase 1: collect every readable DevToolsActivePort candidate up front,
|
|
126
|
+
// rather than committing to the first one found. A single browser writes one
|
|
127
|
+
// such file, but stale files from quit browsers linger, so we must
|
|
128
|
+
// disambiguate below instead of trusting discovery order.
|
|
129
|
+
const candidates: Candidate[] = [];
|
|
130
|
+
const readErrors: string[] = [];
|
|
71
131
|
for (const base of dirs) {
|
|
72
132
|
const portFile = join(base, "DevToolsActivePort");
|
|
73
133
|
let raw: string;
|
|
134
|
+
let mtimeMs = 0;
|
|
74
135
|
try {
|
|
75
136
|
raw = await readFile(portFile, "utf8");
|
|
137
|
+
try {
|
|
138
|
+
mtimeMs = (await stat(portFile)).mtimeMs;
|
|
139
|
+
} catch {
|
|
140
|
+
// mtime is only a tiebreaker; a stat failure just leaves it at 0.
|
|
141
|
+
}
|
|
76
142
|
} catch (e) {
|
|
77
|
-
// Node fs errors carry .code; see ErrnoException
|
|
143
|
+
// Node fs errors carry .code; see ErrnoException. ENOENT is normal — the
|
|
144
|
+
// dir is on our list but the user hasn't installed that browser. EPERM/
|
|
145
|
+
// EACCES is common under sandboxes; remember it and fall back to network
|
|
146
|
+
// probing rather than failing the whole discovery.
|
|
78
147
|
const code = (e as NodeJS.ErrnoException).code;
|
|
79
148
|
if (code === "ENOENT" || code === undefined) continue;
|
|
149
|
+
if (code === "EPERM" || code === "EACCES") {
|
|
150
|
+
readErrors.push(`${portFile}: ${code}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
80
153
|
return err(cdpError("discovery_failed", `failed to read ${portFile}: ${e instanceof Error ? e.message : String(e)}`));
|
|
81
154
|
}
|
|
82
155
|
const lines = raw.trim().split("\n");
|
|
@@ -84,12 +157,85 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
|
84
157
|
const port = lines[0]?.trim();
|
|
85
158
|
const path = lines[1]?.trim();
|
|
86
159
|
if (!port || !path) continue;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
160
|
+
// Guard against a truncated/corrupt port file (e.g. a non-numeric first
|
|
161
|
+
// line): an out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT
|
|
162
|
+
// synchronously, which would escape discoverWsUrl instead of surfacing as
|
|
163
|
+
// a Result error. Skip such entries.
|
|
164
|
+
const portNum = Number(port);
|
|
165
|
+
if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
|
|
166
|
+
candidates.push({ port: portNum, path, mtimeMs, userDataDir: base });
|
|
90
167
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
168
|
+
|
|
169
|
+
// Phase 2: no readable candidate — probe well-known ports directly. Covers
|
|
170
|
+
// sandboxed harnesses that can't read profile dirs and non-default install
|
|
171
|
+
// locations. Requires the browser to have been launched with
|
|
172
|
+
// --remote-debugging-port so /json/version is served.
|
|
173
|
+
if (candidates.length === 0) {
|
|
174
|
+
for (const port of fallbackPorts()) {
|
|
175
|
+
if (!(await isPortLive(port))) continue;
|
|
176
|
+
const live = await queryLiveWsUrl(port);
|
|
177
|
+
// No DevToolsActivePort file was involved, so the user-data-dir behind
|
|
178
|
+
// this endpoint is unknown — profile selection stays unavailable.
|
|
179
|
+
if (live) return ok({ wsUrl: live });
|
|
180
|
+
}
|
|
181
|
+
const permHint = readErrors.length > 0
|
|
182
|
+
? `\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.`
|
|
183
|
+
: "";
|
|
184
|
+
return err(cdpError(
|
|
185
|
+
"discovery_failed",
|
|
186
|
+
`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}`,
|
|
187
|
+
));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Phase 3: disambiguate candidates sharing a port (one browser running, and
|
|
191
|
+
// another's file left behind on the default 9222): keep the most-recently-
|
|
192
|
+
// written file — that's the currently-running browser.
|
|
193
|
+
const byPort = new Map<number, Candidate>();
|
|
194
|
+
for (const c of candidates) {
|
|
195
|
+
const prev = byPort.get(c.port);
|
|
196
|
+
if (!prev || c.mtimeMs > prev.mtimeMs) byPort.set(c.port, c);
|
|
197
|
+
}
|
|
198
|
+
const unique = Array.from(byPort.values());
|
|
199
|
+
|
|
200
|
+
// Prefer candidates whose port is actually accepting connections; this skips
|
|
201
|
+
// stale files whose browser has quit (avoiding a ~30s waitForPort block).
|
|
202
|
+
const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port)));
|
|
203
|
+
const liveCandidates = unique.filter((_, i) => liveChecks[i]);
|
|
204
|
+
const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
|
|
205
|
+
|
|
206
|
+
// Phase 4: for each candidate, prefer the browser's own canonical WS URL from
|
|
207
|
+
// /json/version. Some browsers disable that HTTP endpoint when remote
|
|
208
|
+
// debugging is toggled only via chrome://inspect (rather than a launch flag),
|
|
209
|
+
// so fall back to the WS path written in DevToolsActivePort.
|
|
210
|
+
let lastErr: Result<CdpEndpoint, CdpError> | null = null;
|
|
211
|
+
for (const c of ordered) {
|
|
212
|
+
const ready = await waitForPort(c.port);
|
|
213
|
+
if (!ready.success) {
|
|
214
|
+
lastErr = err(ready.error);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const liveUrl = await queryLiveWsUrl(c.port);
|
|
218
|
+
return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Every discovered candidate was stale/unreachable. Before giving up, try the
|
|
222
|
+
// well-known fallback ports — a user with a leftover DevToolsActivePort file
|
|
223
|
+
// may still have a live browser reachable via 9222 / BU_CDP_PORTS. (Skip ports
|
|
224
|
+
// already covered by a candidate above so we don't re-probe them.)
|
|
225
|
+
for (const port of fallbackPorts()) {
|
|
226
|
+
if (byPort.has(port) || !(await isPortLive(port))) continue;
|
|
227
|
+
const liveUrl = await queryLiveWsUrl(port);
|
|
228
|
+
if (liveUrl) return ok({ wsUrl: liveUrl });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* URL-only view of {@link discoverEndpoint}, kept as the stable entry point for
|
|
236
|
+
* callers that don't care which user-data-dir answered.
|
|
237
|
+
*/
|
|
238
|
+
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
239
|
+
const endpoint = await discoverEndpoint();
|
|
240
|
+
return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint;
|
|
95
241
|
};
|
package/src/cdp/ownership.ts
CHANGED
|
@@ -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?: {
|
|
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
|
},
|
package/src/cdp/session.ts
CHANGED
|
@@ -45,9 +45,21 @@ export type CdpSession = {
|
|
|
45
45
|
drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Supplies the seed tab when attachFirstPage finds no owned tab to attach to.
|
|
50
|
+
* Injected by the client so that seeding goes through the target factory —
|
|
51
|
+
* which, with a profile pinned, opens the window inside that profile. Without
|
|
52
|
+
* it, attachFirstPage's own `Target.createTarget` would land in whichever
|
|
53
|
+
* profile currently has focus.
|
|
54
|
+
*
|
|
55
|
+
* The provider is responsible for its own ownership bookkeeping.
|
|
56
|
+
*/
|
|
57
|
+
export type SeedTargetProvider = () => Promise<Result<string, CdpError>>;
|
|
58
|
+
|
|
48
59
|
export const createCdpSession = (
|
|
49
60
|
transport: CdpTransport,
|
|
50
61
|
ownership?: OwnershipRegistry,
|
|
62
|
+
createSeedTarget?: SeedTargetProvider,
|
|
51
63
|
): CdpSession => {
|
|
52
64
|
let sessionId: string | null = null;
|
|
53
65
|
let targetId: string | null = null;
|
|
@@ -92,6 +104,17 @@ export const createCdpSession = (
|
|
|
92
104
|
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
93
105
|
if (tab) tab.pageInfoDirty = true;
|
|
94
106
|
}
|
|
107
|
+
if (ev.method === "Target.targetCreated" && ownership) {
|
|
108
|
+
// Adopt tabs opened BY a tab we already own (window.open, target=_blank,
|
|
109
|
+
// popups). Chrome sets openerId to the opener target; if that opener is
|
|
110
|
+
// owned, the child belongs to this session's window and must be
|
|
111
|
+
// controllable + cleaned up. Targets the user opens themselves have no
|
|
112
|
+
// owned opener, so they are never adopted.
|
|
113
|
+
const info = (ev.params as { targetInfo?: { targetId?: string; type?: string; openerId?: string } } | undefined)?.targetInfo;
|
|
114
|
+
if (info?.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) {
|
|
115
|
+
ownership.add(info.targetId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
95
118
|
if (ev.method === "Target.targetDestroyed" && ownership) {
|
|
96
119
|
const params = ev.params as { targetId?: string } | undefined;
|
|
97
120
|
if (params?.targetId) {
|
|
@@ -179,6 +202,43 @@ export const createCdpSession = (
|
|
|
179
202
|
if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
|
|
180
203
|
const hw = ownership.harnessWindow();
|
|
181
204
|
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
205
|
+
|
|
206
|
+
if (survivors.length === 0) {
|
|
207
|
+
// No owned tab survived — the harness window is gone (user closed it,
|
|
208
|
+
// or Chrome restarted and reassigned all ids). The persisted windowId
|
|
209
|
+
// is now meaningless and could even collide with one of the user's
|
|
210
|
+
// windows, so drop it. attachFirstPage will mint a fresh window below.
|
|
211
|
+
ownership.setHarnessWindowId(undefined);
|
|
212
|
+
} else {
|
|
213
|
+
// Self-heal ownership from the window itself. We anchor on a tab we
|
|
214
|
+
// KNOW we own (a survivor) and re-adopt only its live window-mates —
|
|
215
|
+
// popups/children from last session, tabs reopened in our window.
|
|
216
|
+
// Ownership is thus DERIVED from the real window, never from the bare
|
|
217
|
+
// persisted integer (which Chrome may reassign to a user window on
|
|
218
|
+
// restart — anchoring on a survivor makes that misfire impossible).
|
|
219
|
+
const anchorId = survivors[0]!;
|
|
220
|
+
const anchorWin = await transport.request("Browser.getWindowForTarget", { targetId: anchorId }, { sessionId: null });
|
|
221
|
+
if (anchorWin.success) {
|
|
222
|
+
const anchorWindowId = (anchorWin.data as { windowId?: number }).windowId;
|
|
223
|
+
if (typeof anchorWindowId === "number") {
|
|
224
|
+
ownership.setHarnessWindowId(anchorWindowId); // refresh to the live id
|
|
225
|
+
const windows = await Promise.all(
|
|
226
|
+
allPages.map((p) =>
|
|
227
|
+
p.targetId === anchorId
|
|
228
|
+
? Promise.resolve(anchorWin)
|
|
229
|
+
: transport.request("Browser.getWindowForTarget", { targetId: p.targetId }, { sessionId: null }),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
allPages.forEach((p, i) => {
|
|
233
|
+
const r = windows[i];
|
|
234
|
+
if (r?.success && (r.data as { windowId?: number }).windowId === anchorWindowId) {
|
|
235
|
+
ownership.add(p.targetId);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId);
|
|
241
|
+
}
|
|
182
242
|
}
|
|
183
243
|
// Prune per-tab state for pages that no longer exist
|
|
184
244
|
const liveTargetIds = new Set(allPages.map((p) => p.targetId));
|
|
@@ -198,6 +258,13 @@ export const createCdpSession = (
|
|
|
198
258
|
const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
|
|
199
259
|
pickTargetId = ownedLive[0];
|
|
200
260
|
}
|
|
261
|
+
if (!pickTargetId && createSeedTarget) {
|
|
262
|
+
// Delegated seeding (see SeedTargetProvider): the provider creates the
|
|
263
|
+
// window in the right profile and records ownership itself.
|
|
264
|
+
const seeded = await createSeedTarget();
|
|
265
|
+
if (!seeded.success) return seeded;
|
|
266
|
+
pickTargetId = seeded.data;
|
|
267
|
+
}
|
|
201
268
|
if (!pickTargetId) {
|
|
202
269
|
const createParams: Record<string, unknown> = { url: "about:blank" };
|
|
203
270
|
if (ownership) createParams["newWindow"] = true;
|
|
@@ -208,6 +275,13 @@ export const createCdpSession = (
|
|
|
208
275
|
if (ownership) {
|
|
209
276
|
ownership.setHarnessWindow(c.targetId);
|
|
210
277
|
ownership.add(c.targetId);
|
|
278
|
+
// Capture the real Chrome windowId — the durable identity of the
|
|
279
|
+
// window this session initialized (survives seed-tab closure).
|
|
280
|
+
const win = await transport.request("Browser.getWindowForTarget", { targetId: c.targetId }, { sessionId: null });
|
|
281
|
+
if (win.success) {
|
|
282
|
+
const wid = (win.data as { windowId?: number }).windowId;
|
|
283
|
+
if (typeof wid === "number") ownership.setHarnessWindowId(wid);
|
|
284
|
+
}
|
|
211
285
|
}
|
|
212
286
|
}
|
|
213
287
|
|