pi-browser-harness 0.10.2 → 0.11.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -9
  3. package/package.json +5 -2
  4. package/skills/pi-browser-harness/SKILL.md +1 -1
  5. package/src/cdp/commands.ts +256 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +28 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +150 -203
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +18 -68
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -32
  28. package/src/domains/console.ts +48 -2
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +22 -26
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +65 -0
  35. package/src/domains/form.ts +116 -105
  36. package/src/domains/history.ts +13 -9
  37. package/src/domains/isolated-tab.ts +26 -55
  38. package/src/domains/js.ts +29 -42
  39. package/src/domains/keyboard.ts +36 -27
  40. package/src/domains/navigate.ts +16 -23
  41. package/src/domains/network.ts +60 -10
  42. package/src/domains/page.ts +24 -3
  43. package/src/domains/readpage/read-page.ts +74 -19
  44. package/src/domains/readpage/readability.ts +0 -24
  45. package/src/domains/ref-resolve.ts +24 -58
  46. package/src/domains/render.ts +6 -27
  47. package/src/domains/screenshot-capture.ts +80 -0
  48. package/src/domains/screenshot.ts +9 -86
  49. package/src/domains/scroll.ts +10 -11
  50. package/src/domains/search/google-serp.ts +0 -29
  51. package/src/domains/search/web-search.ts +48 -20
  52. package/src/domains/setup.ts +1 -11
  53. package/src/domains/snapshot.ts +35 -276
  54. package/src/domains/tabs.ts +14 -7
  55. package/src/domains/target.ts +35 -0
  56. package/src/domains/viewport.ts +5 -3
  57. package/src/index.ts +8 -58
  58. package/src/profile/bind.ts +8 -57
  59. package/src/profile/browser-process.ts +75 -126
  60. package/src/profile/command.ts +50 -29
  61. package/src/profile/launch.ts +22 -40
  62. package/src/profile/list.ts +14 -54
  63. package/src/profile/paths.ts +2 -65
  64. package/src/profile/store.ts +35 -56
  65. package/src/registry.ts +30 -33
  66. package/src/schemas/parse.ts +23 -0
  67. package/src/setup.ts +0 -25
  68. package/src/state.ts +16 -16
  69. package/src/util/debug.ts +8 -0
  70. package/src/util/guards.ts +30 -0
  71. package/src/util/js-template.ts +1 -16
  72. package/src/util/mutex.ts +0 -5
  73. package/src/util/result.ts +0 -6
  74. package/src/util/sharp-shim.ts +12 -19
  75. package/src/util/tool.ts +17 -42
  76. package/src/util/truncate.ts +6 -0
  77. package/tsconfig.json +1 -16
  78. package/skills/deep-research/SKILL.md +0 -66
  79. package/src/cdp/event-queue.ts +0 -128
  80. package/src/cdp/transport.ts +0 -156
  81. package/src/cdp/window-binding.ts +0 -15
  82. package/src/deep-research.ts +0 -41
  83. package/src/domains/forms.ts +0 -355
  84. package/src/domains/readpage/capture.ts +0 -46
  85. package/src/domains/search/extract.ts +0 -44
  86. package/src/profile/picker.ts +0 -73
  87. package/src/util/keycodes.ts +0 -10
  88. package/src/util/time.ts +0 -29
@@ -1,55 +1,24 @@
1
- /**
2
- * Identifying the browser process the harness is attached to.
3
- *
4
- * Two questions, deliberately kept apart:
5
- *
6
- * 1. Is a Chromium-family browser RUNNING? (`detectRunningBrowser().running`)
7
- * Only live-process evidence may answer this — an installed-but-closed
8
- * browser must read as "not running", which is why the registry lookup
9
- * below is never consulted here.
10
- *
11
- * 2. Which binary do we invoke to open a window for a chosen profile?
12
- * (`resolveBrowserExecutable()`) Chromium's ProcessSingleton hands a second
13
- * launch's argv to the already-running browser instead of starting another
14
- * one — Windows via WM_COPYDATA (process_singleton_win.cc), Linux and macOS
15
- * via the socket in the user-data-dir (process_singleton_posix.cc, which is
16
- * compiled on macOS too). Here an installed path is a fine fallback.
17
- *
18
- * Whether the running browser carries an explicit `--user-data-dir` matters:
19
- * ProcessSingleton keys on that directory, so passing a path that differs from
20
- * the running browser's by case or normalisation would start a SECOND browser
21
- * rather than delegating. The flag is forwarded only when the running process
22
- * already has one.
23
- *
24
- * Every probe is best-effort; when nothing can be determined, profile launching
25
- * degrades to a clear message rather than guessing.
26
- *
27
- * Windows note: `wmic` is deliberately unused — Microsoft removed it by default
28
- * in Windows 11 24H2 and entirely in 25H2. PowerShell CIM is the supported
29
- * replacement, with `tasklist` for liveness and the documented App Paths
30
- * registry key for install location.
31
- */
1
+ // `running` is derived only from live-process evidence: an installed-but-closed browser must read as "not running".
2
+ // `wmic` is deliberately unused — Microsoft removed it by default in Windows 11 24H2 and entirely in 25H2, so PowerShell CIM plus tasklist replace it.
32
3
 
33
4
  import { execFile } from "node:child_process";
34
5
  import { access, readFile, readlink } from "node:fs/promises";
35
6
  import { join } from "node:path";
36
7
  import { promisify } from "node:util";
8
+ import { Type } from "typebox";
9
+ import { Compile } from "typebox/compile";
10
+ import { parseJson } from "../schemas/parse";
37
11
  import { browserNameForUserDataDir } from "./paths";
38
12
 
39
- // execFile, not exec: arguments are passed as an array, with no shell involved.
40
13
  const run = promisify(execFile);
41
14
  const EXEC_TIMEOUT_MS = 5_000;
42
15
 
43
16
  export type RunningBrowser = {
44
- /** True when a Chromium-family browser process is live. */
45
17
  readonly running: boolean;
46
- /** Absolute path to the running browser's binary, when determinable. */
47
18
  readonly exePath?: string;
48
- /** Value of an explicit --user-data-dir on the running process, if any. */
49
19
  readonly explicitUserDataDir?: string;
50
20
  };
51
21
 
52
- /** Chromium-family process names, lowercase, as they appear per platform. */
53
22
  const LINUX_COMMS: ReadonlyArray<string> = [
54
23
  "chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge",
55
24
  "google-chrome", "brave", "brave-browser",
@@ -59,22 +28,13 @@ const MAC_BROWSER_NAMES: ReadonlyArray<string> = [
59
28
  ];
60
29
  const WIN_IMAGE_NAMES: ReadonlyArray<string> = ["chrome.exe", "msedge.exe", "brave.exe"];
61
30
 
62
- /**
63
- * Chromium tags every child process with `--type=<role>`; only the browser
64
- * process omits it or carries `--type=browser`. Matching the flag (rather than
65
- * words like "renderer" anywhere in the string) keeps a user whose install path
66
- * contains such a word from being filtered out.
67
- */
31
+ // Match the `--type=` flag, not words like "renderer" anywhere in the string, or an install path containing one is filtered out.
68
32
  const hasChildProcessType = (commandLine: string): boolean => {
69
33
  const lower = commandLine.toLowerCase();
70
34
  return lower.includes("--type=") && !lower.includes("--type=browser");
71
35
  };
72
36
 
73
- /**
74
- * macOS names its child processes in the executable path itself ("Google Chrome
75
- * Helper (Renderer)", crashpad, updaters), so the process NAME must be filtered
76
- * too — the same exclusions the previous setup.ts check applied.
77
- */
37
+ // macOS names its child processes in the executable path itself, so the process NAME must be filtered too.
78
38
  const isMacChildProcessName = (comm: string): boolean => {
79
39
  const lower = comm.toLowerCase();
80
40
  return (
@@ -86,13 +46,36 @@ const isMacChildProcessName = (comm: string): boolean => {
86
46
  );
87
47
  };
88
48
 
89
- /** One running browser process, as much as the platform will tell us. */
90
49
  export type BrowserCandidate = {
91
50
  readonly exePath?: string;
92
51
  readonly explicitUserDataDir?: string;
93
52
  };
94
53
 
95
- /** Path comparison that tolerates separator and case differences per platform. */
54
+ // A package upgrade under a running browser makes `/proc/<pid>/exe` read "… (deleted)", which fails to spawn with ENOENT unless the marker comes off.
55
+ const DELETED_SUFFIX = " (deleted)";
56
+
57
+ export const stripDeletedSuffix = (path: string): string | undefined =>
58
+ path.endsWith(DELETED_SUFFIX) && path.length > DELETED_SUFFIX.length
59
+ ? path.slice(0, -DELETED_SUFFIX.length)
60
+ : undefined;
61
+
62
+ const exists = async (path: string): Promise<boolean> => {
63
+ try {
64
+ await access(path);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ };
70
+
71
+ export const spawnableExePath = async (raw: string): Promise<string | undefined> => {
72
+ if (raw.length === 0) return undefined;
73
+ if (await exists(raw)) return raw;
74
+ const stripped = stripDeletedSuffix(raw);
75
+ if (stripped && (await exists(stripped))) return stripped;
76
+ return undefined;
77
+ };
78
+
96
79
  const samePath = (a: string, b: string): boolean => {
97
80
  const norm = (p: string): string => {
98
81
  const unified = p.replace(/\\/g, "/").replace(/\/+$/, "");
@@ -101,22 +84,11 @@ const samePath = (a: string, b: string): boolean => {
101
84
  return norm(a) === norm(b);
102
85
  };
103
86
 
104
- /**
105
- * Rank a candidate against the user-data-dir the harness is actually connected
106
- * to. Several Chromium browsers can run at once (Chrome plus Brave, or a second
107
- * instance on a custom dir), and launching a profile window into the wrong one
108
- * would open a window the harness cannot see — while disturbing a browser the
109
- * user never pointed us at.
110
- */
111
87
  export const rankBrowserCandidate = (candidate: BrowserCandidate, preferUserDataDir?: string): number => {
112
88
  if (!preferUserDataDir) return 1;
113
89
  if (candidate.explicitUserDataDir) {
114
- // An explicit dir is decisive in both directions: exact match wins, and a
115
- // different one means this is definitively another browser instance.
116
90
  return samePath(candidate.explicitUserDataDir, preferUserDataDir) ? 4 : 0;
117
91
  }
118
- // No flag: the process uses its default dir. Prefer the one whose executable
119
- // belongs to the same browser family as the target directory.
120
92
  const family = browserNameForUserDataDir(preferUserDataDir).toLowerCase();
121
93
  const exe = (candidate.exePath ?? "").toLowerCase();
122
94
  if (!exe) return 1;
@@ -127,7 +99,6 @@ export const rankBrowserCandidate = (candidate: BrowserCandidate, preferUserData
127
99
  return 1;
128
100
  };
129
101
 
130
- /** Extract `--user-data-dir=<path>` from a command line, quoted or bare. */
131
102
  export const parseUserDataDirFlag = (commandLine: string): string | undefined => {
132
103
  const match = /--user-data-dir=(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(commandLine);
133
104
  if (!match) return undefined;
@@ -135,8 +106,6 @@ export const parseUserDataDirFlag = (commandLine: string): string | undefined =>
135
106
  return value && value.length > 0 ? value : undefined;
136
107
  };
137
108
 
138
- // ── Linux ──────────────────────────────────────────────────────────────────
139
-
140
109
  const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
141
110
  let pids: string[];
142
111
  try {
@@ -157,20 +126,19 @@ const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
157
126
  for (const pid of pids) {
158
127
  let cmdline: string;
159
128
  try {
160
- // /proc/<pid>/cmdline is NUL-separated argv.
161
129
  cmdline = (await readFile(`/proc/${pid}/cmdline`, "utf8")).replace(/\0/g, " ").trim();
162
130
  } catch {
163
131
  continue;
164
132
  }
165
133
  if (!cmdline || hasChildProcessType(cmdline)) continue;
166
- let exePath: string | undefined;
134
+ let link: string | undefined;
167
135
  try {
168
- exePath = await readlink(`/proc/${pid}/exe`);
169
- } catch {
170
- // /proc/<pid>/exe is unreadable for processes we don't own; argv[0] is
171
- // the next best thing.
172
- exePath = cmdline.split(" ")[0];
173
- }
136
+ link = await readlink(`/proc/${pid}/exe`);
137
+ } catch {}
138
+ // Chrome rewrites its argv to the bare executable path, so argv[0] is the live install location when /proc/<pid>/exe is unreadable or stale.
139
+ const exePath =
140
+ (link ? await spawnableExePath(link) : undefined) ??
141
+ (await spawnableExePath(cmdline.split(" ")[0] ?? ""));
174
142
  const explicit = parseUserDataDirFlag(cmdline);
175
143
  candidates.push({
176
144
  ...(exePath ? { exePath } : {}),
@@ -180,8 +148,6 @@ const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
180
148
  return candidates;
181
149
  };
182
150
 
183
- // ── macOS ──────────────────────────────────────────────────────────────────
184
-
185
151
  const collectDarwin = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
186
152
  let lines: string[];
187
153
  try {
@@ -206,9 +172,7 @@ const collectDarwin = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
206
172
  try {
207
173
  const { stdout } = await run("ps", ["-ww", "-o", "args=", "-p", pid], { timeout: EXEC_TIMEOUT_MS });
208
174
  args = stdout.trim();
209
- } catch {
210
- // args are optional — only the --user-data-dir probe needs them
211
- }
175
+ } catch {}
212
176
  if (args && hasChildProcessType(args)) continue;
213
177
  const explicit = args ? parseUserDataDirFlag(args) : undefined;
214
178
  candidates.push({
@@ -219,8 +183,6 @@ const collectDarwin = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
219
183
  return candidates;
220
184
  };
221
185
 
222
- // ── Windows ────────────────────────────────────────────────────────────────
223
-
224
186
  const POWERSHELL_QUERY = [
225
187
  "$ErrorActionPreference='SilentlyContinue';",
226
188
  "Get-CimInstance Win32_Process",
@@ -229,7 +191,17 @@ const POWERSHELL_QUERY = [
229
191
  "| ConvertTo-Json -Compress",
230
192
  ].join(" ");
231
193
 
232
- type WinProcess = { ExecutablePath?: string | null; CommandLine?: string | null };
194
+ const WinProcessSchema = Type.Object(
195
+ {
196
+ ExecutablePath: Type.Optional(Type.Union([Type.String(), Type.Null()])),
197
+ CommandLine: Type.Optional(Type.Union([Type.String(), Type.Null()])),
198
+ },
199
+ { additionalProperties: true },
200
+ );
201
+
202
+ const WinProcessesSchema = Type.Union([WinProcessSchema, Type.Array(WinProcessSchema)]);
203
+
204
+ const winProcessesValidator = Compile(WinProcessesSchema);
233
205
 
234
206
  const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
235
207
  try {
@@ -240,37 +212,31 @@ const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
240
212
  );
241
213
  const trimmed = stdout.trim();
242
214
  if (trimmed) {
243
- const parsed: unknown = JSON.parse(trimmed);
244
- // ConvertTo-Json emits an object for a single row, an array for many.
245
- const rows: WinProcess[] = Array.isArray(parsed) ? (parsed as WinProcess[]) : [parsed as WinProcess];
246
- const candidates = rows
247
- .filter((row) => !hasChildProcessType(row.CommandLine ?? ""))
248
- .map((row): BrowserCandidate => {
249
- const explicit = parseUserDataDirFlag(row.CommandLine ?? "");
250
- return {
251
- ...(row.ExecutablePath ? { exePath: row.ExecutablePath } : {}),
252
- ...(explicit ? { explicitUserDataDir: explicit } : {}),
253
- };
254
- });
255
- if (candidates.length > 0) return candidates;
215
+ const parsed = parseJson(trimmed, winProcessesValidator);
216
+ if (parsed.success) {
217
+ const rows = Array.isArray(parsed.data) ? parsed.data : [parsed.data];
218
+ const candidates = rows
219
+ .filter((row) => !hasChildProcessType(row.CommandLine ?? ""))
220
+ .map((row): BrowserCandidate => {
221
+ const explicit = parseUserDataDirFlag(row.CommandLine ?? "");
222
+ return {
223
+ ...(row.ExecutablePath ? { exePath: row.ExecutablePath } : {}),
224
+ ...(explicit ? { explicitUserDataDir: explicit } : {}),
225
+ };
226
+ });
227
+ if (candidates.length > 0) return candidates;
228
+ }
256
229
  }
257
- } catch {
258
- // PowerShell missing or blocked by policy — fall through to tasklist.
259
- }
230
+ } catch {}
260
231
 
261
- // Liveness-only fallback: tasklist still ships on every supported Windows.
262
- // It reveals no path, so the candidate is a bare "something is running".
263
232
  try {
264
233
  const { stdout } = await run("tasklist.exe", [], { timeout: EXEC_TIMEOUT_MS, windowsHide: true });
265
234
  const lower = stdout.toLowerCase();
266
235
  if (WIN_IMAGE_NAMES.some((n) => lower.includes(n))) return [{}];
267
- } catch {
268
- // best-effort
269
- }
236
+ } catch {}
270
237
  return [];
271
238
  };
272
239
 
273
- /** Installed-browser locations to try when the live process reveals no path. */
274
240
  const winInstallCandidates = (): ReadonlyArray<string> => {
275
241
  const roots = [
276
242
  process.env["PROGRAMFILES"],
@@ -288,7 +254,6 @@ const winInstallCandidates = (): ReadonlyArray<string> => {
288
254
  return roots.flatMap((root) => suffixes.map((suffix) => join(root, suffix)));
289
255
  };
290
256
 
291
- /** Query the Shell's App Paths registration for a browser's install location. */
292
257
  const winRegistryExecutable = async (): Promise<string | undefined> => {
293
258
  for (const image of WIN_IMAGE_NAMES) {
294
259
  for (const hive of ["HKLM", "HKCU"]) {
@@ -301,23 +266,12 @@ const winRegistryExecutable = async (): Promise<string | undefined> => {
301
266
  const match = /REG_[A-Z_]+\s+(.+)$/m.exec(stdout.trim());
302
267
  const path = match?.[1]?.trim();
303
268
  if (path) return path;
304
- } catch {
305
- // key absent for this browser/hive — try the next
306
- }
269
+ } catch {}
307
270
  }
308
271
  }
309
272
  return undefined;
310
273
  };
311
274
 
312
- // ── Public API ─────────────────────────────────────────────────────────────
313
-
314
- /**
315
- * Live-process facts about the running Chromium-family browser. `running` is
316
- * derived only from process evidence, never from an install location.
317
- *
318
- * `preferUserDataDir` should be the dir the harness is connected to; with more
319
- * than one browser running it decides which process is described.
320
- */
321
275
  export const detectRunningBrowser = async (preferUserDataDir?: string): Promise<RunningBrowser> => {
322
276
  const candidates =
323
277
  process.platform === "darwin"
@@ -327,9 +281,11 @@ export const detectRunningBrowser = async (preferUserDataDir?: string): Promise<
327
281
  : await collectLinux();
328
282
  if (candidates.length === 0) return { running: false };
329
283
 
330
- let best = candidates[0] as BrowserCandidate;
284
+ const [first, ...rest] = candidates;
285
+ if (!first) return { running: false };
286
+ let best = first;
331
287
  let bestRank = rankBrowserCandidate(best, preferUserDataDir);
332
- for (const candidate of candidates.slice(1)) {
288
+ for (const candidate of rest) {
333
289
  const rank = rankBrowserCandidate(candidate, preferUserDataDir);
334
290
  if (rank > bestRank) {
335
291
  best = candidate;
@@ -344,17 +300,12 @@ export const detectRunningBrowser = async (preferUserDataDir?: string): Promise<
344
300
  };
345
301
  };
346
302
 
347
- /** True when a Chromium-family browser process is running. */
348
303
  export const isBrowserRunning = async (): Promise<boolean> => (await detectRunningBrowser()).running;
349
304
 
350
- /**
351
- * The binary to invoke when opening a window for a chosen profile: the running
352
- * browser's own executable when known, otherwise a documented install location.
353
- * Undefined when nothing usable is found.
354
- */
355
305
  export const resolveBrowserExecutable = async (running?: RunningBrowser): Promise<string | undefined> => {
356
306
  const detected = running ?? (await detectRunningBrowser());
357
- if (detected.exePath) return detected.exePath;
307
+ const usable = detected.exePath ? await spawnableExePath(detected.exePath) : undefined;
308
+ if (usable) return usable;
358
309
  if (process.platform !== "win32") return undefined;
359
310
 
360
311
  const fromRegistry = await winRegistryExecutable();
@@ -363,9 +314,7 @@ export const resolveBrowserExecutable = async (running?: RunningBrowser): Promis
363
314
  try {
364
315
  await access(candidate);
365
316
  return candidate;
366
- } catch {
367
- // not installed here
368
- }
317
+ } catch {}
369
318
  }
370
319
  return undefined;
371
320
  };
@@ -1,27 +1,59 @@
1
- /**
2
- * /browser-profile — choose which browser profile the agent works in.
3
- *
4
- * The selection is durable (see store.ts) and applies immediately: the current
5
- * harness tabs are closed and a new window is opened in the chosen profile, so
6
- * the agent's next action happens where the user just asked for it rather than
7
- * one session later.
8
- */
9
-
10
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
11
2
  import type { BrowserClient } from "../client";
12
3
  import { discoverEndpoint } from "../cdp/discovery";
13
4
  import { type Result, err, ok } from "../util/result";
14
5
  import { type BrowserProfile, listProfiles } from "./list";
15
6
  import { browserNameForUserDataDir, userDataDirCandidates } from "./paths";
16
- import { pinFor, promptForProfile } from "./picker";
17
- import { clearPin, readPin, writePin } from "./store";
18
-
19
- /**
20
- * Which user-data-dir the picker should enumerate: the connected browser's
21
- * when known, otherwise a running browser's, otherwise the only installed one
22
- * that actually has profiles.
23
- */
24
- export const resolveUserDataDir = async (client: BrowserClient): Promise<Result<string, string>> => {
7
+ import { clearPin, type ProfilePin, readPin, writePin } from "./store";
8
+
9
+ // `ctx.ui.select` resolves to undefined both on Escape and in non-interactive modes, so "no answer" must mean "change nothing".
10
+
11
+ const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —";
12
+
13
+ type ProfileChoice =
14
+ | { readonly kind: "profile"; readonly profile: BrowserProfile }
15
+ | { readonly kind: "clear" }
16
+ | { readonly kind: "cancelled" };
17
+
18
+ const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => {
19
+ const counts = new Map<string, number>();
20
+ for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1);
21
+ return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label));
22
+ };
23
+
24
+ const promptForProfile = async (
25
+ ctx: ExtensionContext,
26
+ profiles: ReadonlyArray<BrowserProfile>,
27
+ currentPin: ProfilePin | null,
28
+ title = "Select browser profile",
29
+ ): Promise<ProfileChoice> => {
30
+ if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" };
31
+
32
+ const labels = disambiguate(profiles);
33
+ const rows = labels.map((label, i) => {
34
+ const isPinned = currentPin?.profileDir === profiles[i]?.dir;
35
+ return isPinned ? `✓ ${label}` : ` ${label}`;
36
+ });
37
+ const options = [...rows, CLEAR_SELECTION_LABEL];
38
+
39
+ const answer = await ctx.ui.select(title, options);
40
+ if (answer === undefined) return { kind: "cancelled" };
41
+ if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" };
42
+
43
+ const index = options.indexOf(answer);
44
+ const profile = index >= 0 ? profiles[index] : undefined;
45
+ if (!profile) return { kind: "cancelled" };
46
+ return { kind: "profile", profile };
47
+ };
48
+
49
+ const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({
50
+ userDataDir,
51
+ profileDir: profile.dir,
52
+ label: profile.label,
53
+ savedAt: new Date().toISOString(),
54
+ });
55
+
56
+ const resolveUserDataDir = async (client: BrowserClient): Promise<Result<string, string>> => {
25
57
  const connected = client.userDataDir();
26
58
  if (connected) return ok(connected);
27
59
 
@@ -43,7 +75,6 @@ export const resolveUserDataDir = async (client: BrowserClient): Promise<Result<
43
75
  );
44
76
  };
45
77
 
46
- /** Enumerate profiles for the resolved dir, or explain why we cannot. */
47
78
  const loadProfiles = async (
48
79
  client: BrowserClient,
49
80
  ): Promise<Result<{ userDataDir: string; profiles: ReadonlyArray<BrowserProfile> }, string>> => {
@@ -56,11 +87,6 @@ const loadProfiles = async (
56
87
  return ok({ userDataDir: dir.data, profiles });
57
88
  };
58
89
 
59
- /**
60
- * Re-seed the live session so the new profile takes effect now. Closing the
61
- * owned tabs and restarting the client makes start() open a window in the
62
- * pinned profile and attach to it.
63
- */
64
90
  const applyToLiveSession = async (client: BrowserClient): Promise<Result<void, string>> => {
65
91
  if (!client.status().alive) return ok(undefined);
66
92
  await client.closeOwnedTabs();
@@ -127,11 +153,6 @@ export function registerProfileCommand(pi: ExtensionAPI, client: BrowserClient):
127
153
  });
128
154
  }
129
155
 
130
- /**
131
- * First-run prompt used by setup: pick a profile when none is pinned yet.
132
- * Returns a note to append to the setup output; the caller always continues,
133
- * because a cancelled or unavailable picker must leave existing behavior intact.
134
- */
135
156
  export const promptForProfileIfUnset = async (
136
157
  client: BrowserClient,
137
158
  ctx: ExtensionContext | undefined,
@@ -1,19 +1,5 @@
1
- /**
2
- * Opening a browser window for a chosen profile.
3
- *
4
- * CDP cannot do this: `Target.createTarget` rejects another profile's
5
- * browserContextId ("Failed to find browser context with id"), and a bare
6
- * createTarget lands in `defaultBrowserContextId`, which follows window focus.
7
- * The only way in is the browser's own command line — Chromium's
8
- * ProcessSingleton hands a second launch's argv to the running browser, which
9
- * "does what it would have done", i.e. opens a window in `--profile-directory`.
10
- *
11
- * The window is then identified by a unique `file://` sentinel page. That
12
- * matters: a bare `about:blank#token` argument is discarded by Chrome (it opens
13
- * chrome://newtab instead), so the token has to ride on a real URL. Polling for
14
- * the sentinel URL — rather than diffing the target list — also keeps
15
- * identification correct when the user opens tabs at the same moment.
16
- */
1
+ // CDP cannot open a window in another profile, so this goes through the command line: ProcessSingleton hands the argv to the running browser.
2
+ // The sentinel must ride on a real `file://` URL — Chrome discards a bare `about:blank#token` argument and opens chrome://newtab instead.
17
3
 
18
4
  import { spawn, type ChildProcess } from "node:child_process";
19
5
  import { randomUUID } from "node:crypto";
@@ -24,23 +10,13 @@ import { pathToFileURL } from "node:url";
24
10
  import { type Result, err, ok } from "../util/result";
25
11
 
26
12
  export type ProfileWindowRequest = {
27
- /** Browser binary to invoke. */
28
13
  readonly exePath: string;
29
- /** Profile subdirectory, e.g. "Profile 1". */
30
14
  readonly profileDir: string;
31
- /**
32
- * Only set when the RUNNING browser was launched with an explicit
33
- * --user-data-dir. ProcessSingleton keys on this path, so passing one that
34
- * differs by case or normalisation would start a second browser instead of
35
- * delegating to the running one.
36
- */
37
15
  readonly explicitUserDataDir?: string;
38
16
  };
39
17
 
40
18
  export type ProfileWindowHandle = {
41
- /** file:// URL that will appear as the new window's page target. */
42
19
  readonly sentinelUrl: string;
43
- /** Remove the sentinel file. Safe to call more than once. */
44
20
  readonly cleanup: () => Promise<void>;
45
21
  readonly kill: () => void;
46
22
  };
@@ -49,10 +25,6 @@ const SENTINEL_HTML = (token: string): string =>
49
25
  `<!doctype html><meta charset="utf-8"><title>${token}</title>` +
50
26
  `<body style="font:14px system-ui;padding:2rem;color:#555">pi browser harness — opening this profile…</body>`;
51
27
 
52
- /**
53
- * Ask the running browser to open a window in `profileDir`, marked with a
54
- * unique sentinel page the caller can then find over CDP.
55
- */
56
28
  export const openProfileWindow = async (
57
29
  req: ProfileWindowRequest,
58
30
  ): Promise<Result<ProfileWindowHandle, string>> => {
@@ -69,29 +41,39 @@ export const openProfileWindow = async (
69
41
  if (req.explicitUserDataDir) args.push(`--user-data-dir=${req.explicitUserDataDir}`);
70
42
  args.push(`--profile-directory=${req.profileDir}`, "--new-window", sentinelUrl);
71
43
 
72
- let child: ChildProcess | null = null;
44
+ let child: ChildProcess;
73
45
  try {
74
- // No shell: arguments are passed as an array, so profile names and paths
75
- // containing spaces survive intact on every platform.
76
46
  child = spawn(req.exePath, args, { detached: true, stdio: "ignore" });
77
- child.unref();
78
- // The launcher process exits as soon as the running browser takes over the
79
- // command line; an error here means the binary itself could not be started.
80
- child.on("error", () => {});
81
47
  } catch (e) {
82
48
  await rm(file, { force: true }).catch(() => {});
83
49
  return err(`could not launch ${req.exePath}: ${e instanceof Error ? e.message : String(e)}`);
84
50
  }
85
51
 
52
+ // spawn() reports a bad binary asynchronously as an 'error' event rather than throwing; unhandled, an ENOENT looked like the 15s sentinel timeout.
53
+ const spawnFailure = await new Promise<string | undefined>((resolve) => {
54
+ child.once("spawn", () => resolve(undefined));
55
+ child.once("error", (e: Error) => resolve(e.message));
56
+ });
57
+ // A later 'error' event with no listener would throw, so keep one attached for the detached launcher's lifetime.
58
+ child.on("error", () => {});
59
+ child.unref();
60
+
61
+ if (spawnFailure) {
62
+ await rm(file, { force: true }).catch(() => {});
63
+ return err(`could not launch ${req.exePath}: ${spawnFailure}`);
64
+ }
65
+
66
+ let live: ChildProcess | null = child;
67
+
86
68
  return ok({
87
69
  sentinelUrl,
88
70
  cleanup: async () => {
89
71
  await rm(file, { force: true }).catch(() => {});
90
72
  },
91
73
  kill: () => {
92
- if (child) {
93
- try { child.kill("SIGTERM"); } catch {}
94
- child = null;
74
+ if (live) {
75
+ try { live.kill("SIGTERM"); } catch {}
76
+ live = null;
95
77
  }
96
78
  },
97
79
  });