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/src/client.ts CHANGED
@@ -16,10 +16,12 @@ export type BrowserClientOptions = {
16
16
  readonly initialOwnership?: {
17
17
  readonly ownedTargetIds?: ReadonlyArray<string>;
18
18
  readonly harnessWindowTargetId?: string;
19
+ readonly harnessWindowId?: number;
19
20
  };
20
21
  readonly onOwnershipChange?: (snapshot: {
21
22
  readonly ownedTargetIds: ReadonlyArray<string>;
22
23
  readonly harnessWindowTargetId: string | undefined;
24
+ readonly harnessWindowId: number | undefined;
23
25
  }) => void;
24
26
  };
25
27
 
@@ -32,6 +34,9 @@ export type BrowserClient = {
32
34
  /** Detach from the current page target (removes the "Chrome is being controlled" banner)
33
35
  * while keeping the transport connection alive. Call on session shutdown. */
34
36
  detach(): Promise<void>;
37
+ /** Close every tab this session owns and clear the window binding. Best-effort —
38
+ * used on session shutdown so no stale harness tabs survive. */
39
+ closeOwnedTabs(): Promise<void>;
35
40
  evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
36
41
  pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
37
42
  takeDialog(): DialogInfo | null;
@@ -82,13 +87,16 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
82
87
 
83
88
  export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
84
89
  const transport = opts.transport ?? createCdpTransport();
85
- const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
90
+ const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
86
91
  if (opts.initialOwnership?.ownedTargetIds !== undefined) {
87
92
  ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
88
93
  }
89
94
  if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
90
95
  ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
91
96
  }
97
+ if (opts.initialOwnership?.harnessWindowId !== undefined) {
98
+ ownershipInit.harnessWindowId = opts.initialOwnership.harnessWindowId;
99
+ }
92
100
  const ownership = createOwnershipRegistry(ownershipInit);
93
101
  if (opts.onOwnershipChange) {
94
102
  const cb = opts.onOwnershipChange;
@@ -96,6 +104,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
96
104
  cb({
97
105
  ownedTargetIds: ownership.list(),
98
106
  harnessWindowTargetId: ownership.harnessWindow(),
107
+ harnessWindowId: ownership.harnessWindowId(),
99
108
  });
100
109
  });
101
110
  }
@@ -256,6 +265,15 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
256
265
  return ok(undefined);
257
266
  };
258
267
 
268
+ // Ask Chrome for the real windowId hosting a target. Undefined on failure —
269
+ // callers treat the window binding as best-effort.
270
+ const getWindowId = async (targetId: string): Promise<number | undefined> => {
271
+ const r = await session.callBrowser("Browser.getWindowForTarget", { targetId });
272
+ if (!r.success) return undefined;
273
+ const wid = (r.data as { windowId?: number }).windowId;
274
+ return typeof wid === "number" ? wid : undefined;
275
+ };
276
+
259
277
  const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
260
278
  // Verify the recorded harness window still exists. Querying the live
261
279
  // target list also gives listTabs's reconciliation a chance to run.
@@ -263,22 +281,35 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
263
281
  if (!tabsResult.success) return tabsResult;
264
282
  const live = new Set(tabsResult.data.map((t) => t.targetId));
265
283
  const hw = ownership.harnessWindow();
284
+ // Pick an opener that keeps us inside the harness window. Prefer the seed
285
+ // tab; if the user closed it but other owned tabs survive, reuse one of
286
+ // those instead of spawning a second window (avoids window scatter).
287
+ const opener = hw && live.has(hw)
288
+ ? hw
289
+ : ownership.list().find((id) => live.has(id));
266
290
 
267
291
  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;
292
+ if (opener) {
293
+ // Open as a child of an owned tab — Chrome places it in the same window,
294
+ // giving us visual grouping for free and keeping the session single-window.
295
+ params["openerId"] = opener;
272
296
  } else {
273
- // Either fresh session or the harness window was closed by the user.
274
- // Spawn a new dedicated window.
297
+ // Fresh session, or the whole harness window was closed by the user.
298
+ // Spawn a new dedicated window and rebind to it.
275
299
  params["newWindow"] = true;
276
300
  }
277
301
  const created = await session.callBrowser("Target.createTarget", params);
278
302
  if (!created.success) return created;
279
303
  const c = created.data as { targetId: string };
280
304
 
281
- if (params["newWindow"]) ownership.setHarnessWindow(c.targetId);
305
+ if (params["newWindow"]) {
306
+ ownership.setHarnessWindow(c.targetId);
307
+ const wid = await getWindowId(c.targetId);
308
+ if (wid !== undefined) ownership.setHarnessWindowId(wid);
309
+ } else if (opener && opener !== hw) {
310
+ // Re-seeded onto a surviving owned tab; keep the seed pointer live.
311
+ ownership.setHarnessWindow(opener);
312
+ }
282
313
  ownership.add(c.targetId);
283
314
 
284
315
  const switched = await switchTab(c.targetId);
@@ -297,6 +328,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
297
328
  return ok(undefined);
298
329
  };
299
330
 
331
+ const closeOwnedTabs = async (): Promise<void> => {
332
+ // Snapshot first — remove() mutates the list, and targetDestroyed events
333
+ // may also prune concurrently. Failures are swallowed: a tab the user
334
+ // already closed, or a dead transport, must not block shutdown.
335
+ for (const id of ownership.list()) {
336
+ await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
337
+ ownership.remove(id);
338
+ }
339
+ ownership.setHarnessWindow(undefined);
340
+ ownership.setHarnessWindowId(undefined);
341
+ };
342
+
300
343
  const status = (): DaemonStatus => ({
301
344
  alive: transport.state() === "open" && session.current() !== null,
302
345
  sessionId: session.current()?.sessionId ?? null,
@@ -316,7 +359,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
316
359
 
317
360
  return {
318
361
  namespace: opts.namespace,
319
- ensureAlive, status, start, stop, detach,
362
+ ensureAlive, status, start, stop, detach, closeOwnedTabs,
320
363
  evaluateJs, pageInfo,
321
364
  takeDialog: () => session.takeDialog(),
322
365
  listTabs, switchTab, newTab, closeTab,
@@ -29,7 +29,7 @@ export type CdpBridge = {
29
29
  * are initialized (not when Chrome is connected — that may take retries). */
30
30
  start(): Promise<void>;
31
31
  stop(): Promise<void>;
32
- handleRequest(req: WireRequest, clientId: string, send: SendToClient): void;
32
+ handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>;
33
33
  /** True when the Chrome WebSocket is open and ready. */
34
34
  isAlive(): boolean;
35
35
  recordSession(clientId: string, sessionId: string): void;
@@ -339,11 +339,21 @@ export const createCdpBridge = (): CdpBridge => {
339
339
  wsUrl = null;
340
340
  };
341
341
 
342
- const handleRequest = (
342
+ const handleRequest = async (
343
343
  req: WireRequest,
344
344
  clientId: string,
345
345
  send: SendToClient,
346
- ): void => {
346
+ ): Promise<void> => {
347
+ // ponytail: wait for Chrome to connect (user may still be clicking "Allow").
348
+ // Bridge retries in background; this polls so the client doesn't fail first try.
349
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
350
+ const deadline = Date.now() + 15_000;
351
+ while (Date.now() < deadline) {
352
+ await new Promise((r) => setTimeout(r, 250));
353
+ if (ws && ws.readyState === WebSocket.OPEN) break;
354
+ }
355
+ }
356
+
347
357
  if (!ws || ws.readyState !== WebSocket.OPEN) {
348
358
  send(clientId, {
349
359
  type: "response",
@@ -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
@@ -10,25 +10,94 @@
10
10
  */
11
11
 
12
12
  import { spawn, type ChildProcess } from "node:child_process";
13
- import { access } from "node:fs/promises";
13
+ import { access, unlink } from "node:fs/promises";
14
14
  import { existsSync } from "node:fs";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { dirname, join } from "node:path";
17
- import { DAEMON_SOCKET_PATH } from "./protocol";
17
+ import { createConnection } from "node:net";
18
+ import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
18
19
 
19
20
  const moduleDir = dirname(fileURLToPath(import.meta.url));
20
21
 
22
+ const isWindows = process.platform === "win32";
23
+
21
24
  /**
22
- * Check whether the daemon is running by testing if its Unix socket exists.
23
- * The socket file is created by the daemon on startup and removed on shutdown.
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.
24
29
  */
25
- export const isDaemonRunning = async (): Promise<boolean> => {
26
- try {
27
- await access(DAEMON_SOCKET_PATH);
28
- return true;
29
- } catch {
30
- return false;
30
+ const cleanupStaleSocket = (): void => {
31
+ if (isWindows) return;
32
+ unlink(DAEMON_SOCKET_PATH).catch(() => {});
33
+ };
34
+
35
+ /**
36
+ * Check whether the daemon is running by probing its Unix socket.
37
+ * A file-existence check is unreliable — a crashed daemon leaves a stale socket.
38
+ * We connect, send a register, wait for the registered ack, then disconnect.
39
+ * On failure, clean up the stale socket so ensureDaemon can spawn a fresh one.
40
+ */
41
+ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
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
+ }
31
51
  }
52
+
53
+ return new Promise((resolve) => {
54
+ let settled = false;
55
+ const done = (alive: boolean): void => {
56
+ if (settled) return;
57
+ settled = true;
58
+ resolve(alive);
59
+ };
60
+
61
+ const sock = createConnection(DAEMON_SOCKET_PATH);
62
+ const timer = setTimeout(() => {
63
+ sock.destroy();
64
+ // ponytail: stale socket cleanup; ENOENT = already gone, fine
65
+ cleanupStaleSocket();
66
+ done(false);
67
+ }, timeoutMs);
68
+
69
+ sock.on("connect", () => {
70
+ const regMsg: WireControl = { type: "control", action: "register", clientId: "_liveness_probe" };
71
+ sock.write(serialize(regMsg) + "\n");
72
+ });
73
+
74
+ sock.on("data", (chunk: Buffer) => {
75
+ const text = chunk.toString();
76
+ if (text.includes('"registered"')) {
77
+ clearTimeout(timer);
78
+ // Send deregister so the probe doesn't hog a slot
79
+ const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
80
+ try { sock.write(serialize(deregMsg) + "\n"); } catch {}
81
+ sock.destroy();
82
+ done(true);
83
+ }
84
+ });
85
+
86
+ sock.on("error", () => {
87
+ clearTimeout(timer);
88
+ sock.destroy();
89
+ // ponytail: connection refused = daemon dead, clean up stale socket
90
+ cleanupStaleSocket();
91
+ done(false);
92
+ });
93
+
94
+ // close without 'registered' ack = daemon alive but rejected us (e.g. max clients);
95
+ // don't unlink — the daemon is still using that socket.
96
+ sock.on("close", () => {
97
+ clearTimeout(timer);
98
+ done(false);
99
+ });
100
+ });
32
101
  };
33
102
 
34
103
  /**
@@ -44,18 +113,34 @@ export const isDaemonRunning = async (): Promise<boolean> => {
44
113
  export const spawnDaemon = (): ChildProcess | null => {
45
114
  // Resolve the daemon entry point and tsx binary.
46
115
  const daemonScript = join(moduleDir, "index.ts");
47
- const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
48
116
 
49
- // Use tsx to run the daemon TypeScript source directly.
50
- // 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.
51
127
  // Fall back to `npx tsx` if the local binary isn't available.
52
- const cmd = existsSync(tsxBin) ? tsxBin : "npx";
53
- 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);
54
136
 
55
- const child = spawn(cmd, args, {
137
+ const child = spawn(quote(cmd), args.map(quote), {
56
138
  detached: true,
57
139
  stdio: "ignore",
58
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,
59
144
  });
60
145
 
61
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
+ }