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.
- package/CHANGELOG.md +50 -0
- package/README.md +242 -181
- package/package.json +1 -1
- package/skills/deep-research/SKILL.md +66 -0
- package/skills/pi-browser-harness/SKILL.md +23 -0
- package/src/cdp/discovery.ts +183 -37
- package/src/cdp/ownership.ts +19 -1
- package/src/cdp/session.ts +74 -0
- package/src/cdp/target-factory.ts +223 -0
- package/src/cdp/window-binding.ts +15 -0
- package/src/client.ts +114 -27
- package/src/daemon/protocol.ts +12 -2
- package/src/daemon/spawn.ts +46 -13
- package/src/deep-research.ts +41 -0
- package/src/domains/forms.ts +355 -0
- package/src/domains/isolated-tab.ts +120 -0
- package/src/domains/navigate.ts +21 -13
- package/src/domains/readpage/capture.ts +46 -0
- package/src/domains/readpage/read-page.ts +119 -0
- package/src/domains/readpage/readability.ts +89 -0
- package/src/domains/render.ts +64 -0
- package/src/domains/search/extract.ts +44 -0
- package/src/domains/search/google-serp.ts +114 -0
- package/src/domains/search/web-search.ts +116 -0
- package/src/domains/setup.ts +4 -2
- package/src/index.ts +33 -7
- package/src/profile/bind.ts +138 -0
- package/src/profile/browser-process.ts +371 -0
- package/src/profile/command.ts +168 -0
- package/src/profile/launch.ts +90 -0
- package/src/profile/list.ts +171 -0
- package/src/profile/paths.ts +166 -0
- package/src/profile/picker.ts +73 -0
- package/src/profile/store.ts +105 -0
- package/src/prompt.ts +92 -116
- package/src/registry.ts +6 -0
- package/src/setup.ts +28 -44
- package/src/state.ts +1 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding the harness session to a pinned profile.
|
|
3
|
+
*
|
|
4
|
+
* Sequence, all of it verified against a live browser:
|
|
5
|
+
* 1. resolve the browser binary and any explicit --user-data-dir
|
|
6
|
+
* 2. launch a window into the profile, marked with a file:// sentinel
|
|
7
|
+
* 3. poll Target.getTargets for the sentinel page — its browserContextId IS
|
|
8
|
+
* the profile's context, which is otherwise undiscoverable (CDP exposes no
|
|
9
|
+
* profile identity)
|
|
10
|
+
* 4. adopt that page as the harness window, blank it, drop the sentinel file
|
|
11
|
+
*
|
|
12
|
+
* If the sentinel never appears the bind FAILS LOUDLY. Falling through to a
|
|
13
|
+
* normal createTarget would put the agent in the focus-derived default profile
|
|
14
|
+
* — the exact bug this feature exists to remove — so the caller gets an
|
|
15
|
+
* actionable error instead.
|
|
16
|
+
*
|
|
17
|
+
* The resolved context id is session-scoped state, never persisted: Chrome
|
|
18
|
+
* mints new context ids on every browser run.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { BrowserClient } from "../client";
|
|
22
|
+
import { type CdpError, cdpError } from "../cdp/errors";
|
|
23
|
+
import { type Result, err, ok } from "../util/result";
|
|
24
|
+
import { detectRunningBrowser, resolveBrowserExecutable } from "./browser-process";
|
|
25
|
+
import { openProfileWindow } from "./launch";
|
|
26
|
+
import type { ProfilePin } from "./store";
|
|
27
|
+
|
|
28
|
+
/** The launch delegates through ProcessSingleton, so allow for a cold profile. */
|
|
29
|
+
const SEED_DEADLINE_MS = 15_000;
|
|
30
|
+
const SEED_POLL_MS = 500;
|
|
31
|
+
|
|
32
|
+
type PageTarget = {
|
|
33
|
+
readonly targetId: string;
|
|
34
|
+
readonly type: string;
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly browserContextId?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const pageTargets = async (client: BrowserClient): Promise<ReadonlyArray<PageTarget>> => {
|
|
40
|
+
const r = await client.session().callBrowser("Target.getTargets");
|
|
41
|
+
if (!r.success) return [];
|
|
42
|
+
const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
|
|
43
|
+
return data.targetInfos.filter((t) => t.type === "page");
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const manualFallbackHint = (pin: ProfilePin): string =>
|
|
47
|
+
`open a window for "${pin.label}" yourself (browser profile menu → ${pin.profileDir}), then retry. ` +
|
|
48
|
+
`Run /browser-profile to pick a different profile, or clear the selection to use whichever window is focused.`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Open (and adopt) a harness window inside the pinned profile. Returns the
|
|
52
|
+
* seed tab's target id.
|
|
53
|
+
*/
|
|
54
|
+
export const seedProfileWindow = async (
|
|
55
|
+
client: BrowserClient,
|
|
56
|
+
pin: ProfilePin,
|
|
57
|
+
): Promise<Result<string, CdpError>> => {
|
|
58
|
+
// A pin belongs to one user-data-dir. If the harness is now attached to a
|
|
59
|
+
// different browser, silently seeding the "same" profile name would be wrong.
|
|
60
|
+
const connectedDir = client.userDataDir();
|
|
61
|
+
if (connectedDir && connectedDir !== pin.userDataDir) {
|
|
62
|
+
return err(cdpError(
|
|
63
|
+
"discovery_failed",
|
|
64
|
+
`the selected profile "${pin.label}" belongs to ${pin.userDataDir}, but the harness is connected to ${connectedDir}. ` +
|
|
65
|
+
`Run /browser-profile to choose a profile in the browser you're using.`,
|
|
66
|
+
));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Identify the browser by the dir we're connected to. With several Chromium
|
|
70
|
+
// browsers running, launching into the wrong one would open a window the
|
|
71
|
+
// harness cannot see — in a browser the user never pointed us at.
|
|
72
|
+
const targetUserDataDir = connectedDir ?? pin.userDataDir;
|
|
73
|
+
const running = await detectRunningBrowser(targetUserDataDir);
|
|
74
|
+
const exePath = await resolveBrowserExecutable(running);
|
|
75
|
+
if (!exePath) {
|
|
76
|
+
return err(cdpError(
|
|
77
|
+
"discovery_failed",
|
|
78
|
+
`could not locate the browser executable needed to open "${pin.label}" — ${manualFallbackHint(pin)}`,
|
|
79
|
+
));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const launched = await openProfileWindow({
|
|
83
|
+
exePath,
|
|
84
|
+
profileDir: pin.profileDir,
|
|
85
|
+
// Forward the user-data-dir ONLY when the running browser advertises one.
|
|
86
|
+
// ProcessSingleton keys on this path: passing the value the target process
|
|
87
|
+
// itself reports guarantees delegation, whereas supplying a default dir we
|
|
88
|
+
// reconstructed could differ by case or normalisation and start a SECOND
|
|
89
|
+
// browser instead.
|
|
90
|
+
...(running.explicitUserDataDir ? { explicitUserDataDir: running.explicitUserDataDir } : {}),
|
|
91
|
+
});
|
|
92
|
+
if (!launched.success) {
|
|
93
|
+
return err(cdpError("discovery_failed", `${launched.error} — ${manualFallbackHint(pin)}`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { sentinelUrl, cleanup } = launched.data;
|
|
97
|
+
const deadline = Date.now() + SEED_DEADLINE_MS;
|
|
98
|
+
let seed: PageTarget | undefined;
|
|
99
|
+
while (Date.now() < deadline && !seed) {
|
|
100
|
+
await new Promise((r) => setTimeout(r, SEED_POLL_MS));
|
|
101
|
+
seed = (await pageTargets(client)).find((t) => t.url === sentinelUrl);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!seed) {
|
|
105
|
+
await cleanup();
|
|
106
|
+
return err(cdpError(
|
|
107
|
+
"timeout",
|
|
108
|
+
`couldn't open a window in "${pin.label}" automatically — ${manualFallbackHint(pin)}`,
|
|
109
|
+
));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The sentinel's context is the profile's context — the only way to learn it.
|
|
113
|
+
if (seed.browserContextId) client.setProfileContextId(seed.browserContextId);
|
|
114
|
+
|
|
115
|
+
const ownership = client.ownership();
|
|
116
|
+
ownership.setHarnessWindow(seed.targetId);
|
|
117
|
+
ownership.add(seed.targetId);
|
|
118
|
+
const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId: seed.targetId });
|
|
119
|
+
if (win.success) {
|
|
120
|
+
const windowId = (win.data as { windowId?: number }).windowId;
|
|
121
|
+
if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Blank the handshake page so the seed tab behaves like any other fresh tab,
|
|
125
|
+
// then remove the sentinel file. Best-effort: a failure here costs nothing
|
|
126
|
+
// beyond the placeholder page staying visible.
|
|
127
|
+
const attached = await client.session().callBrowser("Target.attachToTarget", {
|
|
128
|
+
targetId: seed.targetId,
|
|
129
|
+
flatten: true,
|
|
130
|
+
});
|
|
131
|
+
if (attached.success) {
|
|
132
|
+
const sessionId = (attached.data as { sessionId: string }).sessionId;
|
|
133
|
+
await client.session().callOnTarget("Page.navigate", { url: "about:blank" }, sessionId);
|
|
134
|
+
}
|
|
135
|
+
await cleanup();
|
|
136
|
+
|
|
137
|
+
return ok(seed.targetId);
|
|
138
|
+
};
|
|
@@ -0,0 +1,371 @@
|
|
|
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
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { execFile } from "node:child_process";
|
|
34
|
+
import { access, readFile, readlink } from "node:fs/promises";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { promisify } from "node:util";
|
|
37
|
+
import { browserNameForUserDataDir } from "./paths";
|
|
38
|
+
|
|
39
|
+
// execFile, not exec: arguments are passed as an array, with no shell involved.
|
|
40
|
+
const run = promisify(execFile);
|
|
41
|
+
const EXEC_TIMEOUT_MS = 5_000;
|
|
42
|
+
|
|
43
|
+
export type RunningBrowser = {
|
|
44
|
+
/** True when a Chromium-family browser process is live. */
|
|
45
|
+
readonly running: boolean;
|
|
46
|
+
/** Absolute path to the running browser's binary, when determinable. */
|
|
47
|
+
readonly exePath?: string;
|
|
48
|
+
/** Value of an explicit --user-data-dir on the running process, if any. */
|
|
49
|
+
readonly explicitUserDataDir?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Chromium-family process names, lowercase, as they appear per platform. */
|
|
53
|
+
const LINUX_COMMS: ReadonlyArray<string> = [
|
|
54
|
+
"chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge",
|
|
55
|
+
"google-chrome", "brave", "brave-browser",
|
|
56
|
+
];
|
|
57
|
+
const MAC_BROWSER_NAMES: ReadonlyArray<string> = [
|
|
58
|
+
"google chrome", "chromium", "microsoft edge", "brave browser",
|
|
59
|
+
];
|
|
60
|
+
const WIN_IMAGE_NAMES: ReadonlyArray<string> = ["chrome.exe", "msedge.exe", "brave.exe"];
|
|
61
|
+
|
|
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
|
+
*/
|
|
68
|
+
const hasChildProcessType = (commandLine: string): boolean => {
|
|
69
|
+
const lower = commandLine.toLowerCase();
|
|
70
|
+
return lower.includes("--type=") && !lower.includes("--type=browser");
|
|
71
|
+
};
|
|
72
|
+
|
|
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
|
+
*/
|
|
78
|
+
const isMacChildProcessName = (comm: string): boolean => {
|
|
79
|
+
const lower = comm.toLowerCase();
|
|
80
|
+
return (
|
|
81
|
+
lower.includes("helper") ||
|
|
82
|
+
lower.includes("renderer") ||
|
|
83
|
+
lower.includes("crashpad") ||
|
|
84
|
+
lower.includes(" gpu") ||
|
|
85
|
+
lower.includes("updater")
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** One running browser process, as much as the platform will tell us. */
|
|
90
|
+
export type BrowserCandidate = {
|
|
91
|
+
readonly exePath?: string;
|
|
92
|
+
readonly explicitUserDataDir?: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Path comparison that tolerates separator and case differences per platform. */
|
|
96
|
+
const samePath = (a: string, b: string): boolean => {
|
|
97
|
+
const norm = (p: string): string => {
|
|
98
|
+
const unified = p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
99
|
+
return process.platform === "linux" ? unified : unified.toLowerCase();
|
|
100
|
+
};
|
|
101
|
+
return norm(a) === norm(b);
|
|
102
|
+
};
|
|
103
|
+
|
|
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
|
+
export const rankBrowserCandidate = (candidate: BrowserCandidate, preferUserDataDir?: string): number => {
|
|
112
|
+
if (!preferUserDataDir) return 1;
|
|
113
|
+
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
|
+
return samePath(candidate.explicitUserDataDir, preferUserDataDir) ? 4 : 0;
|
|
117
|
+
}
|
|
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
|
+
const family = browserNameForUserDataDir(preferUserDataDir).toLowerCase();
|
|
121
|
+
const exe = (candidate.exePath ?? "").toLowerCase();
|
|
122
|
+
if (!exe) return 1;
|
|
123
|
+
if (family === "brave" && exe.includes("brave")) return 3;
|
|
124
|
+
if (family === "edge" && (exe.includes("edge") || exe.includes("msedge"))) return 3;
|
|
125
|
+
if (family === "chromium" && exe.includes("chromium")) return 3;
|
|
126
|
+
if (family === "chrome" && exe.includes("chrome") && !exe.includes("chromium")) return 3;
|
|
127
|
+
return 1;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Extract `--user-data-dir=<path>` from a command line, quoted or bare. */
|
|
131
|
+
export const parseUserDataDirFlag = (commandLine: string): string | undefined => {
|
|
132
|
+
const match = /--user-data-dir=(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(commandLine);
|
|
133
|
+
if (!match) return undefined;
|
|
134
|
+
const value = match[1] ?? match[2] ?? match[3];
|
|
135
|
+
return value && value.length > 0 ? value : undefined;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ── Linux ──────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
|
|
141
|
+
let pids: string[];
|
|
142
|
+
try {
|
|
143
|
+
const { stdout } = await run("ps", ["-A", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS });
|
|
144
|
+
pids = stdout
|
|
145
|
+
.split("\n")
|
|
146
|
+
.map((line) => line.trim())
|
|
147
|
+
.flatMap((line) => {
|
|
148
|
+
const [pid, comm] = line.split(/\s+/, 2);
|
|
149
|
+
if (!pid || !comm) return [];
|
|
150
|
+
return LINUX_COMMS.includes(comm.toLowerCase()) ? [pid] : [];
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const candidates: BrowserCandidate[] = [];
|
|
157
|
+
for (const pid of pids) {
|
|
158
|
+
let cmdline: string;
|
|
159
|
+
try {
|
|
160
|
+
// /proc/<pid>/cmdline is NUL-separated argv.
|
|
161
|
+
cmdline = (await readFile(`/proc/${pid}/cmdline`, "utf8")).replace(/\0/g, " ").trim();
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!cmdline || hasChildProcessType(cmdline)) continue;
|
|
166
|
+
let exePath: string | undefined;
|
|
167
|
+
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
|
+
}
|
|
174
|
+
const explicit = parseUserDataDirFlag(cmdline);
|
|
175
|
+
candidates.push({
|
|
176
|
+
...(exePath ? { exePath } : {}),
|
|
177
|
+
...(explicit ? { explicitUserDataDir: explicit } : {}),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return candidates;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// ── macOS ──────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
const collectDarwin = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
|
|
186
|
+
let lines: string[];
|
|
187
|
+
try {
|
|
188
|
+
// BSD ps prints the full executable path for `comm`, unlike Linux.
|
|
189
|
+
const { stdout } = await run("ps", ["-A", "-ww", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS });
|
|
190
|
+
lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
191
|
+
} catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const candidates: BrowserCandidate[] = [];
|
|
196
|
+
for (const line of lines) {
|
|
197
|
+
const spaceAt = line.indexOf(" ");
|
|
198
|
+
if (spaceAt < 0) continue;
|
|
199
|
+
const pid = line.slice(0, spaceAt);
|
|
200
|
+
const comm = line.slice(spaceAt + 1).trim();
|
|
201
|
+
const lower = comm.toLowerCase();
|
|
202
|
+
if (!MAC_BROWSER_NAMES.some((name) => lower.includes(name))) continue;
|
|
203
|
+
if (isMacChildProcessName(comm)) continue;
|
|
204
|
+
|
|
205
|
+
let args = "";
|
|
206
|
+
try {
|
|
207
|
+
const { stdout } = await run("ps", ["-ww", "-o", "args=", "-p", pid], { timeout: EXEC_TIMEOUT_MS });
|
|
208
|
+
args = stdout.trim();
|
|
209
|
+
} catch {
|
|
210
|
+
// args are optional — only the --user-data-dir probe needs them
|
|
211
|
+
}
|
|
212
|
+
if (args && hasChildProcessType(args)) continue;
|
|
213
|
+
const explicit = args ? parseUserDataDirFlag(args) : undefined;
|
|
214
|
+
candidates.push({
|
|
215
|
+
exePath: comm,
|
|
216
|
+
...(explicit ? { explicitUserDataDir: explicit } : {}),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return candidates;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// ── Windows ────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
const POWERSHELL_QUERY = [
|
|
225
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
226
|
+
"Get-CimInstance Win32_Process",
|
|
227
|
+
"-Filter \"Name='chrome.exe' or Name='msedge.exe' or Name='brave.exe'\"",
|
|
228
|
+
"| Select-Object ExecutablePath,CommandLine",
|
|
229
|
+
"| ConvertTo-Json -Compress",
|
|
230
|
+
].join(" ");
|
|
231
|
+
|
|
232
|
+
type WinProcess = { ExecutablePath?: string | null; CommandLine?: string | null };
|
|
233
|
+
|
|
234
|
+
const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
|
|
235
|
+
try {
|
|
236
|
+
const { stdout } = await run(
|
|
237
|
+
"powershell.exe",
|
|
238
|
+
["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_QUERY],
|
|
239
|
+
{ timeout: EXEC_TIMEOUT_MS, windowsHide: true },
|
|
240
|
+
);
|
|
241
|
+
const trimmed = stdout.trim();
|
|
242
|
+
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;
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
// PowerShell missing or blocked by policy — fall through to tasklist.
|
|
259
|
+
}
|
|
260
|
+
|
|
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
|
+
try {
|
|
264
|
+
const { stdout } = await run("tasklist.exe", [], { timeout: EXEC_TIMEOUT_MS, windowsHide: true });
|
|
265
|
+
const lower = stdout.toLowerCase();
|
|
266
|
+
if (WIN_IMAGE_NAMES.some((n) => lower.includes(n))) return [{}];
|
|
267
|
+
} catch {
|
|
268
|
+
// best-effort
|
|
269
|
+
}
|
|
270
|
+
return [];
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** Installed-browser locations to try when the live process reveals no path. */
|
|
274
|
+
const winInstallCandidates = (): ReadonlyArray<string> => {
|
|
275
|
+
const roots = [
|
|
276
|
+
process.env["PROGRAMFILES"],
|
|
277
|
+
process.env["PROGRAMFILES(X86)"],
|
|
278
|
+
process.env["LOCALAPPDATA"],
|
|
279
|
+
].filter((r): r is string => typeof r === "string" && r.length > 0);
|
|
280
|
+
const suffixes = [
|
|
281
|
+
"Google\\Chrome\\Application\\chrome.exe",
|
|
282
|
+
"Google\\Chrome Beta\\Application\\chrome.exe",
|
|
283
|
+
"Google\\Chrome SxS\\Application\\chrome.exe",
|
|
284
|
+
"Chromium\\Application\\chrome.exe",
|
|
285
|
+
"BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
286
|
+
"Microsoft\\Edge\\Application\\msedge.exe",
|
|
287
|
+
];
|
|
288
|
+
return roots.flatMap((root) => suffixes.map((suffix) => join(root, suffix)));
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** Query the Shell's App Paths registration for a browser's install location. */
|
|
292
|
+
const winRegistryExecutable = async (): Promise<string | undefined> => {
|
|
293
|
+
for (const image of WIN_IMAGE_NAMES) {
|
|
294
|
+
for (const hive of ["HKLM", "HKCU"]) {
|
|
295
|
+
try {
|
|
296
|
+
const { stdout } = await run(
|
|
297
|
+
"reg.exe",
|
|
298
|
+
["query", `${hive}\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${image}`, "/ve"],
|
|
299
|
+
{ timeout: EXEC_TIMEOUT_MS, windowsHide: true },
|
|
300
|
+
);
|
|
301
|
+
const match = /REG_[A-Z_]+\s+(.+)$/m.exec(stdout.trim());
|
|
302
|
+
const path = match?.[1]?.trim();
|
|
303
|
+
if (path) return path;
|
|
304
|
+
} catch {
|
|
305
|
+
// key absent for this browser/hive — try the next
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
};
|
|
311
|
+
|
|
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
|
+
export const detectRunningBrowser = async (preferUserDataDir?: string): Promise<RunningBrowser> => {
|
|
322
|
+
const candidates =
|
|
323
|
+
process.platform === "darwin"
|
|
324
|
+
? await collectDarwin()
|
|
325
|
+
: process.platform === "win32"
|
|
326
|
+
? await collectWin32()
|
|
327
|
+
: await collectLinux();
|
|
328
|
+
if (candidates.length === 0) return { running: false };
|
|
329
|
+
|
|
330
|
+
let best = candidates[0] as BrowserCandidate;
|
|
331
|
+
let bestRank = rankBrowserCandidate(best, preferUserDataDir);
|
|
332
|
+
for (const candidate of candidates.slice(1)) {
|
|
333
|
+
const rank = rankBrowserCandidate(candidate, preferUserDataDir);
|
|
334
|
+
if (rank > bestRank) {
|
|
335
|
+
best = candidate;
|
|
336
|
+
bestRank = rank;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
running: true,
|
|
342
|
+
...(best.exePath ? { exePath: best.exePath } : {}),
|
|
343
|
+
...(best.explicitUserDataDir ? { explicitUserDataDir: best.explicitUserDataDir } : {}),
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/** True when a Chromium-family browser process is running. */
|
|
348
|
+
export const isBrowserRunning = async (): Promise<boolean> => (await detectRunningBrowser()).running;
|
|
349
|
+
|
|
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
|
+
export const resolveBrowserExecutable = async (running?: RunningBrowser): Promise<string | undefined> => {
|
|
356
|
+
const detected = running ?? (await detectRunningBrowser());
|
|
357
|
+
if (detected.exePath) return detected.exePath;
|
|
358
|
+
if (process.platform !== "win32") return undefined;
|
|
359
|
+
|
|
360
|
+
const fromRegistry = await winRegistryExecutable();
|
|
361
|
+
if (fromRegistry) return fromRegistry;
|
|
362
|
+
for (const candidate of winInstallCandidates()) {
|
|
363
|
+
try {
|
|
364
|
+
await access(candidate);
|
|
365
|
+
return candidate;
|
|
366
|
+
} catch {
|
|
367
|
+
// not installed here
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import type { BrowserClient } from "../client";
|
|
12
|
+
import { discoverEndpoint } from "../cdp/discovery";
|
|
13
|
+
import { type Result, err, ok } from "../util/result";
|
|
14
|
+
import { type BrowserProfile, listProfiles } from "./list";
|
|
15
|
+
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>> => {
|
|
25
|
+
const connected = client.userDataDir();
|
|
26
|
+
if (connected) return ok(connected);
|
|
27
|
+
|
|
28
|
+
const endpoint = await discoverEndpoint();
|
|
29
|
+
if (endpoint.success && endpoint.data.userDataDir) return ok(endpoint.data.userDataDir);
|
|
30
|
+
|
|
31
|
+
const withProfiles: string[] = [];
|
|
32
|
+
for (const dir of userDataDirCandidates()) {
|
|
33
|
+
if ((await listProfiles(dir)).length > 0) withProfiles.push(dir);
|
|
34
|
+
}
|
|
35
|
+
const only = withProfiles[0];
|
|
36
|
+
if (withProfiles.length === 1 && only !== undefined) return ok(only);
|
|
37
|
+
if (withProfiles.length === 0) {
|
|
38
|
+
return err("No browser profiles found on this machine. Open Chrome, Brave, or Edge and try again.");
|
|
39
|
+
}
|
|
40
|
+
return err(
|
|
41
|
+
`Found profiles for more than one browser (${withProfiles.map(browserNameForUserDataDir).join(", ")}). ` +
|
|
42
|
+
"Open the browser you want the agent to use, then run /browser-profile again.",
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Enumerate profiles for the resolved dir, or explain why we cannot. */
|
|
47
|
+
const loadProfiles = async (
|
|
48
|
+
client: BrowserClient,
|
|
49
|
+
): Promise<Result<{ userDataDir: string; profiles: ReadonlyArray<BrowserProfile> }, string>> => {
|
|
50
|
+
const dir = await resolveUserDataDir(client);
|
|
51
|
+
if (!dir.success) return dir;
|
|
52
|
+
const profiles = await listProfiles(dir.data);
|
|
53
|
+
if (profiles.length === 0) {
|
|
54
|
+
return err(`No profiles found in ${dir.data}. If the browser was just installed, open it once and retry.`);
|
|
55
|
+
}
|
|
56
|
+
return ok({ userDataDir: dir.data, profiles });
|
|
57
|
+
};
|
|
58
|
+
|
|
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
|
+
const applyToLiveSession = async (client: BrowserClient): Promise<Result<void, string>> => {
|
|
65
|
+
if (!client.status().alive) return ok(undefined);
|
|
66
|
+
await client.closeOwnedTabs();
|
|
67
|
+
await client.stop();
|
|
68
|
+
const started = await client.start();
|
|
69
|
+
if (!started.success) return err(started.error.message);
|
|
70
|
+
return ok(undefined);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function registerProfileCommand(pi: ExtensionAPI, client: BrowserClient): void {
|
|
74
|
+
pi.registerCommand("browser-profile", {
|
|
75
|
+
description: "Choose which browser profile the agent uses",
|
|
76
|
+
handler: async (_args, ctx) => {
|
|
77
|
+
const loaded = await loadProfiles(client);
|
|
78
|
+
if (!loaded.success) {
|
|
79
|
+
ctx.ui.notify(loaded.error, "error");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!ctx.hasUI) {
|
|
83
|
+
ctx.ui.notify("Profile selection needs an interactive terminal.", "warning");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const currentPin = client.profilePin() ?? (await readPin());
|
|
88
|
+
const choice = await promptForProfile(ctx, loaded.data.profiles, currentPin);
|
|
89
|
+
|
|
90
|
+
if (choice.kind === "cancelled") {
|
|
91
|
+
ctx.ui.notify("Profile unchanged.", "info");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (choice.kind === "clear") {
|
|
96
|
+
const cleared = await clearPin();
|
|
97
|
+
if (!cleared.success) {
|
|
98
|
+
ctx.ui.notify(cleared.error, "error");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
client.setProfilePin(null);
|
|
102
|
+
ctx.ui.notify(
|
|
103
|
+
"Profile selection cleared. New windows will open in whichever browser profile is focused.",
|
|
104
|
+
"info",
|
|
105
|
+
);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const pin = pinFor(loaded.data.userDataDir, choice.profile);
|
|
110
|
+
const saved = await writePin(pin);
|
|
111
|
+
if (!saved.success) {
|
|
112
|
+
ctx.ui.notify(saved.error, "error");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
client.setProfilePin(pin);
|
|
116
|
+
|
|
117
|
+
const applied = await applyToLiveSession(client);
|
|
118
|
+
if (!applied.success) {
|
|
119
|
+
ctx.ui.notify(
|
|
120
|
+
`Saved ${pin.label}, but switching the open session failed: ${applied.error}`,
|
|
121
|
+
"warning",
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
ctx.ui.notify(`Browser profile: ${pin.label} ✓\nSaved — this profile is used until you change it.`, "info");
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
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
|
+
export const promptForProfileIfUnset = async (
|
|
136
|
+
client: BrowserClient,
|
|
137
|
+
ctx: ExtensionContext | undefined,
|
|
138
|
+
): Promise<string | undefined> => {
|
|
139
|
+
if (client.profilePin()) return undefined;
|
|
140
|
+
|
|
141
|
+
const stored = await readPin();
|
|
142
|
+
if (stored) {
|
|
143
|
+
client.setProfilePin(stored);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (!ctx?.hasUI) {
|
|
147
|
+
return "No browser profile selected — using whichever window is focused. Run /browser-profile to pin one.";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const loaded = await loadProfiles(client);
|
|
151
|
+
if (!loaded.success) return loaded.error;
|
|
152
|
+
|
|
153
|
+
const choice = await promptForProfile(
|
|
154
|
+
ctx,
|
|
155
|
+
loaded.data.profiles,
|
|
156
|
+
null,
|
|
157
|
+
"Select the browser profile the agent should use",
|
|
158
|
+
);
|
|
159
|
+
if (choice.kind !== "profile") {
|
|
160
|
+
return "No browser profile selected — using whichever window is focused. Run /browser-profile to pin one.";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pin = pinFor(loaded.data.userDataDir, choice.profile);
|
|
164
|
+
const saved = await writePin(pin);
|
|
165
|
+
if (!saved.success) return saved.error;
|
|
166
|
+
client.setProfilePin(pin);
|
|
167
|
+
return `Browser profile: ${pin.label}`;
|
|
168
|
+
};
|