pi-browser-harness 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@
13
13
  import type { BrowserClient } from "../client";
14
14
  import { type Result, err, ok } from "../util/result";
15
15
  import { safeJs } from "../util/js-template";
16
- import { bindHarnessWindowId } from "../cdp/window-binding";
16
+ import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
17
17
  import type { ToolErr } from "../util/tool";
18
18
 
19
19
  /** 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
32
32
  * sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done.
33
33
  */
34
34
  export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => {
35
- const hw = client.ownership().harnessWindow();
36
- const createParams: Record<string, unknown> = hw
37
- ? { url: "about:blank", openerId: hw }
38
- : { url: "about:blank", newWindow: true };
39
-
40
- const created = await client.session().callBrowser("Target.createTarget", createParams);
41
- if (!created.success) return err(toToolErr(created.error.message));
42
- const { targetId } = created.data as { targetId: string };
43
-
44
- if (createParams["newWindow"]) {
45
- client.ownership().setHarnessWindow(targetId);
46
- await bindHarnessWindowId(client, targetId);
35
+ // Routed through the target factory so an isolated tab lands in the pinned
36
+ // profile like every other harness tab — otherwise a search or page read
37
+ // would run with a different profile's cookies than the visible tabs.
38
+ const window = await ensureHarnessWindow(client);
39
+ if (!window.success) return err(toToolErr(window.error.message));
40
+ let targetId: string;
41
+ if (window.data.freshlyCreated) {
42
+ targetId = window.data.targetId;
43
+ } else {
44
+ const opened = await openHarnessTab(client, window.data.targetId);
45
+ if (!opened.success) return err(toToolErr(opened.error.message));
46
+ targetId = opened.data;
47
47
  }
48
- client.ownership().add(targetId);
49
48
 
50
49
  const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
51
50
  if (!attached.success) {
@@ -4,7 +4,7 @@ import { type Result, err, ok } from "../util/result";
4
4
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
5
  import { applyTruncation } from "../util/truncate";
6
6
  import { safeJs } from "../util/js-template";
7
- import { bindHarnessWindowId } from "../cdp/window-binding";
7
+ import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
8
8
 
9
9
  const NavigateArgs = Type.Object({
10
10
  url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
@@ -89,28 +89,21 @@ export const openUrlsTool = defineBrowserTool({
89
89
  // (fresh/closed), create the FIRST url as a new dedicated window (binding
90
90
  // its windowId) and open the rest as children of it — a parallel
91
91
  // newWindow:true per url would otherwise scatter tabs across N windows.
92
- let seed = client.ownership().harnessWindow();
93
- let seedResult: TabResult | undefined;
94
- if (!seed) {
95
- const url0 = args.urls[0];
96
- if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" });
97
- const r0 = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
98
- if (!r0.success) return err({ kind: "cdp_error", message: r0.error.message });
99
- seed = (r0.data as { targetId: string }).targetId;
100
- client.ownership().setHarnessWindow(seed);
101
- await bindHarnessWindowId(client, seed);
102
- client.ownership().add(seed);
103
- seedResult = { url: url0, targetId: seed, ok: true };
104
- }
105
- const seededWindow = seed;
92
+ const url0 = args.urls[0];
93
+ if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" });
94
+ const window = await ensureHarnessWindow(client);
95
+ if (!window.success) return err({ kind: "cdp_error", message: window.error.message });
96
+ const seededWindow = window.data.targetId;
97
+ // A freshly created window's seed tab serves as the first URL's tab.
98
+ const seedResult: TabResult | undefined = window.data.freshlyCreated
99
+ ? { url: url0, targetId: seededWindow, ok: true }
100
+ : undefined;
106
101
  const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => {
107
102
  // Reuse the seed tab (created above) for the first url instead of duplicating it.
108
103
  if (i === 0 && seedResult) return seedResult;
109
- const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank", openerId: seededWindow });
104
+ const r = await openHarnessTab(client, seededWindow);
110
105
  if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
111
- const c = r.data as { targetId: string };
112
- client.ownership().add(c.targetId);
113
- return { url, targetId: c.targetId, ok: true };
106
+ return { url, targetId: r.data, ok: true };
114
107
  }));
115
108
  // Phase 2: attach + navigate each created tab in parallel.
116
109
  let completed = 0;
@@ -31,8 +31,10 @@ export const setupTool = defineBrowserTool({
31
31
  // ensureAlive:false — this tool IS the setup; it must bypass the socket guard
32
32
  ensureAlive: false,
33
33
  renderCall: () => new Text("🔧 Initializing browser...", 0, 0),
34
- async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
35
- const result = await performSetup(client);
34
+ async handler(_args, { client, extensionCtx }): Promise<Result<ToolOk, ToolErr>> {
35
+ // extensionCtx carries the UI, so the agent calling browser_setup gets the
36
+ // same first-run profile picker the user gets from /browser-setup.
37
+ const result = await performSetup(client, extensionCtx);
36
38
  if (result.success) {
37
39
  return ok({ text: result.data });
38
40
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  *
13
13
  * Commands:
14
14
  * /browser-setup — guided setup wizard
15
+ * /browser-profile — choose which browser profile the agent uses
15
16
  * /browser-status — show client status and current page
16
17
  * /browser-reload-daemon — restart the browser client
17
18
  * /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";
25
26
  import { type BrowserClient, createBrowserClient } from "./client";
26
27
  import { getBrowserSystemPrompt } from "./prompt";
27
28
  import { registerSetupCommand } from "./setup";
29
+ import { registerProfileCommand } from "./profile/command";
30
+ import { readPin } from "./profile/store";
28
31
  import { registerDeepResearchCommand } from "./deep-research";
29
32
  import { type BrowserState, defaultState, persistState, restoreState } from "./state";
30
33
  import { registerAllTools } from "./registry";
@@ -57,8 +60,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
57
60
  return;
58
61
  }
59
62
  const s = client.status();
63
+ const pin = client.profilePin();
60
64
  const lines = [
61
65
  `Browser: ${s.alive ? "🟢 Connected" : "🔴 Disconnected"}`,
66
+ `Profile: ${pin ? pin.label : "not selected (uses whichever window is focused) — run /browser-profile"}`,
62
67
  `Session: ${s.sessionId ?? "none"}`,
63
68
  ];
64
69
  if (s.remoteBrowserId) lines.push(`Browser ID: ${s.remoteBrowserId}`);
@@ -102,6 +107,11 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
102
107
  pi.on("session_start", async (_event, ctx) => {
103
108
  state = restoreState(ctx, state.namespace);
104
109
 
110
+ // The pinned profile lives on disk, not in the session, so it survives
111
+ // session termination. Re-read it every session start: another pi session
112
+ // may have changed it since this client was created.
113
+ const profilePin = await readPin();
114
+
105
115
  // Create the client lazily on first session. The daemon transport
106
116
  // is created but NOT connected — that only happens when the user
107
117
  // runs /browser-setup or a browser tool call finds an existing socket.
@@ -117,6 +127,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
117
127
  client = createBrowserClient({
118
128
  namespace: state.namespace,
119
129
  transport,
130
+ profilePin,
120
131
  ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}),
121
132
  onOwnershipChange: (snap) => {
122
133
  state = {
@@ -136,6 +147,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
136
147
  try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ }
137
148
  },
138
149
  });
150
+ } else {
151
+ // Client carried over from a previous session — adopt whatever pin is on
152
+ // disk now, which another pi session may have changed.
153
+ client.setProfilePin(profilePin);
139
154
  }
140
155
 
141
156
  // Do NOT call client.start() here — browser control is on-demand.
@@ -147,6 +162,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
147
162
  toolsRegistered = true;
148
163
  }
149
164
  registerSetupCommand(pi, client);
165
+ registerProfileCommand(pi, client);
150
166
  registerDeepResearchCommand(pi);
151
167
  });
152
168
 
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Binding the harness session to a pinned profile.
3
+ *
4
+ * Sequence, all of it verified against a live browser:
5
+ * 1. resolve the browser binary and any explicit --user-data-dir
6
+ * 2. launch a window into the profile, marked with a file:// sentinel
7
+ * 3. poll Target.getTargets for the sentinel page — its browserContextId IS
8
+ * the profile's context, which is otherwise undiscoverable (CDP exposes no
9
+ * profile identity)
10
+ * 4. adopt that page as the harness window, blank it, drop the sentinel file
11
+ *
12
+ * If the sentinel never appears the bind FAILS LOUDLY. Falling through to a
13
+ * normal createTarget would put the agent in the focus-derived default profile
14
+ * — the exact bug this feature exists to remove — so the caller gets an
15
+ * actionable error instead.
16
+ *
17
+ * The resolved context id is session-scoped state, never persisted: Chrome
18
+ * mints new context ids on every browser run.
19
+ */
20
+
21
+ import type { BrowserClient } from "../client";
22
+ import { type CdpError, cdpError } from "../cdp/errors";
23
+ import { type Result, err, ok } from "../util/result";
24
+ import { detectRunningBrowser, resolveBrowserExecutable } from "./browser-process";
25
+ import { openProfileWindow } from "./launch";
26
+ import type { ProfilePin } from "./store";
27
+
28
+ /** The launch delegates through ProcessSingleton, so allow for a cold profile. */
29
+ const SEED_DEADLINE_MS = 15_000;
30
+ const SEED_POLL_MS = 500;
31
+
32
+ type PageTarget = {
33
+ readonly targetId: string;
34
+ readonly type: string;
35
+ readonly url: string;
36
+ readonly browserContextId?: string;
37
+ };
38
+
39
+ const pageTargets = async (client: BrowserClient): Promise<ReadonlyArray<PageTarget>> => {
40
+ const r = await client.session().callBrowser("Target.getTargets");
41
+ if (!r.success) return [];
42
+ const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
43
+ return data.targetInfos.filter((t) => t.type === "page");
44
+ };
45
+
46
+ const manualFallbackHint = (pin: ProfilePin): string =>
47
+ `open a window for "${pin.label}" yourself (browser profile menu → ${pin.profileDir}), then retry. ` +
48
+ `Run /browser-profile to pick a different profile, or clear the selection to use whichever window is focused.`;
49
+
50
+ /**
51
+ * Open (and adopt) a harness window inside the pinned profile. Returns the
52
+ * seed tab's target id.
53
+ */
54
+ export const seedProfileWindow = async (
55
+ client: BrowserClient,
56
+ pin: ProfilePin,
57
+ ): Promise<Result<string, CdpError>> => {
58
+ // A pin belongs to one user-data-dir. If the harness is now attached to a
59
+ // different browser, silently seeding the "same" profile name would be wrong.
60
+ const connectedDir = client.userDataDir();
61
+ if (connectedDir && connectedDir !== pin.userDataDir) {
62
+ return err(cdpError(
63
+ "discovery_failed",
64
+ `the selected profile "${pin.label}" belongs to ${pin.userDataDir}, but the harness is connected to ${connectedDir}. ` +
65
+ `Run /browser-profile to choose a profile in the browser you're using.`,
66
+ ));
67
+ }
68
+
69
+ // Identify the browser by the dir we're connected to. With several Chromium
70
+ // browsers running, launching into the wrong one would open a window the
71
+ // harness cannot see — in a browser the user never pointed us at.
72
+ const targetUserDataDir = connectedDir ?? pin.userDataDir;
73
+ const running = await detectRunningBrowser(targetUserDataDir);
74
+ const exePath = await resolveBrowserExecutable(running);
75
+ if (!exePath) {
76
+ return err(cdpError(
77
+ "discovery_failed",
78
+ `could not locate the browser executable needed to open "${pin.label}" — ${manualFallbackHint(pin)}`,
79
+ ));
80
+ }
81
+
82
+ const launched = await openProfileWindow({
83
+ exePath,
84
+ profileDir: pin.profileDir,
85
+ // Forward the user-data-dir ONLY when the running browser advertises one.
86
+ // ProcessSingleton keys on this path: passing the value the target process
87
+ // itself reports guarantees delegation, whereas supplying a default dir we
88
+ // reconstructed could differ by case or normalisation and start a SECOND
89
+ // browser instead.
90
+ ...(running.explicitUserDataDir ? { explicitUserDataDir: running.explicitUserDataDir } : {}),
91
+ });
92
+ if (!launched.success) {
93
+ return err(cdpError("discovery_failed", `${launched.error} — ${manualFallbackHint(pin)}`));
94
+ }
95
+
96
+ const { sentinelUrl, cleanup } = launched.data;
97
+ const deadline = Date.now() + SEED_DEADLINE_MS;
98
+ let seed: PageTarget | undefined;
99
+ while (Date.now() < deadline && !seed) {
100
+ await new Promise((r) => setTimeout(r, SEED_POLL_MS));
101
+ seed = (await pageTargets(client)).find((t) => t.url === sentinelUrl);
102
+ }
103
+
104
+ if (!seed) {
105
+ await cleanup();
106
+ return err(cdpError(
107
+ "timeout",
108
+ `couldn't open a window in "${pin.label}" automatically — ${manualFallbackHint(pin)}`,
109
+ ));
110
+ }
111
+
112
+ // The sentinel's context is the profile's context — the only way to learn it.
113
+ if (seed.browserContextId) client.setProfileContextId(seed.browserContextId);
114
+
115
+ const ownership = client.ownership();
116
+ ownership.setHarnessWindow(seed.targetId);
117
+ ownership.add(seed.targetId);
118
+ const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId: seed.targetId });
119
+ if (win.success) {
120
+ const windowId = (win.data as { windowId?: number }).windowId;
121
+ if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
122
+ }
123
+
124
+ // Blank the handshake page so the seed tab behaves like any other fresh tab,
125
+ // then remove the sentinel file. Best-effort: a failure here costs nothing
126
+ // beyond the placeholder page staying visible.
127
+ const attached = await client.session().callBrowser("Target.attachToTarget", {
128
+ targetId: seed.targetId,
129
+ flatten: true,
130
+ });
131
+ if (attached.success) {
132
+ const sessionId = (attached.data as { sessionId: string }).sessionId;
133
+ await client.session().callOnTarget("Page.navigate", { url: "about:blank" }, sessionId);
134
+ }
135
+ await cleanup();
136
+
137
+ return ok(seed.targetId);
138
+ };