pi-browser-harness 0.10.2 → 0.12.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +375 -265
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -37
  28. package/src/domains/console.ts +48 -4
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
@@ -1,27 +1,59 @@
1
- /**
2
- * /browser-profile — choose which browser profile the agent works in.
3
- *
4
- * The selection is durable (see store.ts) and applies immediately: the current
5
- * harness tabs are closed and a new window is opened in the chosen profile, so
6
- * the agent's next action happens where the user just asked for it rather than
7
- * one session later.
8
- */
9
-
10
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
11
2
  import type { BrowserClient } from "../client";
12
3
  import { discoverEndpoint } from "../cdp/discovery";
13
4
  import { type Result, err, ok } from "../util/result";
14
5
  import { type BrowserProfile, listProfiles } from "./list";
15
6
  import { browserNameForUserDataDir, userDataDirCandidates } from "./paths";
16
- import { pinFor, promptForProfile } from "./picker";
17
- import { clearPin, readPin, writePin } from "./store";
18
-
19
- /**
20
- * Which user-data-dir the picker should enumerate: the connected browser's
21
- * when known, otherwise a running browser's, otherwise the only installed one
22
- * that actually has profiles.
23
- */
24
- export const resolveUserDataDir = async (client: BrowserClient): Promise<Result<string, string>> => {
7
+ import { clearPin, type ProfilePin, readPin, writePin } from "./store";
8
+
9
+ // `ctx.ui.select` resolves to undefined both on Escape and in non-interactive modes, so "no answer" must mean "change nothing".
10
+
11
+ const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —";
12
+
13
+ type ProfileChoice =
14
+ | { readonly kind: "profile"; readonly profile: BrowserProfile }
15
+ | { readonly kind: "clear" }
16
+ | { readonly kind: "cancelled" };
17
+
18
+ const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => {
19
+ const counts = new Map<string, number>();
20
+ for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1);
21
+ return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label));
22
+ };
23
+
24
+ const promptForProfile = async (
25
+ ctx: ExtensionContext,
26
+ profiles: ReadonlyArray<BrowserProfile>,
27
+ currentPin: ProfilePin | null,
28
+ title = "Select browser profile",
29
+ ): Promise<ProfileChoice> => {
30
+ if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" };
31
+
32
+ const labels = disambiguate(profiles);
33
+ const rows = labels.map((label, i) => {
34
+ const isPinned = currentPin?.profileDir === profiles[i]?.dir;
35
+ return isPinned ? `✓ ${label}` : ` ${label}`;
36
+ });
37
+ const options = [...rows, CLEAR_SELECTION_LABEL];
38
+
39
+ const answer = await ctx.ui.select(title, options);
40
+ if (answer === undefined) return { kind: "cancelled" };
41
+ if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" };
42
+
43
+ const index = options.indexOf(answer);
44
+ const profile = index >= 0 ? profiles[index] : undefined;
45
+ if (!profile) return { kind: "cancelled" };
46
+ return { kind: "profile", profile };
47
+ };
48
+
49
+ const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({
50
+ userDataDir,
51
+ profileDir: profile.dir,
52
+ label: profile.label,
53
+ savedAt: new Date().toISOString(),
54
+ });
55
+
56
+ const resolveUserDataDir = async (client: BrowserClient): Promise<Result<string, string>> => {
25
57
  const connected = client.userDataDir();
26
58
  if (connected) return ok(connected);
27
59
 
@@ -43,7 +75,6 @@ export const resolveUserDataDir = async (client: BrowserClient): Promise<Result<
43
75
  );
44
76
  };
45
77
 
46
- /** Enumerate profiles for the resolved dir, or explain why we cannot. */
47
78
  const loadProfiles = async (
48
79
  client: BrowserClient,
49
80
  ): Promise<Result<{ userDataDir: string; profiles: ReadonlyArray<BrowserProfile> }, string>> => {
@@ -56,11 +87,6 @@ const loadProfiles = async (
56
87
  return ok({ userDataDir: dir.data, profiles });
57
88
  };
58
89
 
59
- /**
60
- * Re-seed the live session so the new profile takes effect now. Closing the
61
- * owned tabs and restarting the client makes start() open a window in the
62
- * pinned profile and attach to it.
63
- */
64
90
  const applyToLiveSession = async (client: BrowserClient): Promise<Result<void, string>> => {
65
91
  if (!client.status().alive) return ok(undefined);
66
92
  await client.closeOwnedTabs();
@@ -127,11 +153,6 @@ export function registerProfileCommand(pi: ExtensionAPI, client: BrowserClient):
127
153
  });
128
154
  }
129
155
 
130
- /**
131
- * First-run prompt used by setup: pick a profile when none is pinned yet.
132
- * Returns a note to append to the setup output; the caller always continues,
133
- * because a cancelled or unavailable picker must leave existing behavior intact.
134
- */
135
156
  export const promptForProfileIfUnset = async (
136
157
  client: BrowserClient,
137
158
  ctx: ExtensionContext | undefined,
@@ -1,19 +1,5 @@
1
- /**
2
- * Opening a browser window for a chosen profile.
3
- *
4
- * CDP cannot do this: `Target.createTarget` rejects another profile's
5
- * browserContextId ("Failed to find browser context with id"), and a bare
6
- * createTarget lands in `defaultBrowserContextId`, which follows window focus.
7
- * The only way in is the browser's own command line — Chromium's
8
- * ProcessSingleton hands a second launch's argv to the running browser, which
9
- * "does what it would have done", i.e. opens a window in `--profile-directory`.
10
- *
11
- * The window is then identified by a unique `file://` sentinel page. That
12
- * matters: a bare `about:blank#token` argument is discarded by Chrome (it opens
13
- * chrome://newtab instead), so the token has to ride on a real URL. Polling for
14
- * the sentinel URL — rather than diffing the target list — also keeps
15
- * identification correct when the user opens tabs at the same moment.
16
- */
1
+ // CDP cannot open a window in another profile, so this goes through the command line: ProcessSingleton hands the argv to the running browser.
2
+ // The sentinel must ride on a real `file://` URL — Chrome discards a bare `about:blank#token` argument and opens chrome://newtab instead.
17
3
 
18
4
  import { spawn, type ChildProcess } from "node:child_process";
19
5
  import { randomUUID } from "node:crypto";
@@ -24,23 +10,13 @@ import { pathToFileURL } from "node:url";
24
10
  import { type Result, err, ok } from "../util/result";
25
11
 
26
12
  export type ProfileWindowRequest = {
27
- /** Browser binary to invoke. */
28
13
  readonly exePath: string;
29
- /** Profile subdirectory, e.g. "Profile 1". */
30
14
  readonly profileDir: string;
31
- /**
32
- * Only set when the RUNNING browser was launched with an explicit
33
- * --user-data-dir. ProcessSingleton keys on this path, so passing one that
34
- * differs by case or normalisation would start a second browser instead of
35
- * delegating to the running one.
36
- */
37
15
  readonly explicitUserDataDir?: string;
38
16
  };
39
17
 
40
18
  export type ProfileWindowHandle = {
41
- /** file:// URL that will appear as the new window's page target. */
42
19
  readonly sentinelUrl: string;
43
- /** Remove the sentinel file. Safe to call more than once. */
44
20
  readonly cleanup: () => Promise<void>;
45
21
  readonly kill: () => void;
46
22
  };
@@ -49,10 +25,6 @@ const SENTINEL_HTML = (token: string): string =>
49
25
  `<!doctype html><meta charset="utf-8"><title>${token}</title>` +
50
26
  `<body style="font:14px system-ui;padding:2rem;color:#555">pi browser harness — opening this profile…</body>`;
51
27
 
52
- /**
53
- * Ask the running browser to open a window in `profileDir`, marked with a
54
- * unique sentinel page the caller can then find over CDP.
55
- */
56
28
  export const openProfileWindow = async (
57
29
  req: ProfileWindowRequest,
58
30
  ): Promise<Result<ProfileWindowHandle, string>> => {
@@ -69,29 +41,39 @@ export const openProfileWindow = async (
69
41
  if (req.explicitUserDataDir) args.push(`--user-data-dir=${req.explicitUserDataDir}`);
70
42
  args.push(`--profile-directory=${req.profileDir}`, "--new-window", sentinelUrl);
71
43
 
72
- let child: ChildProcess | null = null;
44
+ let child: ChildProcess;
73
45
  try {
74
- // No shell: arguments are passed as an array, so profile names and paths
75
- // containing spaces survive intact on every platform.
76
46
  child = spawn(req.exePath, args, { detached: true, stdio: "ignore" });
77
- child.unref();
78
- // The launcher process exits as soon as the running browser takes over the
79
- // command line; an error here means the binary itself could not be started.
80
- child.on("error", () => {});
81
47
  } catch (e) {
82
48
  await rm(file, { force: true }).catch(() => {});
83
49
  return err(`could not launch ${req.exePath}: ${e instanceof Error ? e.message : String(e)}`);
84
50
  }
85
51
 
52
+ // spawn() reports a bad binary asynchronously as an 'error' event rather than throwing; unhandled, an ENOENT looked like the 15s sentinel timeout.
53
+ const spawnFailure = await new Promise<string | undefined>((resolve) => {
54
+ child.once("spawn", () => resolve(undefined));
55
+ child.once("error", (e: Error) => resolve(e.message));
56
+ });
57
+ // A later 'error' event with no listener would throw, so keep one attached for the detached launcher's lifetime.
58
+ child.on("error", () => {});
59
+ child.unref();
60
+
61
+ if (spawnFailure) {
62
+ await rm(file, { force: true }).catch(() => {});
63
+ return err(`could not launch ${req.exePath}: ${spawnFailure}`);
64
+ }
65
+
66
+ let live: ChildProcess | null = child;
67
+
86
68
  return ok({
87
69
  sentinelUrl,
88
70
  cleanup: async () => {
89
71
  await rm(file, { force: true }).catch(() => {});
90
72
  },
91
73
  kill: () => {
92
- if (child) {
93
- try { child.kill("SIGTERM"); } catch {}
94
- child = null;
74
+ if (live) {
75
+ try { live.kill("SIGTERM"); } catch {}
76
+ live = null;
95
77
  }
96
78
  },
97
79
  });
@@ -1,76 +1,46 @@
1
- /**
2
- * Browser profile enumeration.
3
- *
4
- * A Chromium user-data-dir registers its profiles in `Local State` under the
5
- * JSON key `profile.info_cache`, with `profile.profiles_order` giving the
6
- * user's own ordering and `profile.last_used` naming the active one. Those
7
- * keys are declared in Chromium's pref_names.h with no platform BUILDFLAG, so
8
- * the layout is identical on Linux, macOS, and Windows (the C++ constant is
9
- * now `kProfileAttributes`; the on-disk key never changed).
10
- *
11
- * Enumeration is two-tier so a Chromium fork or a future schema change cannot
12
- * blank the list:
13
- * tier 1 — `Local State` → `profile.info_cache` (authoritative: display
14
- * names, accounts, ordering)
15
- * tier 2 — scan subdirectories holding a `Preferences` file and read
16
- * `profile.name` + `account_info[0].email`
17
- *
18
- * Nothing here throws: an unreadable or malformed file yields an empty list so
19
- * callers degrade to the harness's pre-existing behavior.
20
- */
21
-
22
1
  import { readdir, readFile } from "node:fs/promises";
23
2
  import { join } from "node:path";
3
+ import { Type } from "typebox";
4
+ import { Compile } from "typebox/compile";
5
+ import { parseJson } from "../schemas/parse";
24
6
 
25
7
  export type BrowserProfile = {
26
- /** Profile subdirectory name, e.g. "Default" or "Profile 1". */
27
8
  readonly dir: string;
28
- /** Display name as shown in the browser's profile menu. */
29
9
  readonly name: string;
30
- /** Signed-in account address, when the profile has one. */
31
10
  readonly email?: string;
32
- /** Picker label — "Name (email)", or "Name (dir)" with no account. */
33
11
  readonly label: string;
34
- /** True for the profile the browser reports as last used. */
35
12
  readonly lastUsed: boolean;
36
13
  };
37
14
 
38
- /** Directories inside a user-data-dir that are not user profiles. */
39
15
  const NON_PROFILE_DIRS: ReadonlySet<string> = new Set([
40
16
  "System Profile",
41
17
  "Guest Profile",
42
18
  ]);
43
19
 
44
- /**
45
- * Picker label. The bracketed half is the account address when there is one,
46
- * and the profile directory otherwise, so every row keeps the same shape.
47
- */
48
20
  export const formatProfileLabel = (name: string, email: string | undefined, dir: string): string =>
49
21
  `${name} (${email && email.length > 0 ? email : dir})`;
50
22
 
23
+ const JsonObject = Type.Record(Type.String(), Type.Unknown());
24
+
25
+ const jsonObjectValidator = Compile(JsonObject);
26
+
51
27
  const readJsonFile = async (path: string): Promise<Record<string, unknown> | null> => {
28
+ let raw: string;
52
29
  try {
53
- const raw = await readFile(path, "utf8");
54
- const parsed: unknown = JSON.parse(raw);
55
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
56
- return parsed as Record<string, unknown>;
30
+ raw = await readFile(path, "utf8");
57
31
  } catch {
58
- // Missing file, permission error, or malformed JSON — the caller falls
59
- // back to the next tier, and ultimately to an empty list.
60
32
  return null;
61
33
  }
34
+ const parsed = parseJson(raw, jsonObjectValidator);
35
+ return parsed.success ? parsed.data : null;
62
36
  };
63
37
 
64
38
  const asRecord = (v: unknown): Record<string, unknown> | null =>
65
- typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
39
+ jsonObjectValidator.Check(v) ? v : null;
66
40
 
67
41
  const asString = (v: unknown): string | undefined =>
68
42
  typeof v === "string" && v.length > 0 ? v : undefined;
69
43
 
70
- /**
71
- * Order profiles the way the browser does: `profiles_order` first (in its own
72
- * sequence), then anything unlisted, alphabetically.
73
- */
74
44
  const orderDirs = (dirs: ReadonlyArray<string>, order: ReadonlyArray<string>): ReadonlyArray<string> => {
75
45
  const known = order.filter((d) => dirs.includes(d));
76
46
  const rest = dirs.filter((d) => !known.includes(d)).sort((a, b) => a.localeCompare(b));
@@ -93,7 +63,6 @@ const buildProfile = (
93
63
  };
94
64
  };
95
65
 
96
- /** Tier 1 — `Local State` → `profile.info_cache`. */
97
66
  const listFromLocalState = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
98
67
  const localState = await readJsonFile(join(userDataDir, "Local State"));
99
68
  if (!localState) return [];
@@ -111,8 +80,7 @@ const listFromLocalState = async (userDataDir: string): Promise<ReadonlyArray<Br
111
80
  if (NON_PROFILE_DIRS.has(dir)) continue;
112
81
  const entry = asRecord(value);
113
82
  if (!entry) continue;
114
- // Ephemeral (guest-like) profiles vanish when their window closes — never
115
- // offer one as a durable pin.
83
+ // Ephemeral (guest-like) profiles vanish when their window closes, so they can never be a durable pin.
116
84
  if (entry["is_ephemeral"] === true) continue;
117
85
  entries.push({ dir, entry });
118
86
  }
@@ -123,16 +91,12 @@ const listFromLocalState = async (userDataDir: string): Promise<ReadonlyArray<Br
123
91
  return ordered.flatMap((dir) => {
124
92
  const entry = byDir.get(dir);
125
93
  if (!entry) return [];
126
- // `name` is the user-visible profile name; `gaia_name` is the Google
127
- // account's own display name and is the better fallback when a profile
128
- // still carries an auto-generated name like "Person 1".
129
94
  const name = asString(entry["name"]) ?? asString(entry["gaia_name"]);
130
95
  const email = asString(entry["user_name"]);
131
96
  return [buildProfile(dir, name, email, dir === lastUsed)];
132
97
  });
133
98
  };
134
99
 
135
- /** Tier 2 — directory scan, reading each profile's own `Preferences`. */
136
100
  const listFromPreferencesScan = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
137
101
  let dirents;
138
102
  try {
@@ -149,7 +113,7 @@ const listFromPreferencesScan = async (userDataDir: string): Promise<ReadonlyArr
149
113
  const profiles: BrowserProfile[] = [];
150
114
  for (const dir of candidates) {
151
115
  const prefs = await readJsonFile(join(userDataDir, dir, "Preferences"));
152
- if (!prefs) continue; // not a profile directory
116
+ if (!prefs) continue;
153
117
  const profileNode = asRecord(prefs["profile"]);
154
118
  const name = profileNode ? asString(profileNode["name"]) : undefined;
155
119
  const accounts = prefs["account_info"];
@@ -160,10 +124,6 @@ const listFromPreferencesScan = async (userDataDir: string): Promise<ReadonlyArr
160
124
  return profiles;
161
125
  };
162
126
 
163
- /**
164
- * Every selectable profile in a user-data-dir, best-effort. Returns an empty
165
- * array when the directory is unreadable or holds no profiles.
166
- */
167
127
  export const listProfiles = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
168
128
  const fromLocalState = await listFromLocalState(userDataDir);
169
129
  if (fromLocalState.length > 0) return fromLocalState;
@@ -1,92 +1,37 @@
1
- /**
2
- * Filesystem locations for browser profile discovery and pin persistence.
3
- *
4
- * Two independent path families live here:
5
- *
6
- * 1. Chromium user-data-dir candidates, per platform and channel. A
7
- * user-data-dir holds `Local State` (the profile registry) and
8
- * `DevToolsActivePort`; individual profiles are subdirectories of it
9
- * (`Default`, `Profile 1`, …).
10
- * Reference: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/user_data_dir.md
11
- *
12
- * 2. pi's agent config dir, where the profile pin is stored.
13
- *
14
- * On the agent dir: pi exports `getAgentDir()` from
15
- * `@mariozechner/pi-coding-agent`, but importing it would be a RUNTIME import
16
- * of the host package. Extensions installed via `pi install npm:...` do not
17
- * get pi resolvable from their own node_modules (verified: the installed
18
- * tree's `@mariozechner/` directory is empty), which is why every other import
19
- * in this codebase is `import type` and erased at build time. So the three
20
- * lines of `getAgentDir()` are replicated here instead — env var first, then
21
- * `~/.pi/agent` (pi's dist/config.js: `PI_CODING_AGENT_DIR`, CONFIG_DIR_NAME
22
- * `.pi`).
23
- */
1
+ // pi's `getAgentDir()` is replicated rather than imported: a runtime import of the host package does not resolve for extensions installed via `pi install npm:...`.
24
2
 
25
3
  import { homedir } from "node:os";
26
4
  import { join } from "node:path";
27
5
 
28
- // ── pi agent config dir ────────────────────────────────────────────────────
29
-
30
- /** pi's env override for the agent dir (APP_NAME.toUpperCase() + "_CODING_AGENT_DIR"). */
31
6
  const ENV_AGENT_DIR = "PI_CODING_AGENT_DIR";
32
7
 
33
- /**
34
- * Expand a leading `~` exactly as pi's expandTildePath does — `~` and `~/…`
35
- * only, never `~\…`, on every platform. This deliberately mirrors pi rather
36
- * than improving on it: the pin has to land in the directory pi itself resolves
37
- * from the same variable, so being more lenient here would put the file
38
- * somewhere pi does not look.
39
- */
8
+ // Deliberately mirrors pi's expandTildePath (never `~\…`) rather than improving on it, or the pin lands somewhere pi does not look.
40
9
  const expandTildePiCompatible = (path: string): string => {
41
10
  if (path === "~") return homedir();
42
11
  if (path.startsWith("~/")) return homedir() + path.slice(1);
43
12
  return path;
44
13
  };
45
14
 
46
- /**
47
- * Tilde expansion for values this package interprets on its own. Windows users
48
- * write `~\dir` as naturally as `~/dir`, and no compatibility constraint
49
- * applies, so both are accepted.
50
- */
51
15
  const expandTildeLenient = (path: string): string => {
52
16
  if (path === "~") return homedir();
53
17
  if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
54
18
  return path;
55
19
  };
56
20
 
57
- /** pi's agent config directory — `$PI_CODING_AGENT_DIR` or `~/.pi/agent`. */
58
21
  export const agentDir = (): string => {
59
22
  const fromEnv = process.env[ENV_AGENT_DIR];
60
23
  if (fromEnv) return expandTildePiCompatible(fromEnv);
61
24
  return join(homedir(), ".pi", "agent");
62
25
  };
63
26
 
64
- /** Where the selected-profile pin is persisted. */
65
27
  export const pinFilePath = (): string => join(agentDir(), "browser-harness.json");
66
28
 
67
- // ── Chromium user-data-dir candidates ──────────────────────────────────────
68
-
69
- /**
70
- * Windows resolves its per-user data root from %LOCALAPPDATA%, which can be
71
- * redirected on roaming / managed accounts. Fall back to the conventional
72
- * layout only when the variable is absent.
73
- */
74
29
  const localAppData = (): string => process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local");
75
30
 
76
- /**
77
- * Every known default user-data-dir for Chromium-family browsers on the
78
- * current platform. Order is stable but carries no priority — callers probe
79
- * all of them.
80
- *
81
- * Includes paths the harness previously missed: snap Chromium (the default
82
- * Chromium on Ubuntu) and the Linux-only `$CHROME_USER_DATA_DIR` override
83
- * documented in user_data_dir.md.
84
- */
85
31
  export const userDataDirCandidates = (): ReadonlyArray<string> => {
86
32
  const home = homedir();
87
33
  const dirs: string[] = [];
88
34
 
89
- // A user-set override wins a spot at the front regardless of platform.
90
35
  const envOverride = process.env["CHROME_USER_DATA_DIR"];
91
36
  if (envOverride) dirs.push(expandTildeLenient(envOverride));
92
37
 
@@ -135,9 +80,7 @@ export const userDataDirCandidates = (): ReadonlyArray<string> => {
135
80
  join(home, ".config/microsoft-edge"),
136
81
  join(home, ".config/microsoft-edge-beta"),
137
82
  join(home, ".config/microsoft-edge-dev"),
138
- // snap (Ubuntu's default Chromium) — confined to ~/snap/<pkg>/common
139
83
  join(home, "snap/chromium/common/chromium"),
140
- // flatpak
141
84
  join(home, ".var/app/org.chromium.Chromium/config/chromium"),
142
85
  join(home, ".var/app/com.google.Chrome/config/google-chrome"),
143
86
  join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
@@ -145,15 +88,9 @@ export const userDataDirCandidates = (): ReadonlyArray<string> => {
145
88
  );
146
89
  }
147
90
 
148
- // De-duplicate while preserving order — an env override may repeat a default.
149
91
  return Array.from(new Set(dirs));
150
92
  };
151
93
 
152
- /**
153
- * Human-readable browser name for a user-data-dir, used to disambiguate the
154
- * picker when more than one browser is installed. Falls back to the directory
155
- * name, which is already descriptive for every path we generate.
156
- */
157
94
  export const browserNameForUserDataDir = (userDataDir: string): string => {
158
95
  const p = userDataDir.replace(/\\/g, "/");
159
96
  const has = (needle: string): boolean => p.toLowerCase().includes(needle);
@@ -1,34 +1,18 @@
1
- /**
2
- * Durable persistence for the selected browser profile.
3
- *
4
- * The pin must outlive session termination, so it cannot live in pi's session
5
- * entries (`pi.appendEntry`, see src/state.ts) — those die with the session.
6
- * It is a small JSON file in pi's agent config dir instead, shared by every
7
- * project on the machine.
8
- *
9
- * The pin deliberately stores only `{ userDataDir, profileDir }`. A
10
- * `browserContextId` is NOT persisted: Chrome mints fresh context ids on every
11
- * browser run, so a stored one would be a stale pointer at best and a pointer
12
- * into the wrong profile at worst. The context is re-resolved on each connect.
13
- *
14
- * Every read failure degrades to "no pin" rather than throwing — a corrupt
15
- * file must never break browser control.
16
- */
1
+ // A `browserContextId` is deliberately NOT persisted: Chrome mints fresh context ids per browser run, so a stored one could point into the wrong profile.
17
2
 
18
3
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
19
4
  import { randomUUID } from "node:crypto";
20
5
  import { dirname } from "node:path";
6
+ import { Type } from "typebox";
7
+ import { Compile } from "typebox/compile";
8
+ import { parseJson } from "../schemas/parse";
21
9
  import { type Result, err, ok } from "../util/result";
22
10
  import { pinFilePath } from "./paths";
23
11
 
24
12
  export type ProfilePin = {
25
- /** User-data-dir the profile belongs to — guards against browser switches. */
26
13
  readonly userDataDir: string;
27
- /** Profile subdirectory, e.g. "Profile 1". */
28
14
  readonly profileDir: string;
29
- /** Label captured at selection time, for display without a disk re-read. */
30
15
  readonly label: string;
31
- /** ISO timestamp of the selection. */
32
16
  readonly savedAt: string;
33
17
  };
34
18
 
@@ -39,28 +23,27 @@ type PinFile = {
39
23
 
40
24
  const CURRENT_VERSION = 1;
41
25
 
42
- const parsePin = (value: unknown): ProfilePin | null => {
43
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
44
- const o = value as Record<string, unknown>;
45
- const userDataDir = o["userDataDir"];
46
- const profileDir = o["profileDir"];
47
- const label = o["label"];
48
- const savedAt = o["savedAt"];
49
- if (typeof userDataDir !== "string" || userDataDir.length === 0) return null;
50
- if (typeof profileDir !== "string" || profileDir.length === 0) return null;
51
- return {
52
- userDataDir,
53
- profileDir,
54
- label: typeof label === "string" && label.length > 0 ? label : profileDir,
55
- savedAt: typeof savedAt === "string" ? savedAt : "",
56
- };
57
- };
26
+ const PinFileSchema = Type.Object(
27
+ {
28
+ version: Type.Literal(CURRENT_VERSION),
29
+ profile: Type.Union([
30
+ Type.Object(
31
+ {
32
+ userDataDir: Type.String({ minLength: 1 }),
33
+ profileDir: Type.String({ minLength: 1 }),
34
+ label: Type.Optional(Type.Unknown()),
35
+ savedAt: Type.Optional(Type.Unknown()),
36
+ },
37
+ { additionalProperties: true },
38
+ ),
39
+ Type.Null(),
40
+ ]),
41
+ },
42
+ { additionalProperties: true },
43
+ );
44
+
45
+ const pinFileValidator = Compile(PinFileSchema);
58
46
 
59
- /**
60
- * The persisted pin, or null when nothing is pinned, the file is missing, or
61
- * it cannot be parsed. A file written by a newer version is treated as "no
62
- * pin" rather than guessed at.
63
- */
64
47
  export const readPin = async (): Promise<ProfilePin | null> => {
65
48
  let raw: string;
66
49
  try {
@@ -68,21 +51,19 @@ export const readPin = async (): Promise<ProfilePin | null> => {
68
51
  } catch {
69
52
  return null;
70
53
  }
71
- try {
72
- const parsed: unknown = JSON.parse(raw);
73
- if (typeof parsed !== "object" || parsed === null) return null;
74
- const file = parsed as Record<string, unknown>;
75
- if (file["version"] !== CURRENT_VERSION) return null;
76
- return parsePin(file["profile"]);
77
- } catch {
78
- return null;
79
- }
54
+ const parsed = parseJson(raw, pinFileValidator);
55
+ if (!parsed.success) return null;
56
+ const profile = parsed.data.profile;
57
+ if (profile === null) return null;
58
+ const { userDataDir, profileDir, label, savedAt } = profile;
59
+ return {
60
+ userDataDir,
61
+ profileDir,
62
+ label: typeof label === "string" && label.length > 0 ? label : profileDir,
63
+ savedAt: typeof savedAt === "string" ? savedAt : "",
64
+ };
80
65
  };
81
66
 
82
- /**
83
- * Replace the pin atomically: a sibling temp file plus rename, so a crash
84
- * mid-write can never leave a half-written pin behind.
85
- */
86
67
  const writePinFile = async (profile: ProfilePin | null): Promise<Result<void, string>> => {
87
68
  const target = pinFilePath();
88
69
  const tmp = `${target}.${randomUUID()}.tmp`;
@@ -98,8 +79,6 @@ const writePinFile = async (profile: ProfilePin | null): Promise<Result<void, st
98
79
  }
99
80
  };
100
81
 
101
- /** Persist the selected profile. */
102
82
  export const writePin = (pin: ProfilePin): Promise<Result<void, string>> => writePinFile(pin);
103
83
 
104
- /** Clear the selection, restoring the harness's unpinned behavior. */
105
84
  export const clearPin = (): Promise<Result<void, string>> => writePinFile(null);