aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-07-25 00:23:05 +0530
committerGitHub <noreply@github.com>2026-07-25 00:23:05 +0530
commit6dd79f7c3bafacba54acb2840f8655fcb05318bf (patch)
tree1570768785ae836717d3dbb2ff42fe0a13f19585 /src
parent0fc1ece6fa4a2f24e87eb4a66eb828a9928e30f7 (diff)
feat: pin the agent to a chosen browser profile (#16)
* feat: pin the agent to a chosen browser profile (#15) Harness tabs were created with a bare Target.createTarget, which places them in Chrome's defaultBrowserContextId — and that value follows window focus. Which profile the agent acted as therefore depended on which browser window the user last clicked, so the same task could run as a work account on one run and a personal account on the next. /browser-profile lists the connected browser's profiles as "Name (email)" and pins one to ~/.pi/agent/browser-harness.json, so the choice outlives the session. The same picker appears on first browser_setup when nothing is pinned; with no interactive UI, or on cancel, setup continues with a note and the previous behavior. Chrome offers no direct route to another profile: Target.createTarget rejects a foreign browserContextId, and openerId does not inherit the opener's context. The window is opened through the browser's own command line (--profile-directory, handed to the running browser by Chromium's ProcessSingleton) and identified by a unique file:// sentinel page, since a bare about:blank#token argument is discarded. Further tabs come from window.open evaluated with userGesture: true — the only CDP-reachable way to place a tab in a non-default browser context — correlated by a unique token so concurrent isolated tabs stay distinct. A failed handshake is reported, never silently redirected to another profile. All four tab-creation sites now funnel through cdp/target-factory, so open_urls and the isolated tabs behind web_search / read_page cannot run with a different profile's cookies than the visible tabs. Also fixed along the way: snap Chromium is discovered, Windows uses %LOCALAPPDATA%, browsers launched with an explicit --user-data-dir are found, the running browser is chosen by ranking candidates against the connected user-data-dir rather than taking the first process found, and an installed-but-closed browser no longer reads as running. CI runs the profile suites and a real-browser end-to-end test on ubuntu, macOS, and Windows — profile discovery is the one part of the harness whose behavior genuinely differs per OS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: single-flight harness window creation Isolated tabs run concurrently (browser_read_page is registered unserialized), so several could each find "no harness window" and create one — two blank windows before this feature, two LAUNCHED profile windows after it. ensureHarnessWindow now shares one in-flight creation per client. Sharing introduces a second hazard: freshlyCreated is a claim on the seed tab, and if every waiter received it, they would all drive the same tab. Only the caller that started the creation keeps the claim; the rest open their own tab beside it. Covered by test/manual/profile-concurrency-test.ts (five concurrent isolated tabs, pinned and unpinned: distinct targets, one window, correct profile), wired into CI on Linux only — the coordination is pure JS and identical across platforms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: tilde expansion differs by purpose on Windows CI on windows-latest caught this: join("~", "x") yields "~\x" there, and expandTilde only handled "~/". The two uses are not the same, so they no longer share a helper. The agent dir stays byte-compatible with pi's own expandTildePath ("~/" only, every platform) — being more lenient would put the pin file somewhere pi never looks. $CHROME_USER_DATA_DIR is ours to interpret, so it accepts "~\" as well, which is what a Windows user would type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/cdp/discovery.ts96
-rw-r--r--src/cdp/session.ts19
-rw-r--r--src/cdp/target-factory.ts223
-rw-r--r--src/client.ts138
-rw-r--r--src/domains/isolated-tab.ts27
-rw-r--r--src/domains/navigate.ts31
-rw-r--r--src/domains/setup.ts6
-rw-r--r--src/index.ts16
-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
-rw-r--r--src/setup.ts74
17 files changed, 1721 insertions, 191 deletions
diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts
index 1b35418..295df20 100644
--- a/src/cdp/discovery.ts
+++ b/src/cdp/discovery.ts
@@ -1,61 +1,24 @@
import { readFile, stat } from "node:fs/promises";
-import { homedir } from "node:os";
import { join } from "node:path";
import { connect as netConnect } from "node:net";
import { request as httpRequest } from "node:http";
import { type Result, err, ok } from "../util/result";
+import { userDataDirCandidates } from "../profile/paths";
import { type CdpError, cdpError } from "./errors";
const PORT_PROBE_DEADLINE_MS = 30_000;
const PORT_PROBE_INTERVAL_MS = 1_000;
-const profileDirs = (): ReadonlyArray<string> => {
- const home = homedir();
- return [
- // macOS — Chrome (all channels), Chromium, Brave, Edge
- join(home, "Library/Application Support/Google/Chrome"),
- join(home, "Library/Application Support/Google/Chrome Beta"),
- join(home, "Library/Application Support/Google/Chrome Dev"),
- join(home, "Library/Application Support/Google/Chrome Canary"),
- join(home, "Library/Application Support/Chromium"),
- join(home, "Library/Application Support/BraveSoftware/Brave-Browser"),
- join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Beta"),
- join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Nightly"),
- join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Dev"),
- join(home, "Library/Application Support/Microsoft Edge"),
- join(home, "Library/Application Support/Microsoft Edge Beta"),
- join(home, "Library/Application Support/Microsoft Edge Dev"),
- join(home, "Library/Application Support/Microsoft Edge Canary"),
- // Linux — Chrome (all channels), Chromium, Brave, Edge
- 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"),
- // Linux 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"),
- // Windows — Chrome, Chromium, Brave, Edge
- join(home, "AppData/Local/Google/Chrome/User Data"),
- join(home, "AppData/Local/Google/Chrome Beta/User Data"),
- join(home, "AppData/Local/Google/Chrome Dev/User Data"),
- join(home, "AppData/Local/Google/Chrome SxS/User Data"),
- join(home, "AppData/Local/Chromium/User Data"),
- join(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data"),
- join(home, "AppData/Local/BraveSoftware/Brave-Browser-Beta/User Data"),
- join(home, "AppData/Local/Microsoft/Edge/User Data"),
- join(home, "AppData/Local/Microsoft/Edge Beta/User Data"),
- join(home, "AppData/Local/Microsoft/Edge Dev/User Data"),
- join(home, "AppData/Local/Microsoft/Edge SxS/User Data"),
- ];
+/**
+ * A live CDP endpoint plus, when discovery went through a DevToolsActivePort
+ * file, the user-data-dir that file lives in. That directory is the browser's
+ * profile registry (`Local State`) — Chromium writes DevToolsActivePort to
+ * chrome::DIR_USER_DATA — so it is what profile selection enumerates.
+ * Undefined when the endpoint came from BU_CDP_WS or a bare port probe.
+ */
+export type CdpEndpoint = {
+ readonly wsUrl: string;
+ readonly userDataDir?: string;
};
const probePort = (port: number): Promise<Result<void, CdpError>> =>
@@ -144,10 +107,20 @@ const fallbackPorts = (): ReadonlyArray<number> => {
return Array.from(ports);
};
-type Candidate = { readonly port: number; readonly path: string; readonly mtimeMs: number };
+type Candidate = {
+ readonly port: number;
+ readonly path: string;
+ readonly mtimeMs: number;
+ /** The user-data-dir whose DevToolsActivePort produced this candidate. */
+ readonly userDataDir: string;
+};
-export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
- const dirs = profileDirs();
+/**
+ * Locate a live CDP endpoint, reporting the user-data-dir it belongs to when
+ * that is knowable. `discoverWsUrl` is the URL-only view of the same walk.
+ */
+export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
+ const dirs = userDataDirCandidates();
// Phase 1: collect every readable DevToolsActivePort candidate up front,
// rather than committing to the first one found. A single browser writes one
@@ -190,7 +163,7 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
// a Result error. Skip such entries.
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
- candidates.push({ port: portNum, path, mtimeMs });
+ candidates.push({ port: portNum, path, mtimeMs, userDataDir: base });
}
// Phase 2: no readable candidate — probe well-known ports directly. Covers
@@ -201,7 +174,9 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
for (const port of fallbackPorts()) {
if (!(await isPortLive(port))) continue;
const live = await queryLiveWsUrl(port);
- if (live) return ok(live);
+ // No DevToolsActivePort file was involved, so the user-data-dir behind
+ // this endpoint is unknown — profile selection stays unavailable.
+ if (live) return ok({ wsUrl: live });
}
const permHint = readErrors.length > 0
? `\n\nDevToolsActivePort reads were denied (${readErrors.join(", ")}); if you're running in a sandbox, grant read access to those files or set BU_CDP_WS / BU_CDP_PORTS to bypass file discovery.`
@@ -232,7 +207,7 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
// /json/version. Some browsers disable that HTTP endpoint when remote
// debugging is toggled only via chrome://inspect (rather than a launch flag),
// so fall back to the WS path written in DevToolsActivePort.
- let lastErr: Result<string, CdpError> | null = null;
+ let lastErr: Result<CdpEndpoint, CdpError> | null = null;
for (const c of ordered) {
const ready = await waitForPort(c.port);
if (!ready.success) {
@@ -240,7 +215,7 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
continue;
}
const liveUrl = await queryLiveWsUrl(c.port);
- return ok(liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`);
+ return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
}
// Every discovered candidate was stale/unreachable. Before giving up, try the
@@ -250,8 +225,17 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
for (const port of fallbackPorts()) {
if (byPort.has(port) || !(await isPortLive(port))) continue;
const liveUrl = await queryLiveWsUrl(port);
- if (liveUrl) return ok(liveUrl);
+ if (liveUrl) return ok({ wsUrl: liveUrl });
}
return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
};
+
+/**
+ * URL-only view of {@link discoverEndpoint}, kept as the stable entry point for
+ * callers that don't care which user-data-dir answered.
+ */
+export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
+ const endpoint = await discoverEndpoint();
+ return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint;
+};
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index 03410ba..eeb3802 100644
--- a/src/cdp/session.ts
+++ b/src/cdp/session.ts
@@ -45,9 +45,21 @@ export type CdpSession = {
drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
};
+/**
+ * Supplies the seed tab when attachFirstPage finds no owned tab to attach to.
+ * Injected by the client so that seeding goes through the target factory —
+ * which, with a profile pinned, opens the window inside that profile. Without
+ * it, attachFirstPage's own `Target.createTarget` would land in whichever
+ * profile currently has focus.
+ *
+ * The provider is responsible for its own ownership bookkeeping.
+ */
+export type SeedTargetProvider = () => Promise<Result<string, CdpError>>;
+
export const createCdpSession = (
transport: CdpTransport,
ownership?: OwnershipRegistry,
+ createSeedTarget?: SeedTargetProvider,
): CdpSession => {
let sessionId: string | null = null;
let targetId: string | null = null;
@@ -246,6 +258,13 @@ export const createCdpSession = (
const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
pickTargetId = ownedLive[0];
}
+ if (!pickTargetId && createSeedTarget) {
+ // Delegated seeding (see SeedTargetProvider): the provider creates the
+ // window in the right profile and records ownership itself.
+ const seeded = await createSeedTarget();
+ if (!seeded.success) return seeded;
+ pickTargetId = seeded.data;
+ }
if (!pickTargetId) {
const createParams: Record<string, unknown> = { url: "about:blank" };
if (ownership) createParams["newWindow"] = true;
diff --git a/src/cdp/target-factory.ts b/src/cdp/target-factory.ts
new file mode 100644
index 0000000..25c43a6
--- /dev/null
+++ b/src/cdp/target-factory.ts
@@ -0,0 +1,223 @@
+/**
+ * The single place harness tabs and windows are created.
+ *
+ * Every tab the harness opens must land in the pinned profile, so all creation
+ * paths (client.newTab, browser_open_urls, isolated tabs for web_search /
+ * read_page) funnel through here. A missed call site would silently act in
+ * another profile — with that profile's cookies and logins.
+ *
+ * Two mechanisms, chosen by whether a profile is pinned:
+ *
+ * unpinned — `Target.createTarget`, exactly as before: `newWindow: true` for
+ * a fresh dedicated window, `openerId` for siblings inside it.
+ *
+ * pinned — the window comes from a command-line launch into the profile
+ * (see profile/launch.ts), and further tabs come from
+ * `window.open(..., '_blank')` evaluated in an owned page with
+ * `userGesture: true`. That is the only CDP-reachable way to place
+ * a tab in a non-default browser context: createTarget rejects a
+ * foreign browserContextId outright, and `openerId` does NOT
+ * inherit the opener's context (verified — it lands in the
+ * focus-derived default context, in a different window).
+ * window.open keeps both the context and the window, recursively.
+ *
+ * Correlation: window.open returns no target id, so each spawn carries a unique
+ * `about:blank#pi-<uuid>` token and the new target is found by that token.
+ * Set-difference polling would be wrong here — isolated tabs run concurrently
+ * (browser_read_page is registered unserialized).
+ */
+
+import { randomUUID } from "node:crypto";
+import type { BrowserClient } from "../client";
+import { type Result, err, ok } from "../util/result";
+import { type CdpError, cdpError } from "./errors";
+
+/** How long to wait for a window.open-spawned target to appear. */
+const SPAWN_DEADLINE_MS = 8_000;
+const SPAWN_POLL_MS = 100;
+
+export type HarnessWindow = {
+ /** Target id of the window's seed tab. */
+ readonly targetId: string;
+ /**
+ * True when this call created the window. The seed tab is then a blank tab
+ * the caller may use directly, instead of opening a second one.
+ */
+ readonly freshlyCreated: boolean;
+};
+
+type PageTarget = {
+ readonly targetId: string;
+ readonly type: string;
+ readonly url: string;
+ readonly browserContextId?: string;
+};
+
+const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
+ const r = await client.session().callBrowser("Target.getTargets");
+ if (!r.success) return r;
+ const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
+ return ok(data.targetInfos.filter((t) => t.type === "page"));
+};
+
+/** A CDP session id for driving `targetId`, reusing the active one when possible. */
+const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => {
+ const current = client.current();
+ if (current?.targetId === targetId) return ok(current.sessionId);
+ const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
+ if (!attached.success) return attached;
+ return ok((attached.data as { sessionId: string }).sessionId);
+};
+
+/**
+ * Spawn a tab from `openerTargetId` via window.open, so it inherits the
+ * opener's profile and window, and return its target id.
+ */
+const spawnTabViaOpener = async (
+ client: BrowserClient,
+ openerTargetId: string,
+): Promise<Result<string, CdpError>> => {
+ const session = await sessionForTarget(client, openerTargetId);
+ if (!session.success) return session;
+
+ const token = `pi-${randomUUID()}`;
+ // userGesture: true is what gets this past the popup blocker.
+ const opened = await client.session().callOnTarget(
+ "Runtime.evaluate",
+ {
+ expression: `!!window.open('about:blank#${token}', '_blank')`,
+ returnByValue: true,
+ userGesture: true,
+ },
+ session.data,
+ );
+ if (!opened.success) return opened;
+ const result = opened.data as { result?: { value?: unknown } };
+ if (result.result?.value !== true) {
+ return err(cdpError(
+ "invalid_response",
+ "the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed",
+ "Runtime.evaluate",
+ ));
+ }
+
+ const deadline = Date.now() + SPAWN_DEADLINE_MS;
+ while (Date.now() < deadline) {
+ const targets = await listPageTargets(client);
+ if (targets.success) {
+ const spawned = targets.data.find((t) => t.url.includes(token));
+ if (spawned) return ok(spawned.targetId);
+ }
+ await new Promise((r) => setTimeout(r, SPAWN_POLL_MS));
+ }
+ return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`));
+};
+
+/**
+ * In-flight window creation, per client. Isolated tabs run concurrently
+ * (browser_read_page is registered unserialized), so two of them starting at
+ * once would otherwise each find "no harness window" and create one — two blank
+ * windows before, and two launched profile windows now. Callers share the first
+ * call's result instead.
+ */
+const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>();
+
+/**
+ * The harness window for this session, creating it when absent.
+ *
+ * Reuses a live owned tab when one exists — including after the user closed the
+ * original seed — so reconnects don't scatter windows.
+ */
+export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => {
+ const pending = inFlight.get(client);
+ if (pending) {
+ const shared = await pending;
+ // `freshlyCreated` is a claim on the seed tab, and only the caller that
+ // started the creation may hold it. Everyone waiting on the same window
+ // must open a tab of their own beside it, or two callers would drive the
+ // very same tab.
+ return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared;
+ }
+ const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client));
+ inFlight.set(client, attempt);
+ return attempt;
+};
+
+const ensureHarnessWindowUncoordinated = async (
+ client: BrowserClient,
+): Promise<Result<HarnessWindow, CdpError>> => {
+ const targets = await listPageTargets(client);
+ if (!targets.success) return targets;
+ const live = new Set(targets.data.map((t) => t.targetId));
+
+ const ownership = client.ownership();
+ const pinnedCtx = client.profileContextId();
+ // A tab may only be reused when we can prove which profile it is in. With a
+ // pin set but no context resolved yet — a fresh connection, or a browser that
+ // restarted — the recorded tabs cannot be verified, so we seed a new window
+ // rather than risk adopting one from the wrong profile. (Sessions close their
+ // owned tabs on shutdown, so this is the normal, no-cost case.)
+ const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
+ const inPinnedProfile = (targetId: string): boolean => {
+ if (!pinnedCtx) return true;
+ const info = targets.data.find((t) => t.targetId === targetId);
+ return info?.browserContextId === pinnedCtx;
+ };
+
+ if (canReuse) {
+ const harnessWindow = ownership.harnessWindow();
+ if (harnessWindow && live.has(harnessWindow) && inPinnedProfile(harnessWindow)) {
+ return ok({ targetId: harnessWindow, freshlyCreated: false });
+ }
+ const survivor = ownership.list().find((id) => live.has(id) && inPinnedProfile(id));
+ if (survivor) {
+ ownership.setHarnessWindow(survivor);
+ return ok({ targetId: survivor, freshlyCreated: false });
+ }
+ }
+
+ const seeded = client.profilePin()
+ ? await client.seedPinnedProfileWindow()
+ : await createDedicatedWindow(client);
+ if (!seeded.success) return seeded;
+ return ok({ targetId: seeded.data, freshlyCreated: true });
+};
+
+/** Unpinned path: a fresh dedicated window, exactly as the harness always did. */
+const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => {
+ const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
+ if (!created.success) return created;
+ const { targetId } = created.data as { targetId: string };
+ const ownership = client.ownership();
+ ownership.setHarnessWindow(targetId);
+ ownership.add(targetId);
+ const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId });
+ if (win.success) {
+ const windowId = (win.data as { windowId?: number }).windowId;
+ if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
+ }
+ return ok(targetId);
+};
+
+/**
+ * Open a harness-owned tab beside `openerTargetId`, in the same window and —
+ * when a profile is pinned — the same profile. The tab is registered as owned.
+ */
+export const openHarnessTab = async (
+ client: BrowserClient,
+ openerTargetId: string,
+): Promise<Result<string, CdpError>> => {
+ const spawned = client.profileContextId()
+ ? await spawnTabViaOpener(client, openerTargetId)
+ : await (async (): Promise<Result<string, CdpError>> => {
+ const created = await client.session().callBrowser("Target.createTarget", {
+ url: "about:blank",
+ openerId: openerTargetId,
+ });
+ if (!created.success) return created;
+ return ok((created.data as { targetId: string }).targetId);
+ })();
+ if (!spawned.success) return spawned;
+ client.ownership().add(spawned.data);
+ return ok(spawned.data);
+};
diff --git a/src/client.ts b/src/client.ts
index 9a97369..1049212 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,12 +1,15 @@
import { type Result, err, ok } from "./util/result";
import { safeJs } from "./util/js-template";
import { type Mutex, createMutex } from "./util/mutex";
-import { discoverWsUrl } from "./cdp/discovery";
+import { discoverEndpoint } from "./cdp/discovery";
import { type CdpError, cdpError } from "./cdp/errors";
import type { CdpTransport } from "./cdp/transport";
import { createCdpTransport } from "./cdp/transport";
import { type CdpSession, createCdpSession } from "./cdp/session";
import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
+import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
+import { seedProfileWindow } from "./profile/bind";
+import type { ProfilePin } from "./profile/store";
import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
export type BrowserClientOptions = {
@@ -18,6 +21,8 @@ export type BrowserClientOptions = {
readonly harnessWindowTargetId?: string;
readonly harnessWindowId?: number;
};
+ /** Profile to pin this session to, loaded from the durable pin store. */
+ readonly profilePin?: ProfilePin | null;
readonly onOwnershipChange?: (snapshot: {
readonly ownedTargetIds: ReadonlyArray<string>;
readonly harnessWindowTargetId: string | undefined;
@@ -49,6 +54,19 @@ export type BrowserClient = {
current(): { readonly sessionId: string; readonly targetId: string } | null;
session(): CdpSession;
transport(): CdpTransport;
+ /** User-data-dir of the connected browser, when discovery could determine it.
+ * Undefined for BU_CDP_WS / bare-port connections, where profile selection
+ * is unavailable. */
+ userDataDir(): string | undefined;
+ /** The profile this session is pinned to, or null when unpinned. */
+ profilePin(): ProfilePin | null;
+ setProfilePin(pin: ProfilePin | null): void;
+ /** browserContextId of the pinned profile, once a window has identified it.
+ * Session-scoped: Chrome re-mints context ids on every browser run. */
+ profileContextId(): string | undefined;
+ setProfileContextId(contextId: string | undefined): void;
+ /** Open and adopt a window inside the pinned profile. Returns its seed tab. */
+ seedPinnedProfileWindow(): Promise<Result<string, CdpError>>;
/** Returns the shared async mutex that serialized browser tools must acquire
* before performing mutations. Observation tools should not use this. */
mutationMutex(): Mutex;
@@ -108,11 +126,33 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
});
});
}
- const session = createCdpSession(transport, ownership);
+ // The seed provider routes attachFirstPage's fallback through the target
+ // factory, so a pinned profile is honoured on every path that can create the
+ // harness window.
+ const session = createCdpSession(transport, ownership, async () => {
+ const window = await ensureHarnessWindow(api);
+ return window.success ? ok(window.data.targetId) : window;
+ });
const mutationMutex = createMutex();
let lastHealth = 0;
let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
+ let userDataDir: string | undefined;
+ let profilePin: ProfilePin | null = opts.profilePin ?? null;
+ let profileContextId: string | undefined;
+
+ /**
+ * With a profile pinned, the harness window must exist BEFORE anything
+ * attaches: session.attachFirstPage() would otherwise fall back to a bare
+ * Target.createTarget, which lands in the focus-derived default profile.
+ * Seeding first means attachFirstPage always finds an owned tab to attach to.
+ */
+ const seedIfPinned = async (): Promise<Result<void, CdpError>> => {
+ if (!profilePin) return ok(undefined);
+ const window = await ensureHarnessWindow(api);
+ if (!window.success) return window;
+ return ok(undefined);
+ };
const start = async (): Promise<Result<void, CdpError>> => {
if (transport.state() === "open" && session.current()) return ok(undefined);
@@ -123,15 +163,23 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
} else if (envUrl) {
wsUrl = envUrl;
} else {
- const discovered = await discoverWsUrl();
+ const discovered = await discoverEndpoint();
if (!discovered.success) return discovered;
- wsUrl = discovered.data;
+ wsUrl = discovered.data.wsUrl;
+ userDataDir = discovered.data.userDataDir;
}
const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
if (!connected.success) return connected;
if (!remote) {
remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" };
}
+ // A fresh browser run invalidates any context id resolved earlier.
+ profileContextId = undefined;
+ const seeded = await seedIfPinned();
+ if (!seeded.success) {
+ await transport.close();
+ return seeded;
+ }
const attached = await session.attachFirstPage();
if (!attached.success) {
await transport.close();
@@ -166,6 +214,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
expression: "1", returnByValue: true,
}, { timeoutMs: 2_000 });
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
+ // Re-seed first when pinned: if the user closed every harness tab,
+ // attachFirstPage would mint a window in the focus-derived profile.
+ const seeded = await seedIfPinned();
+ if (!seeded.success) return seeded;
const reattached = await session.attachFirstPage();
if (reattached.success) {
pageCaches.clear();
@@ -265,60 +317,34 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
return ok(undefined);
};
- // Ask Chrome for the real windowId hosting a target. Undefined on failure —
- // callers treat the window binding as best-effort.
- const getWindowId = async (targetId: string): Promise<number | undefined> => {
- const r = await session.callBrowser("Browser.getWindowForTarget", { targetId });
- if (!r.success) return undefined;
- const wid = (r.data as { windowId?: number }).windowId;
- return typeof wid === "number" ? wid : undefined;
- };
-
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
- // Verify the recorded harness window still exists. Querying the live
- // target list also gives listTabs's reconciliation a chance to run.
+ // Reconcile ownership against live targets before deciding where to open.
const tabsResult = await listTabs(true);
if (!tabsResult.success) return tabsResult;
- const live = new Set(tabsResult.data.map((t) => t.targetId));
- const hw = ownership.harnessWindow();
- // Pick an opener that keeps us inside the harness window. Prefer the seed
- // tab; if the user closed it but other owned tabs survive, reuse one of
- // those instead of spawning a second window (avoids window scatter).
- const opener = hw && live.has(hw)
- ? hw
- : ownership.list().find((id) => live.has(id));
- const params: Record<string, unknown> = { url: "about:blank" };
- if (opener) {
- // Open as a child of an owned tab — Chrome places it in the same window,
- // giving us visual grouping for free and keeping the session single-window.
- params["openerId"] = opener;
+ // ensureHarnessWindow reuses the seed tab, falls back to any surviving
+ // owned tab, and only then creates a window — which, when a profile is
+ // pinned, means launching one inside that profile.
+ const window = await ensureHarnessWindow(api);
+ if (!window.success) return window;
+ // A freshly created window IS the new tab; opening another would leave a
+ // stray blank tab behind.
+ let tabId: string;
+ if (window.data.freshlyCreated) {
+ tabId = window.data.targetId;
} else {
- // Fresh session, or the whole harness window was closed by the user.
- // Spawn a new dedicated window and rebind to it.
- params["newWindow"] = true;
+ const opened = await openHarnessTab(api, window.data.targetId);
+ if (!opened.success) return opened;
+ tabId = opened.data;
}
- const created = await session.callBrowser("Target.createTarget", params);
- if (!created.success) return created;
- const c = created.data as { targetId: string };
- if (params["newWindow"]) {
- ownership.setHarnessWindow(c.targetId);
- const wid = await getWindowId(c.targetId);
- if (wid !== undefined) ownership.setHarnessWindowId(wid);
- } else if (opener && opener !== hw) {
- // Re-seeded onto a surviving owned tab; keep the seed pointer live.
- ownership.setHarnessWindow(opener);
- }
- ownership.add(c.targetId);
-
- const switched = await switchTab(c.targetId);
+ const switched = await switchTab(tabId);
if (!switched.success) return switched;
if (url && url !== "about:blank") {
const nav = await session.call("Page.navigate", { url });
if (!nav.success) return nav;
}
- return ok(c.targetId);
+ return ok(tabId);
};
const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
@@ -357,7 +383,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
};
- return {
+ const api: BrowserClient = {
namespace: opts.namespace,
ensureAlive, status, start, stop, detach, closeOwnedTabs,
evaluateJs, pageInfo,
@@ -368,6 +394,24 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
current: () => session.current(),
session: () => session,
transport: () => transport,
+ userDataDir: () => userDataDir,
+ profilePin: () => profilePin,
+ setProfilePin: (pin) => {
+ profilePin = pin;
+ // The old profile's context no longer describes where tabs should go.
+ profileContextId = undefined;
+ },
+ profileContextId: () => profileContextId,
+ setProfileContextId: (contextId) => {
+ profileContextId = contextId;
+ },
+ seedPinnedProfileWindow: async () => {
+ if (!profilePin) {
+ return err(cdpError("discovery_failed", "no browser profile is selected — run /browser-profile"));
+ }
+ return seedProfileWindow(api, profilePin);
+ },
mutationMutex: () => mutationMutex,
};
+ return api;
};
diff --git a/src/domains/isolated-tab.ts b/src/domains/isolated-tab.ts
index 73ed158..7969c6b 100644
--- a/src/domains/isolated-tab.ts
+++ b/src/domains/isolated-tab.ts
@@ -13,7 +13,7 @@
import type { BrowserClient } from "../client";
import { type Result, err, ok } from "../util/result";
import { safeJs } from "../util/js-template";
-import { bindHarnessWindowId } from "../cdp/window-binding";
+import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
import type { ToolErr } from "../util/tool";
/** A tab attached by its own sessionId, ready to drive via callOnTarget. */
@@ -32,20 +32,19 @@ const toToolErr = (message: string, kind: ToolErr["kind"] = "cdp_error"): ToolEr
* sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done.
*/
export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => {
- const hw = client.ownership().harnessWindow();
- const createParams: Record<string, unknown> = hw
- ? { url: "about:blank", openerId: hw }
- : { url: "about:blank", newWindow: true };
-
- const created = await client.session().callBrowser("Target.createTarget", createParams);
- if (!created.success) return err(toToolErr(created.error.message));
- const { targetId } = created.data as { targetId: string };
-
- if (createParams["newWindow"]) {
- client.ownership().setHarnessWindow(targetId);
- await bindHarnessWindowId(client, targetId);
+ // Routed through the target factory so an isolated tab lands in the pinned
+ // profile like every other harness tab — otherwise a search or page read
+ // would run with a different profile's cookies than the visible tabs.
+ const window = await ensureHarnessWindow(client);
+ if (!window.success) return err(toToolErr(window.error.message));
+ let targetId: string;
+ if (window.data.freshlyCreated) {
+ targetId = window.data.targetId;
+ } else {
+ const opened = await openHarnessTab(client, window.data.targetId);
+ if (!opened.success) return err(toToolErr(opened.error.message));
+ targetId = opened.data;
}
- client.ownership().add(targetId);
const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
if (!attached.success) {
diff --git a/src/domains/navigate.ts b/src/domains/navigate.ts
index e402efc..aa0d671 100644
--- a/src/domains/navigate.ts
+++ b/src/domains/navigate.ts
@@ -4,7 +4,7 @@ import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
import { safeJs } from "../util/js-template";
-import { bindHarnessWindowId } from "../cdp/window-binding";
+import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
const NavigateArgs = Type.Object({
url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
@@ -89,28 +89,21 @@ export const openUrlsTool = defineBrowserTool({
// (fresh/closed), create the FIRST url as a new dedicated window (binding
// its windowId) and open the rest as children of it — a parallel
// newWindow:true per url would otherwise scatter tabs across N windows.
- let seed = client.ownership().harnessWindow();
- let seedResult: TabResult | undefined;
- if (!seed) {
- const url0 = args.urls[0];
- if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" });
- const r0 = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
- if (!r0.success) return err({ kind: "cdp_error", message: r0.error.message });
- seed = (r0.data as { targetId: string }).targetId;
- client.ownership().setHarnessWindow(seed);
- await bindHarnessWindowId(client, seed);
- client.ownership().add(seed);
- seedResult = { url: url0, targetId: seed, ok: true };
- }
- const seededWindow = seed;
+ const url0 = args.urls[0];
+ if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" });
+ const window = await ensureHarnessWindow(client);
+ if (!window.success) return err({ kind: "cdp_error", message: window.error.message });
+ const seededWindow = window.data.targetId;
+ // A freshly created window's seed tab serves as the first URL's tab.
+ const seedResult: TabResult | undefined = window.data.freshlyCreated
+ ? { url: url0, targetId: seededWindow, ok: true }
+ : undefined;
const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => {
// Reuse the seed tab (created above) for the first url instead of duplicating it.
if (i === 0 && seedResult) return seedResult;
- const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank", openerId: seededWindow });
+ const r = await openHarnessTab(client, seededWindow);
if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
- const c = r.data as { targetId: string };
- client.ownership().add(c.targetId);
- return { url, targetId: c.targetId, ok: true };
+ return { url, targetId: r.data, ok: true };
}));
// Phase 2: attach + navigate each created tab in parallel.
let completed = 0;
diff --git a/src/domains/setup.ts b/src/domains/setup.ts
index bbd767f..2ddd739 100644
--- a/src/domains/setup.ts
+++ b/src/domains/setup.ts
@@ -31,8 +31,10 @@ export const setupTool = defineBrowserTool({
// ensureAlive:false — this tool IS the setup; it must bypass the socket guard
ensureAlive: false,
renderCall: () => new Text("🔧 Initializing browser...", 0, 0),
- async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
- const result = await performSetup(client);
+ async handler(_args, { client, extensionCtx }): Promise<Result<ToolOk, ToolErr>> {
+ // extensionCtx carries the UI, so the agent calling browser_setup gets the
+ // same first-run profile picker the user gets from /browser-setup.
+ const result = await performSetup(client, extensionCtx);
if (result.success) {
return ok({ text: result.data });
}
diff --git a/src/index.ts b/src/index.ts
index 7109ea0..e421511 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,6 +12,7 @@
*
* Commands:
* /browser-setup — guided setup wizard
+ * /browser-profile — choose which browser profile the agent uses
* /browser-status — show client status and current page
* /browser-reload-daemon — restart the browser client
* /deep-research <q> — research a question on the web, write a cited report
@@ -25,6 +26,8 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { type BrowserClient, createBrowserClient } from "./client";
import { getBrowserSystemPrompt } from "./prompt";
import { registerSetupCommand } from "./setup";
+import { registerProfileCommand } from "./profile/command";
+import { readPin } from "./profile/store";
import { registerDeepResearchCommand } from "./deep-research";
import { type BrowserState, defaultState, persistState, restoreState } from "./state";
import { registerAllTools } from "./registry";
@@ -57,8 +60,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
return;
}
const s = client.status();
+ const pin = client.profilePin();
const lines = [
`Browser: ${s.alive ? "🟢 Connected" : "🔴 Disconnected"}`,
+ `Profile: ${pin ? pin.label : "not selected (uses whichever window is focused) — run /browser-profile"}`,
`Session: ${s.sessionId ?? "none"}`,
];
if (s.remoteBrowserId) lines.push(`Browser ID: ${s.remoteBrowserId}`);
@@ -102,6 +107,11 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
pi.on("session_start", async (_event, ctx) => {
state = restoreState(ctx, state.namespace);
+ // The pinned profile lives on disk, not in the session, so it survives
+ // session termination. Re-read it every session start: another pi session
+ // may have changed it since this client was created.
+ const profilePin = await readPin();
+
// Create the client lazily on first session. The daemon transport
// is created but NOT connected — that only happens when the user
// runs /browser-setup or a browser tool call finds an existing socket.
@@ -117,6 +127,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
client = createBrowserClient({
namespace: state.namespace,
transport,
+ profilePin,
...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}),
onOwnershipChange: (snap) => {
state = {
@@ -136,6 +147,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ }
},
});
+ } else {
+ // Client carried over from a previous session — adopt whatever pin is on
+ // disk now, which another pi session may have changed.
+ client.setProfilePin(profilePin);
}
// Do NOT call client.start() here — browser control is on-demand.
@@ -147,6 +162,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
toolsRegistered = true;
}
registerSetupCommand(pi, client);
+ registerProfileCommand(pi, client);
registerDeepResearchCommand(pi);
});
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);
diff --git a/src/setup.ts b/src/setup.ts
index 40f501c..8c04030 100644
--- a/src/setup.ts
+++ b/src/setup.ts
@@ -4,11 +4,12 @@
* users and a tool (browser_setup) for the agent to self-recover.
*/
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { execSync } from "node:child_process";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { BrowserClient } from "./client";
import { ensureDaemon } from "./daemon/spawn";
import { DAEMON_SOCKET_PATH } from "./daemon/protocol";
+import { isBrowserRunning } from "./profile/browser-process";
+import { promptForProfileIfUnset } from "./profile/command";
// ── Public: register the /browser-setup slash command ──────────────────────
@@ -16,7 +17,7 @@ export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): v
pi.registerCommand("browser-setup", {
description: "Connect pi to your Chrome browser",
handler: async (_args, ctx) => {
- const result = await performSetup(client);
+ const result = await performSetup(client, ctx);
if (result.success) {
ctx.ui.notify(result.data, "info");
} else {
@@ -32,9 +33,9 @@ export type SetupResult = { success: true; data: string } | { success: false; er
// ── Shared setup logic (used by both the command and the tool) ─────────────
-export async function performSetup(client: BrowserClient): Promise<SetupResult> {
+export async function performSetup(client: BrowserClient, ctx?: ExtensionContext): Promise<SetupResult> {
// Step 1: Check Chrome is running
- const chromeRunning = checkChromeRunning();
+ const chromeRunning = await isBrowserRunning();
if (!chromeRunning) {
return { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." };
}
@@ -45,7 +46,13 @@ export async function performSetup(client: BrowserClient): Promise<SetupResult>
return { success: false, error: `Could not start the browser daemon. Check ${DAEMON_SOCKET_PATH}.` };
}
- // Step 3: Connect to Chrome DevTools
+ // Step 3: Ask which profile to work in, the first time only. Enumeration
+ // reads DevToolsActivePort + Local State from disk, so it works before the
+ // connection exists. Declining leaves the pre-profile behavior untouched —
+ // this must never block setup.
+ const profileNote = await promptForProfileIfUnset(client, ctx);
+
+ // Step 4: Connect to Chrome DevTools
const startResult = await client.start();
if (!startResult.success) {
const msg = startResult.error.message;
@@ -73,7 +80,7 @@ export async function performSetup(client: BrowserClient): Promise<SetupResult>
return { success: false, error: `Connection failed: ${msg}` };
}
- // Step 4: Verify with test navigation
+ // Step 5: Verify with test navigation
const tabResult = await client.newTab("https://github.com");
if (!tabResult.success) {
return { success: false, error: `Browser connected but test navigation failed: ${tabResult.error.message}` };
@@ -85,52 +92,13 @@ export async function performSetup(client: BrowserClient): Promise<SetupResult>
}
const pageUrl = info.success && !("dialog" in info.data) ? info.data.url : "github.com";
- return { success: true, data: `Browser connected ✓\nNavigated to: ${pageUrl}` };
+ const lines = ["Browser connected ✓", `Navigated to: ${pageUrl}`];
+ if (profileNote) lines.push(profileNote);
+ return { success: true, data: lines.join("\n") };
}
// ── Chrome process detection ───────────────────────────────────────────────
-
-function checkChromeRunning(): boolean {
- try {
- if (process.platform === "darwin") {
- // ps -o comm= returns the full executable path on modern macOS, e.g.
- // "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser". We check
- // for a known Chromium-family name anywhere in the path (case-insensitive),
- // excluding helper/renderer/gpu/crashpad/updater subprocesses that linger
- // after the user quits the browser.
- const out = execSync("ps -A -o comm=", { timeout: 5000 }).toString().toLowerCase();
- const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
- const browserNames = ["google chrome", "chromium", "microsoft edge", "brave browser"];
- return lines.some((l) => {
- if (
- l.includes("helper") ||
- l.includes("renderer") ||
- l.includes("crashpad") ||
- l.includes(" gpu") ||
- l.includes("updater")
- ) return false;
- return browserNames.some((name) => l.includes(name));
- });
- } else if (process.platform === "linux") {
- const out = execSync("ps -A -o comm=,args=", { timeout: 5000 }).toString().toLowerCase();
- const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
- const browserComms = [
- "chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge",
- "google-chrome", "brave", "brave-browser",
- ];
- return lines.some((line) => {
- const parts = line.split(/\s+/);
- const comm = parts[0] ?? "";
- if (!comm || !browserComms.includes(comm)) return false;
- const isSubprocess = line.includes("--type=") && !line.includes("--type=browser");
- return !isSubprocess;
- });
- } else if (process.platform === "win32") {
- const out = execSync("tasklist", { timeout: 5000 }).toString().toLowerCase();
- return ["chrome.exe", "msedge.exe", "brave.exe"].some((n) => out.includes(n));
- }
- } catch {
- // best-effort
- }
- return false;
-}
+//
+// Detection moved to src/profile/browser-process.ts, which answers the same
+// "is a browser running?" question per platform and additionally reports the
+// executable path and any explicit --user-data-dir that profile selection needs.