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.
@@ -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`.
@@ -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
- const profileDirs = (): ReadonlyArray<string> => {
12
- const home = homedir();
13
- return [
14
- join(home, "Library/Application Support/Google/Chrome"),
15
- join(home, "Library/Application Support/Microsoft Edge"),
16
- join(home, "Library/Application Support/Microsoft Edge Beta"),
17
- join(home, "Library/Application Support/Microsoft Edge Dev"),
18
- join(home, "Library/Application Support/Microsoft Edge Canary"),
19
- join(home, ".config/google-chrome"),
20
- join(home, ".config/chromium"),
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
- export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
70
- const dirs = profileDirs();
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
- const ready = await waitForPort(Number(port));
88
- if (!ready.success) return ready;
89
- return ok(`ws://127.0.0.1:${port}${path}`);
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
- 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
- ));
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
  };
@@ -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
  },
@@ -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