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.
- package/CHANGELOG.md +28 -0
- package/README.md +5 -9
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
|
@@ -1,55 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
134
|
+
let link: string | undefined;
|
|
167
135
|
try {
|
|
168
|
-
|
|
169
|
-
} catch {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/src/profile/command.ts
CHANGED
|
@@ -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 {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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,
|
package/src/profile/launch.ts
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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 (
|
|
93
|
-
try {
|
|
94
|
-
|
|
74
|
+
if (live) {
|
|
75
|
+
try { live.kill("SIGTERM"); } catch {}
|
|
76
|
+
live = null;
|
|
95
77
|
}
|
|
96
78
|
},
|
|
97
79
|
});
|