diff options
| author | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-07-25 00:23:05 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-25 00:23:05 +0530 |
| commit | 6dd79f7c3bafacba54acb2840f8655fcb05318bf (patch) | |
| tree | 1570768785ae836717d3dbb2ff42fe0a13f19585 /src/profile/command.ts | |
| parent | 0fc1ece6fa4a2f24e87eb4a66eb828a9928e30f7 (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/profile/command.ts')
| -rw-r--r-- | src/profile/command.ts | 168 |
1 files changed, 168 insertions, 0 deletions
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}`; +}; |
