aboutsummaryrefslogtreecommitdiff
path: root/src/profile
diff options
context:
space:
mode:
Diffstat (limited to 'src/profile')
-rw-r--r--src/profile/bind.ts138
-rw-r--r--src/profile/browser-process.ts371
-rw-r--r--src/profile/command.ts168
-rw-r--r--src/profile/launch.ts90
-rw-r--r--src/profile/list.ts171
-rw-r--r--src/profile/paths.ts166
-rw-r--r--src/profile/picker.ts73
-rw-r--r--src/profile/store.ts105
8 files changed, 1282 insertions, 0 deletions
diff --git a/src/profile/bind.ts b/src/profile/bind.ts
new file mode 100644
index 0000000..aeb59d3
--- /dev/null
+++ b/src/profile/bind.ts
@@ -0,0 +1,138 @@
+/**
+ * Binding the harness session to a pinned profile.
+ *
+ * Sequence, all of it verified against a live browser:
+ * 1. resolve the browser binary and any explicit --user-data-dir
+ * 2. launch a window into the profile, marked with a file:// sentinel
+ * 3. poll Target.getTargets for the sentinel page — its browserContextId IS
+ * the profile's context, which is otherwise undiscoverable (CDP exposes no
+ * profile identity)
+ * 4. adopt that page as the harness window, blank it, drop the sentinel file
+ *
+ * If the sentinel never appears the bind FAILS LOUDLY. Falling through to a
+ * normal createTarget would put the agent in the focus-derived default profile
+ * — the exact bug this feature exists to remove — so the caller gets an
+ * actionable error instead.
+ *
+ * The resolved context id is session-scoped state, never persisted: Chrome
+ * mints new context ids on every browser run.
+ */
+
+import type { BrowserClient } from "../client";
+import { type CdpError, cdpError } from "../cdp/errors";
+import { type Result, err, ok } from "../util/result";
+import { detectRunningBrowser, resolveBrowserExecutable } from "./browser-process";
+import { openProfileWindow } from "./launch";
+import type { ProfilePin } from "./store";
+
+/** The launch delegates through ProcessSingleton, so allow for a cold profile. */
+const SEED_DEADLINE_MS = 15_000;
+const SEED_POLL_MS = 500;
+
+type PageTarget = {
+ readonly targetId: string;
+ readonly type: string;
+ readonly url: string;
+ readonly browserContextId?: string;
+};
+
+const pageTargets = async (client: BrowserClient): Promise<ReadonlyArray<PageTarget>> => {
+ const r = await client.session().callBrowser("Target.getTargets");
+ if (!r.success) return [];
+ const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
+ return data.targetInfos.filter((t) => t.type === "page");
+};
+
+const manualFallbackHint = (pin: ProfilePin): string =>
+ `open a window for "${pin.label}" yourself (browser profile menu → ${pin.profileDir}), then retry. ` +
+ `Run /browser-profile to pick a different profile, or clear the selection to use whichever window is focused.`;
+
+/**
+ * Open (and adopt) a harness window inside the pinned profile. Returns the
+ * seed tab's target id.
+ */
+export const seedProfileWindow = async (
+ client: BrowserClient,
+ pin: ProfilePin,
+): Promise<Result<string, CdpError>> => {
+ // A pin belongs to one user-data-dir. If the harness is now attached to a
+ // different browser, silently seeding the "same" profile name would be wrong.
+ const connectedDir = client.userDataDir();
+ if (connectedDir && connectedDir !== pin.userDataDir) {
+ return err(cdpError(
+ "discovery_failed",
+ `the selected profile "${pin.label}" belongs to ${pin.userDataDir}, but the harness is connected to ${connectedDir}. ` +
+ `Run /browser-profile to choose a profile in the browser you're using.`,
+ ));
+ }
+
+ // Identify the browser by the dir we're connected to. With several Chromium
+ // browsers running, launching into the wrong one would open a window the
+ // harness cannot see — in a browser the user never pointed us at.
+ const targetUserDataDir = connectedDir ?? pin.userDataDir;
+ const running = await detectRunningBrowser(targetUserDataDir);
+ const exePath = await resolveBrowserExecutable(running);
+ if (!exePath) {
+ return err(cdpError(
+ "discovery_failed",
+ `could not locate the browser executable needed to open "${pin.label}" — ${manualFallbackHint(pin)}`,
+ ));
+ }
+
+ const launched = await openProfileWindow({
+ exePath,
+ profileDir: pin.profileDir,
+ // Forward the user-data-dir ONLY when the running browser advertises one.
+ // ProcessSingleton keys on this path: passing the value the target process
+ // itself reports guarantees delegation, whereas supplying a default dir we
+ // reconstructed could differ by case or normalisation and start a SECOND
+ // browser instead.
+ ...(running.explicitUserDataDir ? { explicitUserDataDir: running.explicitUserDataDir } : {}),
+ });
+ if (!launched.success) {
+ return err(cdpError("discovery_failed", `${launched.error} — ${manualFallbackHint(pin)}`));
+ }
+
+ const { sentinelUrl, cleanup } = launched.data;
+ const deadline = Date.now() + SEED_DEADLINE_MS;
+ let seed: PageTarget | undefined;
+ while (Date.now() < deadline && !seed) {
+ await new Promise((r) => setTimeout(r, SEED_POLL_MS));
+ seed = (await pageTargets(client)).find((t) => t.url === sentinelUrl);
+ }
+
+ if (!seed) {
+ await cleanup();
+ return err(cdpError(
+ "timeout",
+ `couldn't open a window in "${pin.label}" automatically — ${manualFallbackHint(pin)}`,
+ ));
+ }
+
+ // The sentinel's context is the profile's context — the only way to learn it.
+ if (seed.browserContextId) client.setProfileContextId(seed.browserContextId);
+
+ const ownership = client.ownership();
+ ownership.setHarnessWindow(seed.targetId);
+ ownership.add(seed.targetId);
+ const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId: seed.targetId });
+ if (win.success) {
+ const windowId = (win.data as { windowId?: number }).windowId;
+ if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
+ }
+
+ // Blank the handshake page so the seed tab behaves like any other fresh tab,
+ // then remove the sentinel file. Best-effort: a failure here costs nothing
+ // beyond the placeholder page staying visible.
+ const attached = await client.session().callBrowser("Target.attachToTarget", {
+ targetId: seed.targetId,
+ flatten: true,
+ });
+ if (attached.success) {
+ const sessionId = (attached.data as { sessionId: string }).sessionId;
+ await client.session().callOnTarget("Page.navigate", { url: "about:blank" }, sessionId);
+ }
+ await cleanup();
+
+ return ok(seed.targetId);
+};
diff --git a/src/profile/browser-process.ts b/src/profile/browser-process.ts
new file mode 100644
index 0000000..c0f6178
--- /dev/null
+++ b/src/profile/browser-process.ts
@@ -0,0 +1,371 @@
+/**
+ * Identifying the browser process the harness is attached to.
+ *
+ * Two questions, deliberately kept apart:
+ *
+ * 1. Is a Chromium-family browser RUNNING? (`detectRunningBrowser().running`)
+ * Only live-process evidence may answer this — an installed-but-closed
+ * browser must read as "not running", which is why the registry lookup
+ * below is never consulted here.
+ *
+ * 2. Which binary do we invoke to open a window for a chosen profile?
+ * (`resolveBrowserExecutable()`) Chromium's ProcessSingleton hands a second
+ * launch's argv to the already-running browser instead of starting another
+ * one — Windows via WM_COPYDATA (process_singleton_win.cc), Linux and macOS
+ * via the socket in the user-data-dir (process_singleton_posix.cc, which is
+ * compiled on macOS too). Here an installed path is a fine fallback.
+ *
+ * Whether the running browser carries an explicit `--user-data-dir` matters:
+ * ProcessSingleton keys on that directory, so passing a path that differs from
+ * the running browser's by case or normalisation would start a SECOND browser
+ * rather than delegating. The flag is forwarded only when the running process
+ * already has one.
+ *
+ * Every probe is best-effort; when nothing can be determined, profile launching
+ * degrades to a clear message rather than guessing.
+ *
+ * Windows note: `wmic` is deliberately unused — Microsoft removed it by default
+ * in Windows 11 24H2 and entirely in 25H2. PowerShell CIM is the supported
+ * replacement, with `tasklist` for liveness and the documented App Paths
+ * registry key for install location.
+ */
+
+import { execFile } from "node:child_process";
+import { access, readFile, readlink } from "node:fs/promises";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import { browserNameForUserDataDir } from "./paths";
+
+// execFile, not exec: arguments are passed as an array, with no shell involved.
+const run = promisify(execFile);
+const EXEC_TIMEOUT_MS = 5_000;
+
+export type RunningBrowser = {
+ /** True when a Chromium-family browser process is live. */
+ readonly running: boolean;
+ /** Absolute path to the running browser's binary, when determinable. */
+ readonly exePath?: string;
+ /** Value of an explicit --user-data-dir on the running process, if any. */
+ readonly explicitUserDataDir?: string;
+};
+
+/** Chromium-family process names, lowercase, as they appear per platform. */
+const LINUX_COMMS: ReadonlyArray<string> = [
+ "chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge",
+ "google-chrome", "brave", "brave-browser",
+];
+const MAC_BROWSER_NAMES: ReadonlyArray<string> = [
+ "google chrome", "chromium", "microsoft edge", "brave browser",
+];
+const WIN_IMAGE_NAMES: ReadonlyArray<string> = ["chrome.exe", "msedge.exe", "brave.exe"];
+
+/**
+ * Chromium tags every child process with `--type=<role>`; only the browser
+ * process omits it or carries `--type=browser`. Matching the flag (rather than
+ * words like "renderer" anywhere in the string) keeps a user whose install path
+ * contains such a word from being filtered out.
+ */
+const hasChildProcessType = (commandLine: string): boolean => {
+ const lower = commandLine.toLowerCase();
+ return lower.includes("--type=") && !lower.includes("--type=browser");
+};
+
+/**
+ * macOS names its child processes in the executable path itself ("Google Chrome
+ * Helper (Renderer)", crashpad, updaters), so the process NAME must be filtered
+ * too — the same exclusions the previous setup.ts check applied.
+ */
+const isMacChildProcessName = (comm: string): boolean => {
+ const lower = comm.toLowerCase();
+ return (
+ lower.includes("helper") ||
+ lower.includes("renderer") ||
+ lower.includes("crashpad") ||
+ lower.includes(" gpu") ||
+ lower.includes("updater")
+ );
+};
+
+/** One running browser process, as much as the platform will tell us. */
+export type BrowserCandidate = {
+ readonly exePath?: string;
+ readonly explicitUserDataDir?: string;
+};
+
+/** Path comparison that tolerates separator and case differences per platform. */
+const samePath = (a: string, b: string): boolean => {
+ const norm = (p: string): string => {
+ const unified = p.replace(/\\/g, "/").replace(/\/+$/, "");
+ return process.platform === "linux" ? unified : unified.toLowerCase();
+ };
+ return norm(a) === norm(b);
+};
+
+/**
+ * Rank a candidate against the user-data-dir the harness is actually connected
+ * to. Several Chromium browsers can run at once (Chrome plus Brave, or a second
+ * instance on a custom dir), and launching a profile window into the wrong one
+ * would open a window the harness cannot see — while disturbing a browser the
+ * user never pointed us at.
+ */
+export const rankBrowserCandidate = (candidate: BrowserCandidate, preferUserDataDir?: string): number => {
+ if (!preferUserDataDir) return 1;
+ if (candidate.explicitUserDataDir) {
+ // An explicit dir is decisive in both directions: exact match wins, and a
+ // different one means this is definitively another browser instance.
+ return samePath(candidate.explicitUserDataDir, preferUserDataDir) ? 4 : 0;
+ }
+ // No flag: the process uses its default dir. Prefer the one whose executable
+ // belongs to the same browser family as the target directory.
+ const family = browserNameForUserDataDir(preferUserDataDir).toLowerCase();
+ const exe = (candidate.exePath ?? "").toLowerCase();
+ if (!exe) return 1;
+ if (family === "brave" && exe.includes("brave")) return 3;
+ if (family === "edge" && (exe.includes("edge") || exe.includes("msedge"))) return 3;
+ if (family === "chromium" && exe.includes("chromium")) return 3;
+ if (family === "chrome" && exe.includes("chrome") && !exe.includes("chromium")) return 3;
+ return 1;
+};
+
+/** Extract `--user-data-dir=<path>` from a command line, quoted or bare. */
+export const parseUserDataDirFlag = (commandLine: string): string | undefined => {
+ const match = /--user-data-dir=(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(commandLine);
+ if (!match) return undefined;
+ const value = match[1] ?? match[2] ?? match[3];
+ return value && value.length > 0 ? value : undefined;
+};
+
+// ── Linux ──────────────────────────────────────────────────────────────────
+
+const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
+ let pids: string[];
+ try {
+ const { stdout } = await run("ps", ["-A", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS });
+ pids = stdout
+ .split("\n")
+ .map((line) => line.trim())
+ .flatMap((line) => {
+ const [pid, comm] = line.split(/\s+/, 2);
+ if (!pid || !comm) return [];
+ return LINUX_COMMS.includes(comm.toLowerCase()) ? [pid] : [];
+ });
+ } catch {
+ return [];
+ }
+
+ const candidates: BrowserCandidate[] = [];
+ for (const pid of pids) {
+ let cmdline: string;
+ try {
+ // /proc/<pid>/cmdline is NUL-separated argv.
+ cmdline = (await readFile(`/proc/${pid}/cmdline`, "utf8")).replace(/\0/g, " ").trim();
+ } catch {
+ continue;
+ }
+ if (!cmdline || hasChildProcessType(cmdline)) continue;
+ let exePath: string | undefined;
+ try {
+ exePath = await readlink(`/proc/${pid}/exe`);
+ } catch {
+ // /proc/<pid>/exe is unreadable for processes we don't own; argv[0] is
+ // the next best thing.
+ exePath = cmdline.split(" ")[0];
+ }
+ const explicit = parseUserDataDirFlag(cmdline);
+ candidates.push({
+ ...(exePath ? { exePath } : {}),
+ ...(explicit ? { explicitUserDataDir: explicit } : {}),
+ });
+ }
+ return candidates;
+};
+
+// ── macOS ──────────────────────────────────────────────────────────────────
+
+const collectDarwin = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
+ let lines: string[];
+ try {
+ // BSD ps prints the full executable path for `comm`, unlike Linux.
+ const { stdout } = await run("ps", ["-A", "-ww", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS });
+ lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
+ } catch {
+ return [];
+ }
+
+ const candidates: BrowserCandidate[] = [];
+ for (const line of lines) {
+ const spaceAt = line.indexOf(" ");
+ if (spaceAt < 0) continue;
+ const pid = line.slice(0, spaceAt);
+ const comm = line.slice(spaceAt + 1).trim();
+ const lower = comm.toLowerCase();
+ if (!MAC_BROWSER_NAMES.some((name) => lower.includes(name))) continue;
+ if (isMacChildProcessName(comm)) continue;
+
+ let args = "";
+ try {
+ const { stdout } = await run("ps", ["-ww", "-o", "args=", "-p", pid], { timeout: EXEC_TIMEOUT_MS });
+ args = stdout.trim();
+ } catch {
+ // args are optional — only the --user-data-dir probe needs them
+ }
+ if (args && hasChildProcessType(args)) continue;
+ const explicit = args ? parseUserDataDirFlag(args) : undefined;
+ candidates.push({
+ exePath: comm,
+ ...(explicit ? { explicitUserDataDir: explicit } : {}),
+ });
+ }
+ return candidates;
+};
+
+// ── Windows ────────────────────────────────────────────────────────────────
+
+const POWERSHELL_QUERY = [
+ "$ErrorActionPreference='SilentlyContinue';",
+ "Get-CimInstance Win32_Process",
+ "-Filter \"Name='chrome.exe' or Name='msedge.exe' or Name='brave.exe'\"",
+ "| Select-Object ExecutablePath,CommandLine",
+ "| ConvertTo-Json -Compress",
+].join(" ");
+
+type WinProcess = { ExecutablePath?: string | null; CommandLine?: string | null };
+
+const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
+ try {
+ const { stdout } = await run(
+ "powershell.exe",
+ ["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_QUERY],
+ { timeout: EXEC_TIMEOUT_MS, windowsHide: true },
+ );
+ const trimmed = stdout.trim();
+ if (trimmed) {
+ const parsed: unknown = JSON.parse(trimmed);
+ // ConvertTo-Json emits an object for a single row, an array for many.
+ const rows: WinProcess[] = Array.isArray(parsed) ? (parsed as WinProcess[]) : [parsed as WinProcess];
+ const candidates = rows
+ .filter((row) => !hasChildProcessType(row.CommandLine ?? ""))
+ .map((row): BrowserCandidate => {
+ const explicit = parseUserDataDirFlag(row.CommandLine ?? "");
+ return {
+ ...(row.ExecutablePath ? { exePath: row.ExecutablePath } : {}),
+ ...(explicit ? { explicitUserDataDir: explicit } : {}),
+ };
+ });
+ if (candidates.length > 0) return candidates;
+ }
+ } catch {
+ // PowerShell missing or blocked by policy — fall through to tasklist.
+ }
+
+ // Liveness-only fallback: tasklist still ships on every supported Windows.
+ // It reveals no path, so the candidate is a bare "something is running".
+ try {
+ const { stdout } = await run("tasklist.exe", [], { timeout: EXEC_TIMEOUT_MS, windowsHide: true });
+ const lower = stdout.toLowerCase();
+ if (WIN_IMAGE_NAMES.some((n) => lower.includes(n))) return [{}];
+ } catch {
+ // best-effort
+ }
+ return [];
+};
+
+/** Installed-browser locations to try when the live process reveals no path. */
+const winInstallCandidates = (): ReadonlyArray<string> => {
+ const roots = [
+ process.env["PROGRAMFILES"],
+ process.env["PROGRAMFILES(X86)"],
+ process.env["LOCALAPPDATA"],
+ ].filter((r): r is string => typeof r === "string" && r.length > 0);
+ const suffixes = [
+ "Google\\Chrome\\Application\\chrome.exe",
+ "Google\\Chrome Beta\\Application\\chrome.exe",
+ "Google\\Chrome SxS\\Application\\chrome.exe",
+ "Chromium\\Application\\chrome.exe",
+ "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
+ "Microsoft\\Edge\\Application\\msedge.exe",
+ ];
+ return roots.flatMap((root) => suffixes.map((suffix) => join(root, suffix)));
+};
+
+/** Query the Shell's App Paths registration for a browser's install location. */
+const winRegistryExecutable = async (): Promise<string | undefined> => {
+ for (const image of WIN_IMAGE_NAMES) {
+ for (const hive of ["HKLM", "HKCU"]) {
+ try {
+ const { stdout } = await run(
+ "reg.exe",
+ ["query", `${hive}\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${image}`, "/ve"],
+ { timeout: EXEC_TIMEOUT_MS, windowsHide: true },
+ );
+ const match = /REG_[A-Z_]+\s+(.+)$/m.exec(stdout.trim());
+ const path = match?.[1]?.trim();
+ if (path) return path;
+ } catch {
+ // key absent for this browser/hive — try the next
+ }
+ }
+ }
+ return undefined;
+};
+
+// ── Public API ─────────────────────────────────────────────────────────────
+
+/**
+ * Live-process facts about the running Chromium-family browser. `running` is
+ * derived only from process evidence, never from an install location.
+ *
+ * `preferUserDataDir` should be the dir the harness is connected to; with more
+ * than one browser running it decides which process is described.
+ */
+export const detectRunningBrowser = async (preferUserDataDir?: string): Promise<RunningBrowser> => {
+ const candidates =
+ process.platform === "darwin"
+ ? await collectDarwin()
+ : process.platform === "win32"
+ ? await collectWin32()
+ : await collectLinux();
+ if (candidates.length === 0) return { running: false };
+
+ let best = candidates[0] as BrowserCandidate;
+ let bestRank = rankBrowserCandidate(best, preferUserDataDir);
+ for (const candidate of candidates.slice(1)) {
+ const rank = rankBrowserCandidate(candidate, preferUserDataDir);
+ if (rank > bestRank) {
+ best = candidate;
+ bestRank = rank;
+ }
+ }
+
+ return {
+ running: true,
+ ...(best.exePath ? { exePath: best.exePath } : {}),
+ ...(best.explicitUserDataDir ? { explicitUserDataDir: best.explicitUserDataDir } : {}),
+ };
+};
+
+/** True when a Chromium-family browser process is running. */
+export const isBrowserRunning = async (): Promise<boolean> => (await detectRunningBrowser()).running;
+
+/**
+ * The binary to invoke when opening a window for a chosen profile: the running
+ * browser's own executable when known, otherwise a documented install location.
+ * Undefined when nothing usable is found.
+ */
+export const resolveBrowserExecutable = async (running?: RunningBrowser): Promise<string | undefined> => {
+ const detected = running ?? (await detectRunningBrowser());
+ if (detected.exePath) return detected.exePath;
+ if (process.platform !== "win32") return undefined;
+
+ const fromRegistry = await winRegistryExecutable();
+ if (fromRegistry) return fromRegistry;
+ for (const candidate of winInstallCandidates()) {
+ try {
+ await access(candidate);
+ return candidate;
+ } catch {
+ // not installed here
+ }
+ }
+ return undefined;
+};
diff --git a/src/profile/command.ts b/src/profile/command.ts
new file mode 100644
index 0000000..a49e35d
--- /dev/null
+++ b/src/profile/command.ts
@@ -0,0 +1,168 @@
+/**
+ * /browser-profile — choose which browser profile the agent works in.
+ *
+ * The selection is durable (see store.ts) and applies immediately: the current
+ * harness tabs are closed and a new window is opened in the chosen profile, so
+ * the agent's next action happens where the user just asked for it rather than
+ * one session later.
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { BrowserClient } from "../client";
+import { discoverEndpoint } from "../cdp/discovery";
+import { type Result, err, ok } from "../util/result";
+import { type BrowserProfile, listProfiles } from "./list";
+import { browserNameForUserDataDir, userDataDirCandidates } from "./paths";
+import { pinFor, promptForProfile } from "./picker";
+import { clearPin, readPin, writePin } from "./store";
+
+/**
+ * Which user-data-dir the picker should enumerate: the connected browser's
+ * when known, otherwise a running browser's, otherwise the only installed one
+ * that actually has profiles.
+ */
+export const resolveUserDataDir = async (client: BrowserClient): Promise<Result<string, string>> => {
+ const connected = client.userDataDir();
+ if (connected) return ok(connected);
+
+ const endpoint = await discoverEndpoint();
+ if (endpoint.success && endpoint.data.userDataDir) return ok(endpoint.data.userDataDir);
+
+ const withProfiles: string[] = [];
+ for (const dir of userDataDirCandidates()) {
+ if ((await listProfiles(dir)).length > 0) withProfiles.push(dir);
+ }
+ const only = withProfiles[0];
+ if (withProfiles.length === 1 && only !== undefined) return ok(only);
+ if (withProfiles.length === 0) {
+ return err("No browser profiles found on this machine. Open Chrome, Brave, or Edge and try again.");
+ }
+ return err(
+ `Found profiles for more than one browser (${withProfiles.map(browserNameForUserDataDir).join(", ")}). ` +
+ "Open the browser you want the agent to use, then run /browser-profile again.",
+ );
+};
+
+/** Enumerate profiles for the resolved dir, or explain why we cannot. */
+const loadProfiles = async (
+ client: BrowserClient,
+): Promise<Result<{ userDataDir: string; profiles: ReadonlyArray<BrowserProfile> }, string>> => {
+ const dir = await resolveUserDataDir(client);
+ if (!dir.success) return dir;
+ const profiles = await listProfiles(dir.data);
+ if (profiles.length === 0) {
+ return err(`No profiles found in ${dir.data}. If the browser was just installed, open it once and retry.`);
+ }
+ return ok({ userDataDir: dir.data, profiles });
+};
+
+/**
+ * Re-seed the live session so the new profile takes effect now. Closing the
+ * owned tabs and restarting the client makes start() open a window in the
+ * pinned profile and attach to it.
+ */
+const applyToLiveSession = async (client: BrowserClient): Promise<Result<void, string>> => {
+ if (!client.status().alive) return ok(undefined);
+ await client.closeOwnedTabs();
+ await client.stop();
+ const started = await client.start();
+ if (!started.success) return err(started.error.message);
+ return ok(undefined);
+};
+
+export function registerProfileCommand(pi: ExtensionAPI, client: BrowserClient): void {
+ pi.registerCommand("browser-profile", {
+ description: "Choose which browser profile the agent uses",
+ handler: async (_args, ctx) => {
+ const loaded = await loadProfiles(client);
+ if (!loaded.success) {
+ ctx.ui.notify(loaded.error, "error");
+ return;
+ }
+ if (!ctx.hasUI) {
+ ctx.ui.notify("Profile selection needs an interactive terminal.", "warning");
+ return;
+ }
+
+ const currentPin = client.profilePin() ?? (await readPin());
+ const choice = await promptForProfile(ctx, loaded.data.profiles, currentPin);
+
+ if (choice.kind === "cancelled") {
+ ctx.ui.notify("Profile unchanged.", "info");
+ return;
+ }
+
+ if (choice.kind === "clear") {
+ const cleared = await clearPin();
+ if (!cleared.success) {
+ ctx.ui.notify(cleared.error, "error");
+ return;
+ }
+ client.setProfilePin(null);
+ ctx.ui.notify(
+ "Profile selection cleared. New windows will open in whichever browser profile is focused.",
+ "info",
+ );
+ return;
+ }
+
+ const pin = pinFor(loaded.data.userDataDir, choice.profile);
+ const saved = await writePin(pin);
+ if (!saved.success) {
+ ctx.ui.notify(saved.error, "error");
+ return;
+ }
+ client.setProfilePin(pin);
+
+ const applied = await applyToLiveSession(client);
+ if (!applied.success) {
+ ctx.ui.notify(
+ `Saved ${pin.label}, but switching the open session failed: ${applied.error}`,
+ "warning",
+ );
+ return;
+ }
+ ctx.ui.notify(`Browser profile: ${pin.label} ✓\nSaved — this profile is used until you change it.`, "info");
+ },
+ });
+}
+
+/**
+ * First-run prompt used by setup: pick a profile when none is pinned yet.
+ * Returns a note to append to the setup output; the caller always continues,
+ * because a cancelled or unavailable picker must leave existing behavior intact.
+ */
+export const promptForProfileIfUnset = async (
+ client: BrowserClient,
+ ctx: ExtensionContext | undefined,
+): Promise<string | undefined> => {
+ if (client.profilePin()) return undefined;
+
+ const stored = await readPin();
+ if (stored) {
+ client.setProfilePin(stored);
+ return undefined;
+ }
+ if (!ctx?.hasUI) {
+ return "No browser profile selected — using whichever window is focused. Run /browser-profile to pin one.";
+ }
+
+ const loaded = await loadProfiles(client);
+ if (!loaded.success) return loaded.error;
+
+ const choice = await promptForProfile(
+ ctx,
+ loaded.data.profiles,
+ null,
+ "Select the browser profile the agent should use",
+ );
+ if (choice.kind !== "profile") {
+ return "No browser profile selected — using whichever window is focused. Run /browser-profile to pin one.";
+ }
+
+ const pin = pinFor(loaded.data.userDataDir, choice.profile);
+ const saved = await writePin(pin);
+ if (!saved.success) return saved.error;
+ client.setProfilePin(pin);
+ return `Browser profile: ${pin.label}`;
+};
diff --git a/src/profile/launch.ts b/src/profile/launch.ts
new file mode 100644
index 0000000..4f8f65a
--- /dev/null
+++ b/src/profile/launch.ts
@@ -0,0 +1,90 @@
+/**
+ * Opening a browser window for a chosen profile.
+ *
+ * CDP cannot do this: `Target.createTarget` rejects another profile's
+ * browserContextId ("Failed to find browser context with id"), and a bare
+ * createTarget lands in `defaultBrowserContextId`, which follows window focus.
+ * The only way in is the browser's own command line — Chromium's
+ * ProcessSingleton hands a second launch's argv to the running browser, which
+ * "does what it would have done", i.e. opens a window in `--profile-directory`.
+ *
+ * The window is then identified by a unique `file://` sentinel page. That
+ * matters: a bare `about:blank#token` argument is discarded by Chrome (it opens
+ * chrome://newtab instead), so the token has to ride on a real URL. Polling for
+ * the sentinel URL — rather than diffing the target list — also keeps
+ * identification correct when the user opens tabs at the same moment.
+ */
+
+import { spawn } from "node:child_process";
+import { randomUUID } from "node:crypto";
+import { rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { pathToFileURL } from "node:url";
+import { type Result, err, ok } from "../util/result";
+
+export type ProfileWindowRequest = {
+ /** Browser binary to invoke. */
+ readonly exePath: string;
+ /** Profile subdirectory, e.g. "Profile 1". */
+ readonly profileDir: string;
+ /**
+ * Only set when the RUNNING browser was launched with an explicit
+ * --user-data-dir. ProcessSingleton keys on this path, so passing one that
+ * differs by case or normalisation would start a second browser instead of
+ * delegating to the running one.
+ */
+ readonly explicitUserDataDir?: string;
+};
+
+export type ProfileWindowHandle = {
+ /** file:// URL that will appear as the new window's page target. */
+ readonly sentinelUrl: string;
+ /** Remove the sentinel file. Safe to call more than once. */
+ readonly cleanup: () => Promise<void>;
+};
+
+const SENTINEL_HTML = (token: string): string =>
+ `<!doctype html><meta charset="utf-8"><title>${token}</title>` +
+ `<body style="font:14px system-ui;padding:2rem;color:#555">pi browser harness — opening this profile…</body>`;
+
+/**
+ * Ask the running browser to open a window in `profileDir`, marked with a
+ * unique sentinel page the caller can then find over CDP.
+ */
+export const openProfileWindow = async (
+ req: ProfileWindowRequest,
+): Promise<Result<ProfileWindowHandle, string>> => {
+ const token = `pi-harness-${randomUUID()}`;
+ const file = join(tmpdir(), `${token}.html`);
+ try {
+ await writeFile(file, SENTINEL_HTML(token), "utf8");
+ } catch (e) {
+ return err(`could not write the profile handshake page: ${e instanceof Error ? e.message : String(e)}`);
+ }
+
+ const sentinelUrl = pathToFileURL(file).href;
+ const args: string[] = [];
+ if (req.explicitUserDataDir) args.push(`--user-data-dir=${req.explicitUserDataDir}`);
+ args.push(`--profile-directory=${req.profileDir}`, "--new-window", sentinelUrl);
+
+ try {
+ // No shell: arguments are passed as an array, so profile names and paths
+ // containing spaces survive intact on every platform.
+ const child = spawn(req.exePath, args, { detached: true, stdio: "ignore" });
+ child.unref();
+ // The launcher process exits as soon as the running browser takes over the
+ // command line; an error here means the binary itself could not be started.
+ child.on("error", () => {});
+ } catch (e) {
+ await rm(file, { force: true }).catch(() => {});
+ return err(`could not launch ${req.exePath}: ${e instanceof Error ? e.message : String(e)}`);
+ }
+
+ return ok({
+ sentinelUrl,
+ cleanup: async () => {
+ await rm(file, { force: true }).catch(() => {});
+ },
+ });
+};
diff --git a/src/profile/list.ts b/src/profile/list.ts
new file mode 100644
index 0000000..5a83654
--- /dev/null
+++ b/src/profile/list.ts
@@ -0,0 +1,171 @@
+/**
+ * Browser profile enumeration.
+ *
+ * A Chromium user-data-dir registers its profiles in `Local State` under the
+ * JSON key `profile.info_cache`, with `profile.profiles_order` giving the
+ * user's own ordering and `profile.last_used` naming the active one. Those
+ * keys are declared in Chromium's pref_names.h with no platform BUILDFLAG, so
+ * the layout is identical on Linux, macOS, and Windows (the C++ constant is
+ * now `kProfileAttributes`; the on-disk key never changed).
+ *
+ * Enumeration is two-tier so a Chromium fork or a future schema change cannot
+ * blank the list:
+ * tier 1 — `Local State` → `profile.info_cache` (authoritative: display
+ * names, accounts, ordering)
+ * tier 2 — scan subdirectories holding a `Preferences` file and read
+ * `profile.name` + `account_info[0].email`
+ *
+ * Nothing here throws: an unreadable or malformed file yields an empty list so
+ * callers degrade to the harness's pre-existing behavior.
+ */
+
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+
+export type BrowserProfile = {
+ /** Profile subdirectory name, e.g. "Default" or "Profile 1". */
+ readonly dir: string;
+ /** Display name as shown in the browser's profile menu. */
+ readonly name: string;
+ /** Signed-in account address, when the profile has one. */
+ readonly email?: string;
+ /** Picker label — "Name (email)", or "Name (dir)" with no account. */
+ readonly label: string;
+ /** True for the profile the browser reports as last used. */
+ readonly lastUsed: boolean;
+};
+
+/** Directories inside a user-data-dir that are not user profiles. */
+const NON_PROFILE_DIRS: ReadonlySet<string> = new Set([
+ "System Profile",
+ "Guest Profile",
+]);
+
+/**
+ * Picker label. The bracketed half is the account address when there is one,
+ * and the profile directory otherwise, so every row keeps the same shape.
+ */
+export const formatProfileLabel = (name: string, email: string | undefined, dir: string): string =>
+ `${name} (${email && email.length > 0 ? email : dir})`;
+
+const readJsonFile = async (path: string): Promise<Record<string, unknown> | null> => {
+ try {
+ const raw = await readFile(path, "utf8");
+ const parsed: unknown = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
+ return parsed as Record<string, unknown>;
+ } catch {
+ // Missing file, permission error, or malformed JSON — the caller falls
+ // back to the next tier, and ultimately to an empty list.
+ return null;
+ }
+};
+
+const asRecord = (v: unknown): Record<string, unknown> | null =>
+ typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
+
+const asString = (v: unknown): string | undefined =>
+ typeof v === "string" && v.length > 0 ? v : undefined;
+
+/**
+ * Order profiles the way the browser does: `profiles_order` first (in its own
+ * sequence), then anything unlisted, alphabetically.
+ */
+const orderDirs = (dirs: ReadonlyArray<string>, order: ReadonlyArray<string>): ReadonlyArray<string> => {
+ const known = order.filter((d) => dirs.includes(d));
+ const rest = dirs.filter((d) => !known.includes(d)).sort((a, b) => a.localeCompare(b));
+ return [...known, ...rest];
+};
+
+const buildProfile = (
+ dir: string,
+ name: string | undefined,
+ email: string | undefined,
+ lastUsed: boolean,
+): BrowserProfile => {
+ const displayName = name ?? dir;
+ return {
+ dir,
+ name: displayName,
+ ...(email !== undefined ? { email } : {}),
+ label: formatProfileLabel(displayName, email, dir),
+ lastUsed,
+ };
+};
+
+/** Tier 1 — `Local State` → `profile.info_cache`. */
+const listFromLocalState = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
+ const localState = await readJsonFile(join(userDataDir, "Local State"));
+ if (!localState) return [];
+ const profileNode = asRecord(localState["profile"]);
+ if (!profileNode) return [];
+ const infoCache = asRecord(profileNode["info_cache"]);
+ if (!infoCache) return [];
+
+ const rawOrder = profileNode["profiles_order"];
+ const order = Array.isArray(rawOrder) ? rawOrder.filter((v): v is string => typeof v === "string") : [];
+ const lastUsed = asString(profileNode["last_used"]);
+
+ const entries: Array<{ dir: string; entry: Record<string, unknown> }> = [];
+ for (const [dir, value] of Object.entries(infoCache)) {
+ if (NON_PROFILE_DIRS.has(dir)) continue;
+ const entry = asRecord(value);
+ if (!entry) continue;
+ // Ephemeral (guest-like) profiles vanish when their window closes — never
+ // offer one as a durable pin.
+ if (entry["is_ephemeral"] === true) continue;
+ entries.push({ dir, entry });
+ }
+
+ const ordered = orderDirs(entries.map((e) => e.dir), order);
+ const byDir = new Map(entries.map((e) => [e.dir, e.entry]));
+
+ return ordered.flatMap((dir) => {
+ const entry = byDir.get(dir);
+ if (!entry) return [];
+ // `name` is the user-visible profile name; `gaia_name` is the Google
+ // account's own display name and is the better fallback when a profile
+ // still carries an auto-generated name like "Person 1".
+ const name = asString(entry["name"]) ?? asString(entry["gaia_name"]);
+ const email = asString(entry["user_name"]);
+ return [buildProfile(dir, name, email, dir === lastUsed)];
+ });
+};
+
+/** Tier 2 — directory scan, reading each profile's own `Preferences`. */
+const listFromPreferencesScan = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
+ let dirents;
+ try {
+ dirents = await readdir(userDataDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ const candidates = dirents
+ .filter((d) => d.isDirectory() && !NON_PROFILE_DIRS.has(d.name))
+ .map((d) => d.name)
+ .sort((a, b) => a.localeCompare(b));
+
+ const profiles: BrowserProfile[] = [];
+ for (const dir of candidates) {
+ const prefs = await readJsonFile(join(userDataDir, dir, "Preferences"));
+ if (!prefs) continue; // not a profile directory
+ const profileNode = asRecord(prefs["profile"]);
+ const name = profileNode ? asString(profileNode["name"]) : undefined;
+ const accounts = prefs["account_info"];
+ const firstAccount = Array.isArray(accounts) ? asRecord(accounts[0]) : null;
+ const email = firstAccount ? asString(firstAccount["email"]) : undefined;
+ profiles.push(buildProfile(dir, name, email, false));
+ }
+ return profiles;
+};
+
+/**
+ * Every selectable profile in a user-data-dir, best-effort. Returns an empty
+ * array when the directory is unreadable or holds no profiles.
+ */
+export const listProfiles = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
+ const fromLocalState = await listFromLocalState(userDataDir);
+ if (fromLocalState.length > 0) return fromLocalState;
+ return listFromPreferencesScan(userDataDir);
+};
diff --git a/src/profile/paths.ts b/src/profile/paths.ts
new file mode 100644
index 0000000..095687e
--- /dev/null
+++ b/src/profile/paths.ts
@@ -0,0 +1,166 @@
+/**
+ * Filesystem locations for browser profile discovery and pin persistence.
+ *
+ * Two independent path families live here:
+ *
+ * 1. Chromium user-data-dir candidates, per platform and channel. A
+ * user-data-dir holds `Local State` (the profile registry) and
+ * `DevToolsActivePort`; individual profiles are subdirectories of it
+ * (`Default`, `Profile 1`, …).
+ * Reference: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/user_data_dir.md
+ *
+ * 2. pi's agent config dir, where the profile pin is stored.
+ *
+ * On the agent dir: pi exports `getAgentDir()` from
+ * `@mariozechner/pi-coding-agent`, but importing it would be a RUNTIME import
+ * of the host package. Extensions installed via `pi install npm:...` do not
+ * get pi resolvable from their own node_modules (verified: the installed
+ * tree's `@mariozechner/` directory is empty), which is why every other import
+ * in this codebase is `import type` and erased at build time. So the three
+ * lines of `getAgentDir()` are replicated here instead — env var first, then
+ * `~/.pi/agent` (pi's dist/config.js: `PI_CODING_AGENT_DIR`, CONFIG_DIR_NAME
+ * `.pi`).
+ */
+
+import { homedir } from "node:os";
+import { join } from "node:path";
+
+// ── pi agent config dir ────────────────────────────────────────────────────
+
+/** pi's env override for the agent dir (APP_NAME.toUpperCase() + "_CODING_AGENT_DIR"). */
+const ENV_AGENT_DIR = "PI_CODING_AGENT_DIR";
+
+/**
+ * Expand a leading `~` exactly as pi's expandTildePath does — `~` and `~/…`
+ * only, never `~\…`, on every platform. This deliberately mirrors pi rather
+ * than improving on it: the pin has to land in the directory pi itself resolves
+ * from the same variable, so being more lenient here would put the file
+ * somewhere pi does not look.
+ */
+const expandTildePiCompatible = (path: string): string => {
+ if (path === "~") return homedir();
+ if (path.startsWith("~/")) return homedir() + path.slice(1);
+ return path;
+};
+
+/**
+ * Tilde expansion for values this package interprets on its own. Windows users
+ * write `~\dir` as naturally as `~/dir`, and no compatibility constraint
+ * applies, so both are accepted.
+ */
+const expandTildeLenient = (path: string): string => {
+ if (path === "~") return homedir();
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
+ return path;
+};
+
+/** pi's agent config directory — `$PI_CODING_AGENT_DIR` or `~/.pi/agent`. */
+export const agentDir = (): string => {
+ const fromEnv = process.env[ENV_AGENT_DIR];
+ if (fromEnv) return expandTildePiCompatible(fromEnv);
+ return join(homedir(), ".pi", "agent");
+};
+
+/** Where the selected-profile pin is persisted. */
+export const pinFilePath = (): string => join(agentDir(), "browser-harness.json");
+
+// ── Chromium user-data-dir candidates ──────────────────────────────────────
+
+/**
+ * Windows resolves its per-user data root from %LOCALAPPDATA%, which can be
+ * redirected on roaming / managed accounts. Fall back to the conventional
+ * layout only when the variable is absent.
+ */
+const localAppData = (): string => process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local");
+
+/**
+ * Every known default user-data-dir for Chromium-family browsers on the
+ * current platform. Order is stable but carries no priority — callers probe
+ * all of them.
+ *
+ * Includes paths the harness previously missed: snap Chromium (the default
+ * Chromium on Ubuntu) and the Linux-only `$CHROME_USER_DATA_DIR` override
+ * documented in user_data_dir.md.
+ */
+export const userDataDirCandidates = (): ReadonlyArray<string> => {
+ const home = homedir();
+ const dirs: string[] = [];
+
+ // A user-set override wins a spot at the front regardless of platform.
+ const envOverride = process.env["CHROME_USER_DATA_DIR"];
+ if (envOverride) dirs.push(expandTildeLenient(envOverride));
+
+ if (process.platform === "darwin") {
+ const support = join(home, "Library", "Application Support");
+ dirs.push(
+ join(support, "Google/Chrome"),
+ join(support, "Google/Chrome Beta"),
+ join(support, "Google/Chrome Dev"),
+ join(support, "Google/Chrome Canary"),
+ join(support, "Chromium"),
+ join(support, "BraveSoftware/Brave-Browser"),
+ join(support, "BraveSoftware/Brave-Browser-Beta"),
+ join(support, "BraveSoftware/Brave-Browser-Nightly"),
+ join(support, "BraveSoftware/Brave-Browser-Dev"),
+ join(support, "Microsoft Edge"),
+ join(support, "Microsoft Edge Beta"),
+ join(support, "Microsoft Edge Dev"),
+ join(support, "Microsoft Edge Canary"),
+ );
+ } else if (process.platform === "win32") {
+ const lad = localAppData();
+ dirs.push(
+ join(lad, "Google/Chrome/User Data"),
+ join(lad, "Google/Chrome Beta/User Data"),
+ join(lad, "Google/Chrome Dev/User Data"),
+ join(lad, "Google/Chrome SxS/User Data"),
+ join(lad, "Chromium/User Data"),
+ join(lad, "BraveSoftware/Brave-Browser/User Data"),
+ join(lad, "BraveSoftware/Brave-Browser-Beta/User Data"),
+ join(lad, "Microsoft/Edge/User Data"),
+ join(lad, "Microsoft/Edge Beta/User Data"),
+ join(lad, "Microsoft/Edge Dev/User Data"),
+ join(lad, "Microsoft/Edge SxS/User Data"),
+ );
+ } else {
+ dirs.push(
+ join(home, ".config/google-chrome"),
+ join(home, ".config/google-chrome-beta"),
+ join(home, ".config/google-chrome-unstable"),
+ join(home, ".config/chromium"),
+ join(home, ".config/chromium-browser"),
+ join(home, ".config/BraveSoftware/Brave-Browser"),
+ join(home, ".config/BraveSoftware/Brave-Browser-Beta"),
+ join(home, ".config/BraveSoftware/Brave-Browser-Nightly"),
+ join(home, ".config/microsoft-edge"),
+ join(home, ".config/microsoft-edge-beta"),
+ join(home, ".config/microsoft-edge-dev"),
+ // snap (Ubuntu's default Chromium) — confined to ~/snap/<pkg>/common
+ join(home, "snap/chromium/common/chromium"),
+ // flatpak
+ join(home, ".var/app/org.chromium.Chromium/config/chromium"),
+ join(home, ".var/app/com.google.Chrome/config/google-chrome"),
+ join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
+ join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
+ );
+ }
+
+ // De-duplicate while preserving order — an env override may repeat a default.
+ return Array.from(new Set(dirs));
+};
+
+/**
+ * Human-readable browser name for a user-data-dir, used to disambiguate the
+ * picker when more than one browser is installed. Falls back to the directory
+ * name, which is already descriptive for every path we generate.
+ */
+export const browserNameForUserDataDir = (userDataDir: string): string => {
+ const p = userDataDir.replace(/\\/g, "/");
+ const has = (needle: string): boolean => p.toLowerCase().includes(needle);
+ if (has("brave")) return "Brave";
+ if (has("edge")) return "Edge";
+ if (has("chromium")) return "Chromium";
+ if (has("chrome")) return "Chrome";
+ const last = p.split("/").filter(Boolean).pop();
+ return last ?? userDataDir;
+};
diff --git a/src/profile/picker.ts b/src/profile/picker.ts
new file mode 100644
index 0000000..b4df059
--- /dev/null
+++ b/src/profile/picker.ts
@@ -0,0 +1,73 @@
+/**
+ * The profile picker, shared by /browser-profile and first-run setup so both
+ * show the user exactly the same list.
+ *
+ * Rendering rules:
+ * - one row per profile, labelled "Name (email)" — or "Name (Profile 3)" when
+ * the profile has no signed-in account, so every row keeps its shape
+ * - the pinned profile is marked with a check
+ * - a final row clears the selection, since a pin must be undoable
+ *
+ * `ctx.ui.select` resolves to undefined when the user presses Escape AND in
+ * non-interactive modes (pi's print/RPC UI context returns undefined for every
+ * dialog), so callers must treat "no answer" as "change nothing".
+ */
+
+import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { BrowserProfile } from "./list";
+import type { ProfilePin } from "./store";
+
+export const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —";
+
+export type ProfileChoice =
+ | { readonly kind: "profile"; readonly profile: BrowserProfile }
+ | { readonly kind: "clear" }
+ | { readonly kind: "cancelled" };
+
+/**
+ * Two profiles can legitimately share a name and account (the same person
+ * signed into two profiles). Labels must stay distinct or the selection cannot
+ * be mapped back, so a repeated label gains its directory.
+ */
+const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => {
+ const counts = new Map<string, number>();
+ for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1);
+ return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label));
+};
+
+/**
+ * Show the picker and report what the user chose. Returns "cancelled" when
+ * there is no UI or the user dismissed the dialog.
+ */
+export const promptForProfile = async (
+ ctx: ExtensionContext,
+ profiles: ReadonlyArray<BrowserProfile>,
+ currentPin: ProfilePin | null,
+ title = "Select browser profile",
+): Promise<ProfileChoice> => {
+ if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" };
+
+ const labels = disambiguate(profiles);
+ const rows = labels.map((label, i) => {
+ const isPinned = currentPin?.profileDir === profiles[i]?.dir;
+ return isPinned ? `✓ ${label}` : ` ${label}`;
+ });
+ const options = [...rows, CLEAR_SELECTION_LABEL];
+
+ const answer = await ctx.ui.select(title, options);
+ if (answer === undefined) return { kind: "cancelled" };
+ if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" };
+
+ const index = options.indexOf(answer);
+ const profile = index >= 0 ? profiles[index] : undefined;
+ if (!profile) return { kind: "cancelled" };
+ return { kind: "profile", profile };
+};
+
+/** Build the durable pin for a chosen profile. */
+export const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({
+ userDataDir,
+ profileDir: profile.dir,
+ label: profile.label,
+ savedAt: new Date().toISOString(),
+});
diff --git a/src/profile/store.ts b/src/profile/store.ts
new file mode 100644
index 0000000..c8b8952
--- /dev/null
+++ b/src/profile/store.ts
@@ -0,0 +1,105 @@
+/**
+ * Durable persistence for the selected browser profile.
+ *
+ * The pin must outlive session termination, so it cannot live in pi's session
+ * entries (`pi.appendEntry`, see src/state.ts) — those die with the session.
+ * It is a small JSON file in pi's agent config dir instead, shared by every
+ * project on the machine.
+ *
+ * The pin deliberately stores only `{ userDataDir, profileDir }`. A
+ * `browserContextId` is NOT persisted: Chrome mints fresh context ids on every
+ * browser run, so a stored one would be a stale pointer at best and a pointer
+ * into the wrong profile at worst. The context is re-resolved on each connect.
+ *
+ * Every read failure degrades to "no pin" rather than throwing — a corrupt
+ * file must never break browser control.
+ */
+
+import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
+import { randomUUID } from "node:crypto";
+import { dirname } from "node:path";
+import { type Result, err, ok } from "../util/result";
+import { pinFilePath } from "./paths";
+
+export type ProfilePin = {
+ /** User-data-dir the profile belongs to — guards against browser switches. */
+ readonly userDataDir: string;
+ /** Profile subdirectory, e.g. "Profile 1". */
+ readonly profileDir: string;
+ /** Label captured at selection time, for display without a disk re-read. */
+ readonly label: string;
+ /** ISO timestamp of the selection. */
+ readonly savedAt: string;
+};
+
+type PinFile = {
+ readonly version: 1;
+ readonly profile: ProfilePin | null;
+};
+
+const CURRENT_VERSION = 1;
+
+const parsePin = (value: unknown): ProfilePin | null => {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
+ const o = value as Record<string, unknown>;
+ const userDataDir = o["userDataDir"];
+ const profileDir = o["profileDir"];
+ const label = o["label"];
+ const savedAt = o["savedAt"];
+ if (typeof userDataDir !== "string" || userDataDir.length === 0) return null;
+ if (typeof profileDir !== "string" || profileDir.length === 0) return null;
+ return {
+ userDataDir,
+ profileDir,
+ label: typeof label === "string" && label.length > 0 ? label : profileDir,
+ savedAt: typeof savedAt === "string" ? savedAt : "",
+ };
+};
+
+/**
+ * The persisted pin, or null when nothing is pinned, the file is missing, or
+ * it cannot be parsed. A file written by a newer version is treated as "no
+ * pin" rather than guessed at.
+ */
+export const readPin = async (): Promise<ProfilePin | null> => {
+ let raw: string;
+ try {
+ raw = await readFile(pinFilePath(), "utf8");
+ } catch {
+ return null;
+ }
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const file = parsed as Record<string, unknown>;
+ if (file["version"] !== CURRENT_VERSION) return null;
+ return parsePin(file["profile"]);
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Replace the pin atomically: a sibling temp file plus rename, so a crash
+ * mid-write can never leave a half-written pin behind.
+ */
+const writePinFile = async (profile: ProfilePin | null): Promise<Result<void, string>> => {
+ const target = pinFilePath();
+ const tmp = `${target}.${randomUUID()}.tmp`;
+ const payload: PinFile = { version: CURRENT_VERSION, profile };
+ try {
+ await mkdir(dirname(target), { recursive: true });
+ await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
+ await rename(tmp, target);
+ return ok(undefined);
+ } catch (e) {
+ await unlink(tmp).catch(() => {});
+ return err(`could not save profile selection to ${target}: ${e instanceof Error ? e.message : String(e)}`);
+ }
+};
+
+/** Persist the selected profile. */
+export const writePin = (pin: ProfilePin): Promise<Result<void, string>> => writePinFile(pin);
+
+/** Clear the selection, restoring the harness's unpinned behavior. */
+export const clearPin = (): Promise<Result<void, string>> => writePinFile(null);