aboutsummaryrefslogtreecommitdiff
path: root/src/profile/command.ts
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/profile/command.ts
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/profile/command.ts')
-rw-r--r--src/profile/command.ts168
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}`;
+};