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,223 @@
1
+ /**
2
+ * The single place harness tabs and windows are created.
3
+ *
4
+ * Every tab the harness opens must land in the pinned profile, so all creation
5
+ * paths (client.newTab, browser_open_urls, isolated tabs for web_search /
6
+ * read_page) funnel through here. A missed call site would silently act in
7
+ * another profile — with that profile's cookies and logins.
8
+ *
9
+ * Two mechanisms, chosen by whether a profile is pinned:
10
+ *
11
+ * unpinned — `Target.createTarget`, exactly as before: `newWindow: true` for
12
+ * a fresh dedicated window, `openerId` for siblings inside it.
13
+ *
14
+ * pinned — the window comes from a command-line launch into the profile
15
+ * (see profile/launch.ts), and further tabs come from
16
+ * `window.open(..., '_blank')` evaluated in an owned page with
17
+ * `userGesture: true`. That is the only CDP-reachable way to place
18
+ * a tab in a non-default browser context: createTarget rejects a
19
+ * foreign browserContextId outright, and `openerId` does NOT
20
+ * inherit the opener's context (verified — it lands in the
21
+ * focus-derived default context, in a different window).
22
+ * window.open keeps both the context and the window, recursively.
23
+ *
24
+ * Correlation: window.open returns no target id, so each spawn carries a unique
25
+ * `about:blank#pi-<uuid>` token and the new target is found by that token.
26
+ * Set-difference polling would be wrong here — isolated tabs run concurrently
27
+ * (browser_read_page is registered unserialized).
28
+ */
29
+
30
+ import { randomUUID } from "node:crypto";
31
+ import type { BrowserClient } from "../client";
32
+ import { type Result, err, ok } from "../util/result";
33
+ import { type CdpError, cdpError } from "./errors";
34
+
35
+ /** How long to wait for a window.open-spawned target to appear. */
36
+ const SPAWN_DEADLINE_MS = 8_000;
37
+ const SPAWN_POLL_MS = 100;
38
+
39
+ export type HarnessWindow = {
40
+ /** Target id of the window's seed tab. */
41
+ readonly targetId: string;
42
+ /**
43
+ * True when this call created the window. The seed tab is then a blank tab
44
+ * the caller may use directly, instead of opening a second one.
45
+ */
46
+ readonly freshlyCreated: boolean;
47
+ };
48
+
49
+ type PageTarget = {
50
+ readonly targetId: string;
51
+ readonly type: string;
52
+ readonly url: string;
53
+ readonly browserContextId?: string;
54
+ };
55
+
56
+ const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
57
+ const r = await client.session().callBrowser("Target.getTargets");
58
+ if (!r.success) return r;
59
+ const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
60
+ return ok(data.targetInfos.filter((t) => t.type === "page"));
61
+ };
62
+
63
+ /** A CDP session id for driving `targetId`, reusing the active one when possible. */
64
+ const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => {
65
+ const current = client.current();
66
+ if (current?.targetId === targetId) return ok(current.sessionId);
67
+ const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
68
+ if (!attached.success) return attached;
69
+ return ok((attached.data as { sessionId: string }).sessionId);
70
+ };
71
+
72
+ /**
73
+ * Spawn a tab from `openerTargetId` via window.open, so it inherits the
74
+ * opener's profile and window, and return its target id.
75
+ */
76
+ const spawnTabViaOpener = async (
77
+ client: BrowserClient,
78
+ openerTargetId: string,
79
+ ): Promise<Result<string, CdpError>> => {
80
+ const session = await sessionForTarget(client, openerTargetId);
81
+ if (!session.success) return session;
82
+
83
+ const token = `pi-${randomUUID()}`;
84
+ // userGesture: true is what gets this past the popup blocker.
85
+ const opened = await client.session().callOnTarget(
86
+ "Runtime.evaluate",
87
+ {
88
+ expression: `!!window.open('about:blank#${token}', '_blank')`,
89
+ returnByValue: true,
90
+ userGesture: true,
91
+ },
92
+ session.data,
93
+ );
94
+ if (!opened.success) return opened;
95
+ const result = opened.data as { result?: { value?: unknown } };
96
+ if (result.result?.value !== true) {
97
+ return err(cdpError(
98
+ "invalid_response",
99
+ "the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed",
100
+ "Runtime.evaluate",
101
+ ));
102
+ }
103
+
104
+ const deadline = Date.now() + SPAWN_DEADLINE_MS;
105
+ while (Date.now() < deadline) {
106
+ const targets = await listPageTargets(client);
107
+ if (targets.success) {
108
+ const spawned = targets.data.find((t) => t.url.includes(token));
109
+ if (spawned) return ok(spawned.targetId);
110
+ }
111
+ await new Promise((r) => setTimeout(r, SPAWN_POLL_MS));
112
+ }
113
+ return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`));
114
+ };
115
+
116
+ /**
117
+ * In-flight window creation, per client. Isolated tabs run concurrently
118
+ * (browser_read_page is registered unserialized), so two of them starting at
119
+ * once would otherwise each find "no harness window" and create one — two blank
120
+ * windows before, and two launched profile windows now. Callers share the first
121
+ * call's result instead.
122
+ */
123
+ const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>();
124
+
125
+ /**
126
+ * The harness window for this session, creating it when absent.
127
+ *
128
+ * Reuses a live owned tab when one exists — including after the user closed the
129
+ * original seed — so reconnects don't scatter windows.
130
+ */
131
+ export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => {
132
+ const pending = inFlight.get(client);
133
+ if (pending) {
134
+ const shared = await pending;
135
+ // `freshlyCreated` is a claim on the seed tab, and only the caller that
136
+ // started the creation may hold it. Everyone waiting on the same window
137
+ // must open a tab of their own beside it, or two callers would drive the
138
+ // very same tab.
139
+ return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared;
140
+ }
141
+ const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client));
142
+ inFlight.set(client, attempt);
143
+ return attempt;
144
+ };
145
+
146
+ const ensureHarnessWindowUncoordinated = async (
147
+ client: BrowserClient,
148
+ ): Promise<Result<HarnessWindow, CdpError>> => {
149
+ const targets = await listPageTargets(client);
150
+ if (!targets.success) return targets;
151
+ const live = new Set(targets.data.map((t) => t.targetId));
152
+
153
+ const ownership = client.ownership();
154
+ const pinnedCtx = client.profileContextId();
155
+ // A tab may only be reused when we can prove which profile it is in. With a
156
+ // pin set but no context resolved yet — a fresh connection, or a browser that
157
+ // restarted — the recorded tabs cannot be verified, so we seed a new window
158
+ // rather than risk adopting one from the wrong profile. (Sessions close their
159
+ // owned tabs on shutdown, so this is the normal, no-cost case.)
160
+ const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
161
+ const inPinnedProfile = (targetId: string): boolean => {
162
+ if (!pinnedCtx) return true;
163
+ const info = targets.data.find((t) => t.targetId === targetId);
164
+ return info?.browserContextId === pinnedCtx;
165
+ };
166
+
167
+ if (canReuse) {
168
+ const harnessWindow = ownership.harnessWindow();
169
+ if (harnessWindow && live.has(harnessWindow) && inPinnedProfile(harnessWindow)) {
170
+ return ok({ targetId: harnessWindow, freshlyCreated: false });
171
+ }
172
+ const survivor = ownership.list().find((id) => live.has(id) && inPinnedProfile(id));
173
+ if (survivor) {
174
+ ownership.setHarnessWindow(survivor);
175
+ return ok({ targetId: survivor, freshlyCreated: false });
176
+ }
177
+ }
178
+
179
+ const seeded = client.profilePin()
180
+ ? await client.seedPinnedProfileWindow()
181
+ : await createDedicatedWindow(client);
182
+ if (!seeded.success) return seeded;
183
+ return ok({ targetId: seeded.data, freshlyCreated: true });
184
+ };
185
+
186
+ /** Unpinned path: a fresh dedicated window, exactly as the harness always did. */
187
+ const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => {
188
+ const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
189
+ if (!created.success) return created;
190
+ const { targetId } = created.data as { targetId: string };
191
+ const ownership = client.ownership();
192
+ ownership.setHarnessWindow(targetId);
193
+ ownership.add(targetId);
194
+ const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId });
195
+ if (win.success) {
196
+ const windowId = (win.data as { windowId?: number }).windowId;
197
+ if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
198
+ }
199
+ return ok(targetId);
200
+ };
201
+
202
+ /**
203
+ * Open a harness-owned tab beside `openerTargetId`, in the same window and —
204
+ * when a profile is pinned — the same profile. The tab is registered as owned.
205
+ */
206
+ export const openHarnessTab = async (
207
+ client: BrowserClient,
208
+ openerTargetId: string,
209
+ ): Promise<Result<string, CdpError>> => {
210
+ const spawned = client.profileContextId()
211
+ ? await spawnTabViaOpener(client, openerTargetId)
212
+ : await (async (): Promise<Result<string, CdpError>> => {
213
+ const created = await client.session().callBrowser("Target.createTarget", {
214
+ url: "about:blank",
215
+ openerId: openerTargetId,
216
+ });
217
+ if (!created.success) return created;
218
+ return ok((created.data as { targetId: string }).targetId);
219
+ })();
220
+ if (!spawned.success) return spawned;
221
+ client.ownership().add(spawned.data);
222
+ return ok(spawned.data);
223
+ };
@@ -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
+ };
package/src/client.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { type Result, err, ok } from "./util/result";
2
2
  import { safeJs } from "./util/js-template";
3
3
  import { type Mutex, createMutex } from "./util/mutex";
4
- import { discoverWsUrl } from "./cdp/discovery";
4
+ import { discoverEndpoint } from "./cdp/discovery";
5
5
  import { type CdpError, cdpError } from "./cdp/errors";
6
6
  import type { CdpTransport } from "./cdp/transport";
7
7
  import { createCdpTransport } from "./cdp/transport";
8
8
  import { type CdpSession, createCdpSession } from "./cdp/session";
9
9
  import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
10
+ import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
11
+ import { seedProfileWindow } from "./profile/bind";
12
+ import type { ProfilePin } from "./profile/store";
10
13
  import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
11
14
 
12
15
  export type BrowserClientOptions = {
@@ -16,10 +19,14 @@ export type BrowserClientOptions = {
16
19
  readonly initialOwnership?: {
17
20
  readonly ownedTargetIds?: ReadonlyArray<string>;
18
21
  readonly harnessWindowTargetId?: string;
22
+ readonly harnessWindowId?: number;
19
23
  };
24
+ /** Profile to pin this session to, loaded from the durable pin store. */
25
+ readonly profilePin?: ProfilePin | null;
20
26
  readonly onOwnershipChange?: (snapshot: {
21
27
  readonly ownedTargetIds: ReadonlyArray<string>;
22
28
  readonly harnessWindowTargetId: string | undefined;
29
+ readonly harnessWindowId: number | undefined;
23
30
  }) => void;
24
31
  };
25
32
 
@@ -32,6 +39,9 @@ export type BrowserClient = {
32
39
  /** Detach from the current page target (removes the "Chrome is being controlled" banner)
33
40
  * while keeping the transport connection alive. Call on session shutdown. */
34
41
  detach(): Promise<void>;
42
+ /** Close every tab this session owns and clear the window binding. Best-effort —
43
+ * used on session shutdown so no stale harness tabs survive. */
44
+ closeOwnedTabs(): Promise<void>;
35
45
  evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
36
46
  pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
37
47
  takeDialog(): DialogInfo | null;
@@ -44,6 +54,19 @@ export type BrowserClient = {
44
54
  current(): { readonly sessionId: string; readonly targetId: string } | null;
45
55
  session(): CdpSession;
46
56
  transport(): CdpTransport;
57
+ /** User-data-dir of the connected browser, when discovery could determine it.
58
+ * Undefined for BU_CDP_WS / bare-port connections, where profile selection
59
+ * is unavailable. */
60
+ userDataDir(): string | undefined;
61
+ /** The profile this session is pinned to, or null when unpinned. */
62
+ profilePin(): ProfilePin | null;
63
+ setProfilePin(pin: ProfilePin | null): void;
64
+ /** browserContextId of the pinned profile, once a window has identified it.
65
+ * Session-scoped: Chrome re-mints context ids on every browser run. */
66
+ profileContextId(): string | undefined;
67
+ setProfileContextId(contextId: string | undefined): void;
68
+ /** Open and adopt a window inside the pinned profile. Returns its seed tab. */
69
+ seedPinnedProfileWindow(): Promise<Result<string, CdpError>>;
47
70
  /** Returns the shared async mutex that serialized browser tools must acquire
48
71
  * before performing mutations. Observation tools should not use this. */
49
72
  mutationMutex(): Mutex;
@@ -82,13 +105,16 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
82
105
 
83
106
  export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
84
107
  const transport = opts.transport ?? createCdpTransport();
85
- const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
108
+ const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
86
109
  if (opts.initialOwnership?.ownedTargetIds !== undefined) {
87
110
  ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
88
111
  }
89
112
  if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
90
113
  ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
91
114
  }
115
+ if (opts.initialOwnership?.harnessWindowId !== undefined) {
116
+ ownershipInit.harnessWindowId = opts.initialOwnership.harnessWindowId;
117
+ }
92
118
  const ownership = createOwnershipRegistry(ownershipInit);
93
119
  if (opts.onOwnershipChange) {
94
120
  const cb = opts.onOwnershipChange;
@@ -96,14 +122,37 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
96
122
  cb({
97
123
  ownedTargetIds: ownership.list(),
98
124
  harnessWindowTargetId: ownership.harnessWindow(),
125
+ harnessWindowId: ownership.harnessWindowId(),
99
126
  });
100
127
  });
101
128
  }
102
- const session = createCdpSession(transport, ownership);
129
+ // The seed provider routes attachFirstPage's fallback through the target
130
+ // factory, so a pinned profile is honoured on every path that can create the
131
+ // harness window.
132
+ const session = createCdpSession(transport, ownership, async () => {
133
+ const window = await ensureHarnessWindow(api);
134
+ return window.success ? ok(window.data.targetId) : window;
135
+ });
103
136
  const mutationMutex = createMutex();
104
137
  let lastHealth = 0;
105
138
  let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
106
139
  let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
140
+ let userDataDir: string | undefined;
141
+ let profilePin: ProfilePin | null = opts.profilePin ?? null;
142
+ let profileContextId: string | undefined;
143
+
144
+ /**
145
+ * With a profile pinned, the harness window must exist BEFORE anything
146
+ * attaches: session.attachFirstPage() would otherwise fall back to a bare
147
+ * Target.createTarget, which lands in the focus-derived default profile.
148
+ * Seeding first means attachFirstPage always finds an owned tab to attach to.
149
+ */
150
+ const seedIfPinned = async (): Promise<Result<void, CdpError>> => {
151
+ if (!profilePin) return ok(undefined);
152
+ const window = await ensureHarnessWindow(api);
153
+ if (!window.success) return window;
154
+ return ok(undefined);
155
+ };
107
156
 
108
157
  const start = async (): Promise<Result<void, CdpError>> => {
109
158
  if (transport.state() === "open" && session.current()) return ok(undefined);
@@ -114,15 +163,23 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
114
163
  } else if (envUrl) {
115
164
  wsUrl = envUrl;
116
165
  } else {
117
- const discovered = await discoverWsUrl();
166
+ const discovered = await discoverEndpoint();
118
167
  if (!discovered.success) return discovered;
119
- wsUrl = discovered.data;
168
+ wsUrl = discovered.data.wsUrl;
169
+ userDataDir = discovered.data.userDataDir;
120
170
  }
121
171
  const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
122
172
  if (!connected.success) return connected;
123
173
  if (!remote) {
124
174
  remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" };
125
175
  }
176
+ // A fresh browser run invalidates any context id resolved earlier.
177
+ profileContextId = undefined;
178
+ const seeded = await seedIfPinned();
179
+ if (!seeded.success) {
180
+ await transport.close();
181
+ return seeded;
182
+ }
126
183
  const attached = await session.attachFirstPage();
127
184
  if (!attached.success) {
128
185
  await transport.close();
@@ -157,6 +214,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
157
214
  expression: "1", returnByValue: true,
158
215
  }, { timeoutMs: 2_000 });
159
216
  if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
217
+ // Re-seed first when pinned: if the user closed every harness tab,
218
+ // attachFirstPage would mint a window in the focus-derived profile.
219
+ const seeded = await seedIfPinned();
220
+ if (!seeded.success) return seeded;
160
221
  const reattached = await session.attachFirstPage();
161
222
  if (reattached.success) {
162
223
  pageCaches.clear();
@@ -257,37 +318,33 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
257
318
  };
258
319
 
259
320
  const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
260
- // Verify the recorded harness window still exists. Querying the live
261
- // target list also gives listTabs's reconciliation a chance to run.
321
+ // Reconcile ownership against live targets before deciding where to open.
262
322
  const tabsResult = await listTabs(true);
263
323
  if (!tabsResult.success) return tabsResult;
264
- const live = new Set(tabsResult.data.map((t) => t.targetId));
265
- const hw = ownership.harnessWindow();
266
324
 
267
- const params: Record<string, unknown> = { url: "about:blank" };
268
- if (hw && live.has(hw)) {
269
- // Open as a child of the harness window's seed tab — Chrome places
270
- // it in the same window, giving us visual grouping for free.
271
- params["openerId"] = hw;
325
+ // ensureHarnessWindow reuses the seed tab, falls back to any surviving
326
+ // owned tab, and only then creates a window — which, when a profile is
327
+ // pinned, means launching one inside that profile.
328
+ const window = await ensureHarnessWindow(api);
329
+ if (!window.success) return window;
330
+ // A freshly created window IS the new tab; opening another would leave a
331
+ // stray blank tab behind.
332
+ let tabId: string;
333
+ if (window.data.freshlyCreated) {
334
+ tabId = window.data.targetId;
272
335
  } else {
273
- // Either fresh session or the harness window was closed by the user.
274
- // Spawn a new dedicated window.
275
- params["newWindow"] = true;
336
+ const opened = await openHarnessTab(api, window.data.targetId);
337
+ if (!opened.success) return opened;
338
+ tabId = opened.data;
276
339
  }
277
- const created = await session.callBrowser("Target.createTarget", params);
278
- if (!created.success) return created;
279
- const c = created.data as { targetId: string };
280
-
281
- if (params["newWindow"]) ownership.setHarnessWindow(c.targetId);
282
- ownership.add(c.targetId);
283
340
 
284
- const switched = await switchTab(c.targetId);
341
+ const switched = await switchTab(tabId);
285
342
  if (!switched.success) return switched;
286
343
  if (url && url !== "about:blank") {
287
344
  const nav = await session.call("Page.navigate", { url });
288
345
  if (!nav.success) return nav;
289
346
  }
290
- return ok(c.targetId);
347
+ return ok(tabId);
291
348
  };
292
349
 
293
350
  const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
@@ -297,6 +354,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
297
354
  return ok(undefined);
298
355
  };
299
356
 
357
+ const closeOwnedTabs = async (): Promise<void> => {
358
+ // Snapshot first — remove() mutates the list, and targetDestroyed events
359
+ // may also prune concurrently. Failures are swallowed: a tab the user
360
+ // already closed, or a dead transport, must not block shutdown.
361
+ for (const id of ownership.list()) {
362
+ await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
363
+ ownership.remove(id);
364
+ }
365
+ ownership.setHarnessWindow(undefined);
366
+ ownership.setHarnessWindowId(undefined);
367
+ };
368
+
300
369
  const status = (): DaemonStatus => ({
301
370
  alive: transport.state() === "open" && session.current() !== null,
302
371
  sessionId: session.current()?.sessionId ?? null,
@@ -314,9 +383,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
314
383
  await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
315
384
  };
316
385
 
317
- return {
386
+ const api: BrowserClient = {
318
387
  namespace: opts.namespace,
319
- ensureAlive, status, start, stop, detach,
388
+ ensureAlive, status, start, stop, detach, closeOwnedTabs,
320
389
  evaluateJs, pageInfo,
321
390
  takeDialog: () => session.takeDialog(),
322
391
  listTabs, switchTab, newTab, closeTab,
@@ -325,6 +394,24 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
325
394
  current: () => session.current(),
326
395
  session: () => session,
327
396
  transport: () => transport,
397
+ userDataDir: () => userDataDir,
398
+ profilePin: () => profilePin,
399
+ setProfilePin: (pin) => {
400
+ profilePin = pin;
401
+ // The old profile's context no longer describes where tabs should go.
402
+ profileContextId = undefined;
403
+ },
404
+ profileContextId: () => profileContextId,
405
+ setProfileContextId: (contextId) => {
406
+ profileContextId = contextId;
407
+ },
408
+ seedPinnedProfileWindow: async () => {
409
+ if (!profilePin) {
410
+ return err(cdpError("discovery_failed", "no browser profile is selected — run /browser-profile"));
411
+ }
412
+ return seedProfileWindow(api, profilePin);
413
+ },
328
414
  mutationMutex: () => mutationMutex,
329
415
  };
416
+ return api;
330
417
  };
@@ -133,8 +133,18 @@ export const deserialize = (line: string): WireMessage | null => {
133
133
 
134
134
  // ── Constants ──────────────────────────────────────────────────────────────────
135
135
 
136
- /** Filesystem path for the daemon's Unix domain socket. */
137
- export const DAEMON_SOCKET_PATH = "/tmp/pi-browser-daemon.sock";
136
+ /**
137
+ * IPC endpoint for the daemon.
138
+ *
139
+ * On POSIX platforms this is a Unix domain socket path. On Windows, Node's IPC
140
+ * requires a named pipe of the form `\\.\pipe\<name>` — a Unix path like
141
+ * `/tmp/…` is not a valid `net` listen/connect target there and causes setup
142
+ * to fail. See https://nodejs.org/api/net.html#ipc-support.
143
+ */
144
+ export const DAEMON_SOCKET_PATH =
145
+ process.platform === "win32"
146
+ ? "\\\\.\\pipe\\pi-browser-daemon"
147
+ : "/tmp/pi-browser-daemon.sock";
138
148
 
139
149
  /**
140
150
  * How long the daemon stays alive with zero connected clients before exiting
@@ -19,6 +19,19 @@ import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
19
19
 
20
20
  const moduleDir = dirname(fileURLToPath(import.meta.url));
21
21
 
22
+ const isWindows = process.platform === "win32";
23
+
24
+ /**
25
+ * Remove a stale POSIX socket file. On Windows the daemon endpoint is a named
26
+ * pipe, which is not a filesystem entry and is torn down automatically when the
27
+ * owning process exits — so there is nothing to unlink, and calling unlink on a
28
+ * pipe path throws. This is a no-op on Windows.
29
+ */
30
+ const cleanupStaleSocket = (): void => {
31
+ if (isWindows) return;
32
+ unlink(DAEMON_SOCKET_PATH).catch(() => {});
33
+ };
34
+
22
35
  /**
23
36
  * Check whether the daemon is running by probing its Unix socket.
24
37
  * A file-existence check is unreliable — a crashed daemon leaves a stale socket.
@@ -26,11 +39,15 @@ const moduleDir = dirname(fileURLToPath(import.meta.url));
26
39
  * On failure, clean up the stale socket so ensureDaemon can spawn a fresh one.
27
40
  */
28
41
  export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
29
- // If the socket file doesn't exist, the daemon definitely isn't running.
30
- try {
31
- await access(DAEMON_SOCKET_PATH);
32
- } catch {
33
- return false;
42
+ // On POSIX, a missing socket file means the daemon definitely isn't running,
43
+ // so we can short-circuit. Windows named pipes are not filesystem entries —
44
+ // access() always fails there — so skip the pre-check and probe directly.
45
+ if (!isWindows) {
46
+ try {
47
+ await access(DAEMON_SOCKET_PATH);
48
+ } catch {
49
+ return false;
50
+ }
34
51
  }
35
52
 
36
53
  return new Promise((resolve) => {
@@ -45,7 +62,7 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
45
62
  const timer = setTimeout(() => {
46
63
  sock.destroy();
47
64
  // ponytail: stale socket cleanup; ENOENT = already gone, fine
48
- unlink(DAEMON_SOCKET_PATH).catch(() => {});
65
+ cleanupStaleSocket();
49
66
  done(false);
50
67
  }, timeoutMs);
51
68
 
@@ -70,7 +87,7 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
70
87
  clearTimeout(timer);
71
88
  sock.destroy();
72
89
  // ponytail: connection refused = daemon dead, clean up stale socket
73
- unlink(DAEMON_SOCKET_PATH).catch(() => {});
90
+ cleanupStaleSocket();
74
91
  done(false);
75
92
  });
76
93
 
@@ -96,18 +113,34 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
96
113
  export const spawnDaemon = (): ChildProcess | null => {
97
114
  // Resolve the daemon entry point and tsx binary.
98
115
  const daemonScript = join(moduleDir, "index.ts");
99
- const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
100
116
 
101
- // Use tsx to run the daemon TypeScript source directly.
102
- // The spawed process inherits the parent's env so module resolution works.
117
+ // On Windows, npm installs binaries as `.cmd` shims, not bare executables,
118
+ // and the npx fallback is `npx.cmd`. A `.cmd` file can only be launched
119
+ // through a shell (spawning it directly yields EINVAL), so set shell: true
120
+ // whenever we invoke one.
121
+ const tsxBinBase = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
122
+ const tsxBin = isWindows ? `${tsxBinBase}.cmd` : tsxBinBase;
123
+ const hasLocalTsx = existsSync(tsxBin);
124
+
125
+ // Use the local tsx binary to run the daemon TypeScript source directly.
126
+ // The spawned process inherits the parent's env so module resolution works.
103
127
  // Fall back to `npx tsx` if the local binary isn't available.
104
- const cmd = existsSync(tsxBin) ? tsxBin : "npx";
105
- const args = existsSync(tsxBin) ? [daemonScript] : ["tsx", daemonScript];
128
+ const cmd = hasLocalTsx ? tsxBin : isWindows ? "npx.cmd" : "npx";
129
+ const args = hasLocalTsx ? [daemonScript] : ["tsx", daemonScript];
130
+
131
+ // With shell: true, Node passes the command line to the shell verbatim
132
+ // without quoting, so any path containing spaces (e.g. C:\Users\Some Name)
133
+ // would be split into multiple tokens. Wrap each token in double quotes on
134
+ // Windows to keep space-containing paths intact.
135
+ const quote = (s: string): string => (isWindows ? `"${s}"` : s);
106
136
 
107
- const child = spawn(cmd, args, {
137
+ const child = spawn(quote(cmd), args.map(quote), {
108
138
  detached: true,
109
139
  stdio: "ignore",
110
140
  env: { ...process.env },
141
+ // `.cmd` shims (local tsx or npx.cmd on Windows) must run via a shell.
142
+ shell: isWindows,
143
+ windowsVerbatimArguments: isWindows,
111
144
  });
112
145
 
113
146
  child.unref();
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Deep research — the /deep-research slash command. An explicit entry point
3
+ * (alongside the auto-triggering deep-research skill) that kicks off a research
4
+ * run for a given question.
5
+ *
6
+ * The command is thin: it validates a non-empty question and injects a user
7
+ * message that starts the deep-research flow. The actual orchestration
8
+ * (decompose → fan out web-search-researcher subagents → synthesize a cited
9
+ * report) lives in skills/deep-research/SKILL.md, which the injected prompt
10
+ * triggers. pi.sendUserMessage always starts a turn.
11
+ */
12
+
13
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
14
+
15
+ // ── Shared core: build the prompt that starts a deep-research run ───────────
16
+
17
+ export function buildDeepResearchPrompt(question: string): string {
18
+ return (
19
+ `Do deep research on the following question and produce a cited Markdown report. ` +
20
+ `Use the deep-research skill: decompose it into sub-questions, fan out web-search-researcher ` +
21
+ `subagents to research each against the live web, then synthesize a source-cited report file.\n\n` +
22
+ `Question: ${question}`
23
+ );
24
+ }
25
+
26
+ // ── Public: register the /deep-research slash command ──────────────────────
27
+
28
+ export function registerDeepResearchCommand(pi: ExtensionAPI): void {
29
+ pi.registerCommand("deep-research", {
30
+ description: "Research a question on the web and write a cited report",
31
+ handler: async (args, ctx) => {
32
+ const question = args.trim();
33
+ if (question.length === 0) {
34
+ ctx.ui.notify('Usage: /deep-research <question>. Example: /deep-research "compare CDP vs WebDriver"', "warning");
35
+ return;
36
+ }
37
+ ctx.ui.notify(`Starting deep research: ${question}`, "info");
38
+ pi.sendUserMessage(buildDeepResearchPrompt(question));
39
+ },
40
+ });
41
+ }