pi-browser-harness 0.12.0 → 0.14.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.
- package/CHANGELOG.md +56 -0
- package/README.md +132 -0
- package/package.json +15 -7
- package/skills/pi-browser-harness/SKILL.md +89 -2
- package/src/cdp/commands.ts +205 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +57 -25
- package/src/cdp/events.ts +9 -3
- package/src/cdp/network-buffer.ts +38 -32
- package/src/cdp/ownership.ts +22 -4
- package/src/cdp/record-store.ts +2 -2
- package/src/cdp/session.ts +358 -216
- package/src/cdp/target-factory.ts +34 -15
- package/src/cdp/types.ts +17 -1
- package/src/client.ts +127 -54
- package/src/daemon/bridge.ts +110 -349
- package/src/daemon/connection.ts +323 -0
- package/src/daemon/index.ts +77 -48
- package/src/daemon/lines.ts +40 -0
- package/src/daemon/main.ts +12 -0
- package/src/daemon/protocol.ts +55 -25
- package/src/daemon/server.ts +100 -63
- package/src/daemon/socket.ts +10 -0
- package/src/daemon/spawn.ts +21 -22
- package/src/daemon/transport.ts +80 -49
- package/src/domains/ax-tree.ts +27 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -2
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -64
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +13 -9
- package/src/domains/form-batch.ts +13 -10
- package/src/domains/form.ts +78 -107
- package/src/domains/headers.ts +2 -2
- package/src/domains/history.ts +4 -4
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +67 -46
- package/src/domains/keyboard.ts +14 -11
- package/src/domains/navigate.ts +102 -66
- package/src/domains/network.ts +55 -95
- package/src/domains/page/fill.page.ts +139 -0
- package/src/domains/page/form.page.ts +61 -0
- package/src/domains/page/inject.ts +37 -0
- package/src/domains/page/keyboard.page.ts +16 -0
- package/src/domains/page/live-value.page.ts +10 -0
- package/src/domains/page/read-page.page.ts +51 -0
- package/src/domains/page/recipe.page.ts +82 -0
- package/src/domains/page/serp.page.ts +42 -0
- package/src/domains/page.ts +18 -51
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -53
- package/src/domains/ref-resolve.ts +43 -29
- package/src/domains/ref-signature.ts +11 -0
- package/src/domains/render.ts +12 -23
- package/src/domains/screenshot-capture.ts +4 -2
- package/src/domains/screenshot.ts +13 -10
- package/src/domains/scroll.ts +4 -4
- package/src/domains/search/google-serp.ts +78 -8
- package/src/domains/search/redirect-resolver.ts +41 -0
- package/src/domains/search/web-search.ts +103 -40
- package/src/domains/snapshot.ts +81 -78
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +18 -69
- package/src/domains/target.ts +1 -1
- package/src/index.ts +101 -33
- package/src/profile/bind.ts +45 -14
- package/src/profile/browser-process.ts +50 -12
- package/src/profile/command.ts +20 -12
- package/src/profile/launch.ts +13 -9
- package/src/profile/list.ts +9 -13
- package/src/profile/store.ts +2 -1
- package/src/prompt.ts +34 -8
- package/src/recipes/command.ts +96 -0
- package/src/recipes/compile.ts +198 -0
- package/src/recipes/find.ts +49 -0
- package/src/recipes/journal.ts +125 -0
- package/src/recipes/recipe.ts +215 -0
- package/src/recipes/recordable.ts +359 -0
- package/src/recipes/replay.ts +354 -0
- package/src/recipes/run-tool.ts +132 -0
- package/src/recipes/save-tool.ts +247 -0
- package/src/recipes/store.ts +103 -0
- package/src/recipes/trust.ts +172 -0
- package/src/registry.ts +69 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +2 -1
- package/src/util/debug.ts +7 -1
- package/src/util/guards.ts +3 -0
- package/src/util/js-source.ts +28 -0
- package/src/util/mutex.ts +1 -1
- package/src/util/paths.ts +3 -2
- package/src/util/result.ts +8 -0
- package/src/util/sharp-shim.ts +18 -11
- package/src/util/sleep.ts +18 -0
- package/src/util/tool.ts +185 -114
- package/src/util/truncate.ts +1 -1
- package/tsconfig.json +2 -1
- package/src/domains/fill-engine.ts +0 -80
package/src/profile/bind.ts
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
import type { BrowserClient } from "../client";
|
|
2
2
|
import type { ResultOf } from "../cdp/commands";
|
|
3
3
|
import { type CdpError, cdpError } from "../cdp/errors";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { borrowSession } from "../cdp/target-factory";
|
|
5
|
+
import { type Result, err, map, ok, unwrapOr } from "../util/result";
|
|
6
|
+
import { sleep as sleepMs } from "../util/sleep";
|
|
7
|
+
import { detectRunningBrowser, resolveBrowserExecutable, type RunningBrowser } from "./browser-process";
|
|
8
|
+
import { openProfileWindow, type ProfileWindowHandle, type ProfileWindowRequest } from "./launch";
|
|
7
9
|
import type { ProfilePin } from "./store";
|
|
8
10
|
|
|
9
11
|
const SEED_DEADLINE_MS = 15_000;
|
|
10
12
|
const SEED_POLL_MS = 500;
|
|
11
13
|
|
|
14
|
+
// The machine-facing steps of seeding: process detection, executable lookup, the window launch
|
|
15
|
+
// and the sentinel poll's clock. Each defaults to the real implementation.
|
|
16
|
+
export type SeedDeps = {
|
|
17
|
+
readonly detectRunningBrowser?: (preferUserDataDir?: string) => Promise<RunningBrowser>;
|
|
18
|
+
readonly resolveBrowserExecutable?: (running?: RunningBrowser) => Promise<string | undefined>;
|
|
19
|
+
readonly openProfileWindow?: (req: ProfileWindowRequest) => Promise<Result<ProfileWindowHandle, string>>;
|
|
20
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
21
|
+
readonly now?: () => number;
|
|
22
|
+
readonly deadlineMs?: number;
|
|
23
|
+
readonly pollMs?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
|
|
12
27
|
type PageTarget = ResultOf<"Target.getTargets">["targetInfos"][number];
|
|
13
28
|
|
|
14
29
|
const pageTargets = async (client: BrowserClient): Promise<ReadonlyArray<PageTarget>> => {
|
|
15
30
|
const r = await client.session().callBrowser("Target.getTargets");
|
|
16
|
-
|
|
17
|
-
|
|
31
|
+
return unwrapOr(
|
|
32
|
+
map(r, (d): ReadonlyArray<PageTarget> => d.targetInfos.filter((t) => t.type === "page")),
|
|
33
|
+
[],
|
|
34
|
+
);
|
|
18
35
|
};
|
|
19
36
|
|
|
20
37
|
const manualFallbackHint = (pin: ProfilePin): string =>
|
|
@@ -24,7 +41,16 @@ const manualFallbackHint = (pin: ProfilePin): string =>
|
|
|
24
41
|
export const seedProfileWindow = async (
|
|
25
42
|
client: BrowserClient,
|
|
26
43
|
pin: ProfilePin,
|
|
44
|
+
deps: SeedDeps = {},
|
|
27
45
|
): Promise<Result<string, CdpError>> => {
|
|
46
|
+
const detect = deps.detectRunningBrowser ?? detectRunningBrowser;
|
|
47
|
+
const resolveExe = deps.resolveBrowserExecutable ?? resolveBrowserExecutable;
|
|
48
|
+
const launch = deps.openProfileWindow ?? openProfileWindow;
|
|
49
|
+
const sleep = deps.sleep ?? sleepMs;
|
|
50
|
+
const now = deps.now ?? Date.now;
|
|
51
|
+
const deadlineMs = deps.deadlineMs ?? SEED_DEADLINE_MS;
|
|
52
|
+
const pollMs = deps.pollMs ?? SEED_POLL_MS;
|
|
53
|
+
|
|
28
54
|
const connectedDir = client.userDataDir();
|
|
29
55
|
if (connectedDir && connectedDir !== pin.userDataDir) {
|
|
30
56
|
return err(cdpError(
|
|
@@ -35,8 +61,8 @@ export const seedProfileWindow = async (
|
|
|
35
61
|
}
|
|
36
62
|
|
|
37
63
|
const targetUserDataDir = connectedDir ?? pin.userDataDir;
|
|
38
|
-
const running = await
|
|
39
|
-
const exePath = await
|
|
64
|
+
const running = await detect(targetUserDataDir);
|
|
65
|
+
const exePath = await resolveExe(running);
|
|
40
66
|
if (!exePath) {
|
|
41
67
|
return err(cdpError(
|
|
42
68
|
"discovery_failed",
|
|
@@ -44,7 +70,7 @@ export const seedProfileWindow = async (
|
|
|
44
70
|
));
|
|
45
71
|
}
|
|
46
72
|
|
|
47
|
-
const launched = await
|
|
73
|
+
const launched = await launch({
|
|
48
74
|
exePath,
|
|
49
75
|
profileDir: pin.profileDir,
|
|
50
76
|
// ProcessSingleton keys on the user-data-dir path, so forward only the value the running browser reports: a reconstructed one may differ by case and start a SECOND browser.
|
|
@@ -55,10 +81,10 @@ export const seedProfileWindow = async (
|
|
|
55
81
|
}
|
|
56
82
|
|
|
57
83
|
const { sentinelUrl, cleanup, kill } = launched.data;
|
|
58
|
-
const deadline =
|
|
84
|
+
const deadline = now() + deadlineMs;
|
|
59
85
|
let seed: PageTarget | undefined;
|
|
60
|
-
while (
|
|
61
|
-
await
|
|
86
|
+
while (now() < deadline && !seed) {
|
|
87
|
+
await sleep(pollMs);
|
|
62
88
|
seed = (await pageTargets(client)).find((t) => t.url === sentinelUrl);
|
|
63
89
|
}
|
|
64
90
|
|
|
@@ -80,9 +106,14 @@ export const seedProfileWindow = async (
|
|
|
80
106
|
const win = await client.session().windowId(seed.targetId);
|
|
81
107
|
if (win.success) ownership.setHarnessWindowId(win.data);
|
|
82
108
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
109
|
+
// Clears the sentinel URL from the seed tab. The session is borrowed and released so an untracked attach never outlives this call.
|
|
110
|
+
const borrowed = await borrowSession(client, seed.targetId);
|
|
111
|
+
if (borrowed.success) {
|
|
112
|
+
try {
|
|
113
|
+
await client.session().callOnTarget("Page.navigate", { url: "about:blank" }, borrowed.data.sessionId);
|
|
114
|
+
} finally {
|
|
115
|
+
await borrowed.data.release();
|
|
116
|
+
}
|
|
86
117
|
}
|
|
87
118
|
await cleanup();
|
|
88
119
|
|
|
@@ -23,6 +23,31 @@ const LINUX_COMMS: ReadonlyArray<string> = [
|
|
|
23
23
|
"chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge",
|
|
24
24
|
"google-chrome", "brave", "brave-browser",
|
|
25
25
|
];
|
|
26
|
+
|
|
27
|
+
// The kernel stores a process name in TASK_COMM_LEN (16) bytes, so `ps -o comm=` prints at most 15
|
|
28
|
+
// characters: "chromium-browser" shows up as "chromium-browse" and would never match in full.
|
|
29
|
+
const LINUX_COMM_MAX = 15;
|
|
30
|
+
|
|
31
|
+
export const isLinuxBrowserComm = (comm: string): boolean => {
|
|
32
|
+
const lower = comm.toLowerCase();
|
|
33
|
+
return LINUX_COMMS.some((name) => name.slice(0, LINUX_COMM_MAX) === lower);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// /proc/<pid>/cmdline separates arguments with NUL, which keeps a space inside argv[0] or a
|
|
37
|
+
// --user-data-dir value intact. A string without NULs (a test fixture, or a zombie's empty
|
|
38
|
+
// cmdline) falls back to whitespace splitting.
|
|
39
|
+
export const parseProcCmdline = (raw: string): ReadonlyArray<string> => {
|
|
40
|
+
const argv = raw.includes("\0") ? raw.split("\0") : raw.trim().split(/\s+/);
|
|
41
|
+
return argv.filter((arg) => arg.length > 0);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const USER_DATA_DIR_FLAG = "--user-data-dir=";
|
|
45
|
+
|
|
46
|
+
export const userDataDirFromArgv = (argv: ReadonlyArray<string>): string | undefined => {
|
|
47
|
+
const flag = argv.find((arg) => arg.startsWith(USER_DATA_DIR_FLAG));
|
|
48
|
+
const value = flag?.slice(USER_DATA_DIR_FLAG.length);
|
|
49
|
+
return value !== undefined && value.length > 0 ? value : undefined;
|
|
50
|
+
};
|
|
26
51
|
const MAC_BROWSER_NAMES: ReadonlyArray<string> = [
|
|
27
52
|
"google chrome", "chromium", "microsoft edge", "brave browser",
|
|
28
53
|
];
|
|
@@ -116,7 +141,7 @@ const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
|
|
|
116
141
|
.flatMap((line) => {
|
|
117
142
|
const [pid, comm] = line.split(/\s+/, 2);
|
|
118
143
|
if (!pid || !comm) return [];
|
|
119
|
-
return
|
|
144
|
+
return isLinuxBrowserComm(comm) ? [pid] : [];
|
|
120
145
|
});
|
|
121
146
|
} catch {
|
|
122
147
|
return [];
|
|
@@ -124,22 +149,25 @@ const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
|
|
|
124
149
|
|
|
125
150
|
const candidates: BrowserCandidate[] = [];
|
|
126
151
|
for (const pid of pids) {
|
|
127
|
-
let
|
|
152
|
+
let argv: ReadonlyArray<string>;
|
|
128
153
|
try {
|
|
129
|
-
|
|
154
|
+
argv = parseProcCmdline(await readFile(`/proc/${pid}/cmdline`, "utf8"));
|
|
130
155
|
} catch {
|
|
131
156
|
continue;
|
|
132
157
|
}
|
|
133
|
-
|
|
158
|
+
const argv0 = argv[0];
|
|
159
|
+
if (argv0 === undefined || hasChildProcessType(argv.join(" "))) continue;
|
|
134
160
|
let link: string | undefined;
|
|
135
161
|
try {
|
|
136
162
|
link = await readlink(`/proc/${pid}/exe`);
|
|
137
|
-
} catch {
|
|
163
|
+
} catch {
|
|
164
|
+
// /proc/<pid>/exe is unreadable for processes owned by another user; argv[0] is used instead.
|
|
165
|
+
}
|
|
138
166
|
// Chrome rewrites its argv to the bare executable path, so argv[0] is the live install location when /proc/<pid>/exe is unreadable or stale.
|
|
139
167
|
const exePath =
|
|
140
168
|
(link ? await spawnableExePath(link) : undefined) ??
|
|
141
|
-
(await spawnableExePath(
|
|
142
|
-
const explicit =
|
|
169
|
+
(await spawnableExePath(argv0));
|
|
170
|
+
const explicit = userDataDirFromArgv(argv);
|
|
143
171
|
candidates.push({
|
|
144
172
|
...(exePath ? { exePath } : {}),
|
|
145
173
|
...(explicit ? { explicitUserDataDir: explicit } : {}),
|
|
@@ -172,7 +200,9 @@ const collectDarwin = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
|
|
|
172
200
|
try {
|
|
173
201
|
const { stdout } = await run("ps", ["-ww", "-o", "args=", "-p", pid], { timeout: EXEC_TIMEOUT_MS });
|
|
174
202
|
args = stdout.trim();
|
|
175
|
-
} catch {
|
|
203
|
+
} catch {
|
|
204
|
+
// ps can fail for a process that exited meanwhile; the pid is still reported, without args.
|
|
205
|
+
}
|
|
176
206
|
if (args && hasChildProcessType(args)) continue;
|
|
177
207
|
const explicit = args ? parseUserDataDirFlag(args) : undefined;
|
|
178
208
|
candidates.push({
|
|
@@ -227,13 +257,17 @@ const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
|
|
|
227
257
|
if (candidates.length > 0) return candidates;
|
|
228
258
|
}
|
|
229
259
|
}
|
|
230
|
-
} catch {
|
|
260
|
+
} catch {
|
|
261
|
+
// PowerShell is missing or restricted: fall back to tasklist below.
|
|
262
|
+
}
|
|
231
263
|
|
|
232
264
|
try {
|
|
233
265
|
const { stdout } = await run("tasklist.exe", [], { timeout: EXEC_TIMEOUT_MS, windowsHide: true });
|
|
234
266
|
const lower = stdout.toLowerCase();
|
|
235
267
|
if (WIN_IMAGE_NAMES.some((n) => lower.includes(n))) return [{}];
|
|
236
|
-
} catch {
|
|
268
|
+
} catch {
|
|
269
|
+
// tasklist unavailable too: report no running browser rather than failing detection.
|
|
270
|
+
}
|
|
237
271
|
return [];
|
|
238
272
|
};
|
|
239
273
|
|
|
@@ -266,7 +300,9 @@ const winRegistryExecutable = async (): Promise<string | undefined> => {
|
|
|
266
300
|
const match = /REG_[A-Z_]+\s+(.+)$/m.exec(stdout.trim());
|
|
267
301
|
const path = match?.[1]?.trim();
|
|
268
302
|
if (path) return path;
|
|
269
|
-
} catch {
|
|
303
|
+
} catch {
|
|
304
|
+
// Key absent or reg.exe failed: try the next registry location.
|
|
305
|
+
}
|
|
270
306
|
}
|
|
271
307
|
}
|
|
272
308
|
return undefined;
|
|
@@ -314,7 +350,9 @@ export const resolveBrowserExecutable = async (running?: RunningBrowser): Promis
|
|
|
314
350
|
try {
|
|
315
351
|
await access(candidate);
|
|
316
352
|
return candidate;
|
|
317
|
-
} catch {
|
|
353
|
+
} catch {
|
|
354
|
+
// Not installed at this location: try the next one.
|
|
355
|
+
}
|
|
318
356
|
}
|
|
319
357
|
return undefined;
|
|
320
358
|
};
|
package/src/profile/command.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import type { BrowserClient } from "../client";
|
|
3
|
-
import { discoverEndpoint } from "../cdp/discovery";
|
|
4
|
-
import
|
|
3
|
+
import { type CdpEndpoint, discoverEndpoint } from "../cdp/discovery";
|
|
4
|
+
import type { CdpError } from "../cdp/errors";
|
|
5
|
+
import { type Result, err, mapErr, ok } from "../util/result";
|
|
5
6
|
import { type BrowserProfile, listProfiles } from "./list";
|
|
6
7
|
import { browserNameForUserDataDir, userDataDirCandidates } from "./paths";
|
|
7
8
|
import { clearPin, type ProfilePin, readPin, writePin } from "./store";
|
|
@@ -53,15 +54,22 @@ const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({
|
|
|
53
54
|
savedAt: new Date().toISOString(),
|
|
54
55
|
});
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
// How the command finds a user-data-dir when the client is not connected: the DevTools endpoint's
|
|
58
|
+
// own report, then a scan of the per-platform candidate directories. Each defaults to the real one.
|
|
59
|
+
export type ProfileCommandDeps = {
|
|
60
|
+
readonly discoverEndpoint?: () => Promise<Result<CdpEndpoint, CdpError>>;
|
|
61
|
+
readonly userDataDirCandidates?: () => ReadonlyArray<string>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const resolveUserDataDir = async (client: BrowserClient, deps: ProfileCommandDeps): Promise<Result<string, string>> => {
|
|
57
65
|
const connected = client.userDataDir();
|
|
58
66
|
if (connected) return ok(connected);
|
|
59
67
|
|
|
60
|
-
const endpoint = await discoverEndpoint();
|
|
68
|
+
const endpoint = await (deps.discoverEndpoint ?? discoverEndpoint)();
|
|
61
69
|
if (endpoint.success && endpoint.data.userDataDir) return ok(endpoint.data.userDataDir);
|
|
62
70
|
|
|
63
71
|
const withProfiles: string[] = [];
|
|
64
|
-
for (const dir of userDataDirCandidates()) {
|
|
72
|
+
for (const dir of (deps.userDataDirCandidates ?? userDataDirCandidates)()) {
|
|
65
73
|
if ((await listProfiles(dir)).length > 0) withProfiles.push(dir);
|
|
66
74
|
}
|
|
67
75
|
const only = withProfiles[0];
|
|
@@ -77,8 +85,9 @@ const resolveUserDataDir = async (client: BrowserClient): Promise<Result<string,
|
|
|
77
85
|
|
|
78
86
|
const loadProfiles = async (
|
|
79
87
|
client: BrowserClient,
|
|
88
|
+
deps: ProfileCommandDeps,
|
|
80
89
|
): Promise<Result<{ userDataDir: string; profiles: ReadonlyArray<BrowserProfile> }, string>> => {
|
|
81
|
-
const dir = await resolveUserDataDir(client);
|
|
90
|
+
const dir = await resolveUserDataDir(client, deps);
|
|
82
91
|
if (!dir.success) return dir;
|
|
83
92
|
const profiles = await listProfiles(dir.data);
|
|
84
93
|
if (profiles.length === 0) {
|
|
@@ -91,16 +100,14 @@ const applyToLiveSession = async (client: BrowserClient): Promise<Result<void, s
|
|
|
91
100
|
if (!client.status().alive) return ok(undefined);
|
|
92
101
|
await client.closeOwnedTabs();
|
|
93
102
|
await client.stop();
|
|
94
|
-
|
|
95
|
-
if (!started.success) return err(started.error.message);
|
|
96
|
-
return ok(undefined);
|
|
103
|
+
return mapErr(await client.start(), (e) => e.message);
|
|
97
104
|
};
|
|
98
105
|
|
|
99
|
-
export function registerProfileCommand(pi: ExtensionAPI, client: BrowserClient): void {
|
|
106
|
+
export function registerProfileCommand(pi: ExtensionAPI, client: BrowserClient, deps: ProfileCommandDeps = {}): void {
|
|
100
107
|
pi.registerCommand("browser-profile", {
|
|
101
108
|
description: "Choose which browser profile the agent uses",
|
|
102
109
|
handler: async (_args, ctx) => {
|
|
103
|
-
const loaded = await loadProfiles(client);
|
|
110
|
+
const loaded = await loadProfiles(client, deps);
|
|
104
111
|
if (!loaded.success) {
|
|
105
112
|
ctx.ui.notify(loaded.error, "error");
|
|
106
113
|
return;
|
|
@@ -156,6 +163,7 @@ export function registerProfileCommand(pi: ExtensionAPI, client: BrowserClient):
|
|
|
156
163
|
export const promptForProfileIfUnset = async (
|
|
157
164
|
client: BrowserClient,
|
|
158
165
|
ctx: ExtensionContext | undefined,
|
|
166
|
+
deps: ProfileCommandDeps = {},
|
|
159
167
|
): Promise<string | undefined> => {
|
|
160
168
|
if (client.profilePin()) return undefined;
|
|
161
169
|
|
|
@@ -168,7 +176,7 @@ export const promptForProfileIfUnset = async (
|
|
|
168
176
|
return "No browser profile selected — using whichever window is focused. Run /browser-profile to pin one.";
|
|
169
177
|
}
|
|
170
178
|
|
|
171
|
-
const loaded = await loadProfiles(client);
|
|
179
|
+
const loaded = await loadProfiles(client, deps);
|
|
172
180
|
if (!loaded.success) return loaded.error;
|
|
173
181
|
|
|
174
182
|
const choice = await promptForProfile(
|
package/src/profile/launch.ts
CHANGED
|
@@ -41,25 +41,27 @@ export const openProfileWindow = async (
|
|
|
41
41
|
if (req.explicitUserDataDir) args.push(`--user-data-dir=${req.explicitUserDataDir}`);
|
|
42
42
|
args.push(`--profile-directory=${req.profileDir}`, "--new-window", sentinelUrl);
|
|
43
43
|
|
|
44
|
+
const discardSentinel = (): Promise<void> => rm(file, { force: true }).catch(() => undefined);
|
|
45
|
+
|
|
44
46
|
let child: ChildProcess;
|
|
45
47
|
try {
|
|
46
48
|
child = spawn(req.exePath, args, { detached: true, stdio: "ignore" });
|
|
47
49
|
} catch (e) {
|
|
48
|
-
await
|
|
50
|
+
await discardSentinel();
|
|
49
51
|
return err(`could not launch ${req.exePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
// spawn() reports a bad binary asynchronously as an 'error' event rather than throwing; unhandled, an ENOENT looked like the 15s sentinel timeout.
|
|
53
55
|
const spawnFailure = await new Promise<string | undefined>((resolve) => {
|
|
54
|
-
child.once("spawn", () => resolve(undefined));
|
|
55
|
-
child.once("error", (e: Error) => resolve(e.message));
|
|
56
|
+
child.once("spawn", () => { resolve(undefined); });
|
|
57
|
+
child.once("error", (e: Error) => { resolve(e.message); });
|
|
56
58
|
});
|
|
57
59
|
// A later 'error' event with no listener would throw, so keep one attached for the detached launcher's lifetime.
|
|
58
|
-
child.on("error", () =>
|
|
60
|
+
child.on("error", () => undefined);
|
|
59
61
|
child.unref();
|
|
60
62
|
|
|
61
63
|
if (spawnFailure) {
|
|
62
|
-
await
|
|
64
|
+
await discardSentinel();
|
|
63
65
|
return err(`could not launch ${req.exePath}: ${spawnFailure}`);
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -67,12 +69,14 @@ export const openProfileWindow = async (
|
|
|
67
69
|
|
|
68
70
|
return ok({
|
|
69
71
|
sentinelUrl,
|
|
70
|
-
cleanup:
|
|
71
|
-
await rm(file, { force: true }).catch(() => {});
|
|
72
|
-
},
|
|
72
|
+
cleanup: discardSentinel,
|
|
73
73
|
kill: () => {
|
|
74
74
|
if (live) {
|
|
75
|
-
try {
|
|
75
|
+
try {
|
|
76
|
+
live.kill("SIGTERM");
|
|
77
|
+
} catch {
|
|
78
|
+
// The process already exited; there is nothing left to kill.
|
|
79
|
+
}
|
|
76
80
|
live = null;
|
|
77
81
|
}
|
|
78
82
|
},
|
package/src/profile/list.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { Compile } from "typebox/compile";
|
|
5
5
|
import { parseJson } from "../schemas/parse";
|
|
6
|
+
// Chrome writes "" for an unnamed profile and an unsigned-in account, so an empty string must read as absent.
|
|
7
|
+
import { asNonEmptyString, asRecord } from "../util/guards";
|
|
6
8
|
|
|
7
9
|
export type BrowserProfile = {
|
|
8
10
|
readonly dir: string;
|
|
@@ -35,12 +37,6 @@ const readJsonFile = async (path: string): Promise<Record<string, unknown> | nul
|
|
|
35
37
|
return parsed.success ? parsed.data : null;
|
|
36
38
|
};
|
|
37
39
|
|
|
38
|
-
const asRecord = (v: unknown): Record<string, unknown> | null =>
|
|
39
|
-
jsonObjectValidator.Check(v) ? v : null;
|
|
40
|
-
|
|
41
|
-
const asString = (v: unknown): string | undefined =>
|
|
42
|
-
typeof v === "string" && v.length > 0 ? v : undefined;
|
|
43
|
-
|
|
44
40
|
const orderDirs = (dirs: ReadonlyArray<string>, order: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
45
41
|
const known = order.filter((d) => dirs.includes(d));
|
|
46
42
|
const rest = dirs.filter((d) => !known.includes(d)).sort((a, b) => a.localeCompare(b));
|
|
@@ -73,9 +69,9 @@ const listFromLocalState = async (userDataDir: string): Promise<ReadonlyArray<Br
|
|
|
73
69
|
|
|
74
70
|
const rawOrder = profileNode["profiles_order"];
|
|
75
71
|
const order = Array.isArray(rawOrder) ? rawOrder.filter((v): v is string => typeof v === "string") : [];
|
|
76
|
-
const lastUsed =
|
|
72
|
+
const lastUsed = asNonEmptyString(profileNode["last_used"]);
|
|
77
73
|
|
|
78
|
-
const entries: Array<{ dir: string; entry: Record<string, unknown
|
|
74
|
+
const entries: Array<{ dir: string; entry: Readonly<Record<string, unknown>> }> = [];
|
|
79
75
|
for (const [dir, value] of Object.entries(infoCache)) {
|
|
80
76
|
if (NON_PROFILE_DIRS.has(dir)) continue;
|
|
81
77
|
const entry = asRecord(value);
|
|
@@ -91,8 +87,8 @@ const listFromLocalState = async (userDataDir: string): Promise<ReadonlyArray<Br
|
|
|
91
87
|
return ordered.flatMap((dir) => {
|
|
92
88
|
const entry = byDir.get(dir);
|
|
93
89
|
if (!entry) return [];
|
|
94
|
-
const name =
|
|
95
|
-
const email =
|
|
90
|
+
const name = asNonEmptyString(entry["name"]) ?? asNonEmptyString(entry["gaia_name"]);
|
|
91
|
+
const email = asNonEmptyString(entry["user_name"]);
|
|
96
92
|
return [buildProfile(dir, name, email, dir === lastUsed)];
|
|
97
93
|
});
|
|
98
94
|
};
|
|
@@ -115,10 +111,10 @@ const listFromPreferencesScan = async (userDataDir: string): Promise<ReadonlyArr
|
|
|
115
111
|
const prefs = await readJsonFile(join(userDataDir, dir, "Preferences"));
|
|
116
112
|
if (!prefs) continue;
|
|
117
113
|
const profileNode = asRecord(prefs["profile"]);
|
|
118
|
-
const name = profileNode ?
|
|
114
|
+
const name = profileNode ? asNonEmptyString(profileNode["name"]) : undefined;
|
|
119
115
|
const accounts = prefs["account_info"];
|
|
120
|
-
const firstAccount = Array.isArray(accounts) ? asRecord(accounts[0]) :
|
|
121
|
-
const email = firstAccount ?
|
|
116
|
+
const firstAccount = Array.isArray(accounts) ? asRecord(accounts[0]) : undefined;
|
|
117
|
+
const email = firstAccount ? asNonEmptyString(firstAccount["email"]) : undefined;
|
|
122
118
|
profiles.push(buildProfile(dir, name, email, false));
|
|
123
119
|
}
|
|
124
120
|
return profiles;
|
package/src/profile/store.ts
CHANGED
|
@@ -74,7 +74,8 @@ const writePinFile = async (profile: ProfilePin | null): Promise<Result<void, st
|
|
|
74
74
|
await rename(tmp, target);
|
|
75
75
|
return ok(undefined);
|
|
76
76
|
} catch (e) {
|
|
77
|
-
|
|
77
|
+
// The temp file may never have been created; the write error below is the one that matters.
|
|
78
|
+
await unlink(tmp).catch(() => undefined);
|
|
78
79
|
return err(`could not save profile selection to ${target}: ${e instanceof Error ? e.message : String(e)}`);
|
|
79
80
|
}
|
|
80
81
|
};
|
package/src/prompt.ts
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
import { ALL_TOOLS } from "./registry";
|
|
2
|
+
import type { BrowserTool, ToolConcurrency } from "./util/tool";
|
|
3
|
+
|
|
4
|
+
// The lane text is derived from each tool's own `concurrency`, so a tool can never
|
|
5
|
+
// be mis-described as observational while actually taking the mutation mutex.
|
|
6
|
+
export type PromptTool = Pick<BrowserTool, "name" | "concurrency">;
|
|
7
|
+
|
|
8
|
+
export type LaneNames = {
|
|
9
|
+
readonly serialized: ReadonlyArray<string>;
|
|
10
|
+
readonly parallel: ReadonlyArray<string>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const TOOL_PREFIX = "browser_";
|
|
14
|
+
|
|
15
|
+
// Lane lists use the short form (click, not browser_click) to keep the prompt compact; the
|
|
16
|
+
// prefix is universal and stated once in the prose above the list.
|
|
17
|
+
export const shortToolName = (name: string): string =>
|
|
18
|
+
name.startsWith(TOOL_PREFIX) ? name.slice(TOOL_PREFIX.length) : name;
|
|
19
|
+
|
|
20
|
+
export const laneNames = (tools: ReadonlyArray<PromptTool>): LaneNames => {
|
|
21
|
+
const lane = (c: ToolConcurrency): ReadonlyArray<string> => tools.filter((t) => t.concurrency === c).map((t) => t.name);
|
|
22
|
+
return { serialized: lane("serialized"), parallel: lane("parallel") };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const joinShort = (names: ReadonlyArray<string>): string => names.map(shortToolName).join(", ");
|
|
26
|
+
|
|
27
|
+
export function getBrowserSystemPrompt(connected = true, tools: ReadonlyArray<PromptTool> = ALL_TOOLS): string {
|
|
2
28
|
const connectionNotice = connected
|
|
3
29
|
? ""
|
|
4
|
-
: "
|
|
30
|
+
: // "Not connected" alone read to some models as "no browser tools", and they fell back to curl.
|
|
31
|
+
"The browser_* tools are available in this session; the browser is just not connected yet. Call browser_setup first to connect (the user can also run /browser-setup), then use the browser_* tools. When the user asks for the browser, do not swap in curl, another fetch tool or another browser tool.\n\n";
|
|
32
|
+
const lanes = laneNames(tools);
|
|
5
33
|
return `
|
|
6
34
|
## Browser Control
|
|
7
35
|
|
|
@@ -11,12 +39,10 @@ and tool-specific guidelines.
|
|
|
11
39
|
|
|
12
40
|
- Operate only inside this session's owned Chrome window. Never reach into the
|
|
13
41
|
user's other tabs or windows. Close owned tabs once no later step needs them.
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
concurrently with them. \`browser_run_script\` bypasses that lane: keep mutating
|
|
19
|
-
scripts sequential with other browser calls. Parallelize only independent calls.
|
|
42
|
+
- Tools in the serialized lane (${joinShort(lanes.serialized)}) run one at a time.
|
|
43
|
+
Tools in the parallel lane (${joinShort(lanes.parallel)}) may run concurrently
|
|
44
|
+
with them. \`browser_run_script\` bypasses that lane: keep mutating scripts
|
|
45
|
+
sequential with other browser calls. Parallelize only independent calls.
|
|
20
46
|
- Agents sharing a session share its active tab. Check \`browser_current_tab\`
|
|
21
47
|
before a mutation to confirm the expected tab when another agent may switch it.
|
|
22
48
|
- Read any appended "Page changes" diff to confirm an interaction. Tools without a
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { BrowserClient } from "../client";
|
|
3
|
+
import { missingParams, runRecipe, unknownParams, unknownParamsMessage } from "./replay";
|
|
4
|
+
import { formatFailureHandoff, formatOutputs, listText } from "./run-tool";
|
|
5
|
+
import { findRecipe, listRecipes, recordRun } from "./store";
|
|
6
|
+
import { approveProjectRecipe, trustWritten } from "./trust";
|
|
7
|
+
|
|
8
|
+
const TOKEN = /([^\s=]+)="([^"]*)"|(\S+)/g;
|
|
9
|
+
|
|
10
|
+
// `NAME key=value key="two words"`: the first bare word is the name, a double-quoted value stays whole, and the
|
|
11
|
+
// bare words after an unquoted value join it, so `topic=Grace Hopper` is "Grace Hopper", not "Grace".
|
|
12
|
+
export const parseCommandArgs = (raw: string): { readonly name: string | undefined; readonly params: Readonly<Record<string, string>> } => {
|
|
13
|
+
let name: string | undefined;
|
|
14
|
+
let open: string | undefined;
|
|
15
|
+
const params: Record<string, string> = {};
|
|
16
|
+
for (const m of raw.matchAll(TOKEN)) {
|
|
17
|
+
const [, quotedKey, quotedValue, bare] = m;
|
|
18
|
+
if (quotedKey !== undefined && quotedValue !== undefined) {
|
|
19
|
+
params[quotedKey] = quotedValue;
|
|
20
|
+
open = undefined;
|
|
21
|
+
} else if (bare?.includes("=") === true) {
|
|
22
|
+
const at = bare.indexOf("=");
|
|
23
|
+
open = bare.slice(0, at);
|
|
24
|
+
params[open] = bare.slice(at + 1);
|
|
25
|
+
} else if (bare !== undefined && name === undefined) {
|
|
26
|
+
name = bare;
|
|
27
|
+
} else if (bare !== undefined && open !== undefined) {
|
|
28
|
+
params[open] = `${params[open] ?? ""} ${bare}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { name, params };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Runs the replay engine directly, so a command run never starts a model turn.
|
|
35
|
+
export const registerRecipeCommand = (pi: Pick<ExtensionAPI, "registerCommand" | "sendUserMessage" | "sendMessage">, client: BrowserClient): void => {
|
|
36
|
+
pi.registerCommand("browser-recipe", {
|
|
37
|
+
description: "Replay a saved browser recipe with no model calls: /browser-recipe NAME key=value. No name lists recipes.",
|
|
38
|
+
getArgumentCompletions: async (prefix) => {
|
|
39
|
+
const all = await listRecipes(process.cwd());
|
|
40
|
+
return all
|
|
41
|
+
.map((s) => s.recipe.name)
|
|
42
|
+
.filter((n) => n.startsWith(prefix))
|
|
43
|
+
.map((n) => ({ value: n, label: n }));
|
|
44
|
+
},
|
|
45
|
+
handler: async (raw, ctx) => {
|
|
46
|
+
const { name, params } = parseCommandArgs(raw);
|
|
47
|
+
if (name === undefined) {
|
|
48
|
+
ctx.ui.notify(await listText(ctx.cwd), "info");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const alive = await client.ensureAlive();
|
|
52
|
+
if (!alive.success) {
|
|
53
|
+
ctx.ui.notify(`Browser not connected: ${alive.error.message}. Run /browser-setup first.`, "error");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const stored = await findRecipe(ctx.cwd, name);
|
|
57
|
+
if (!stored.success) {
|
|
58
|
+
ctx.ui.notify(stored.error, "error");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const unknown = unknownParams(stored.data.recipe, params);
|
|
62
|
+
if (unknown.length > 0) {
|
|
63
|
+
ctx.ui.notify(unknownParamsMessage(stored.data.recipe, unknown), "error");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const missing = missingParams(stored.data.recipe, params);
|
|
67
|
+
if (missing.length > 0) {
|
|
68
|
+
ctx.ui.notify(`Recipe ${name} needs: ${missing.map((p) => `${p}=… (e.g. ${stored.data.recipe.params[p]?.example ?? ""})`).join(" ")}`, "error");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const approved = await approveProjectRecipe(ctx, stored.data, params);
|
|
72
|
+
if (!approved.success) {
|
|
73
|
+
ctx.ui.notify(approved.error, "error");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
ctx.ui.notify(`Running recipe ${name}…`, "info");
|
|
77
|
+
const outcome = await runRecipe({ client, extensionCtx: ctx, recipe: stored.data.recipe, params });
|
|
78
|
+
const at = new Date().toISOString();
|
|
79
|
+
if (outcome.ok) {
|
|
80
|
+
await recordRun(stored.data, { at, result: "ok" });
|
|
81
|
+
await trustWritten(stored.data, ctx.cwd);
|
|
82
|
+
ctx.ui.notify(`Recipe ${name} done: ${outcome.steps} steps ✓`, "info");
|
|
83
|
+
// Shown in the session without starting a model turn; the agent sees it with the user's next message.
|
|
84
|
+
const data = formatOutputs(outcome.outputs);
|
|
85
|
+
if (data !== "") pi.sendMessage({ customType: "browser-recipe-output", content: `/browser-recipe ${name}\n${data}`, display: true }, { triggerTurn: false });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await recordRun(stored.data, { at, result: "failed", step: outcome.failure.step });
|
|
89
|
+
await trustWritten(stored.data, ctx.cwd);
|
|
90
|
+
ctx.ui.notify(`Recipe ${name} failed at step ${outcome.failure.step}; handing it to the agent to finish the task and repair the recipe.`, "warning");
|
|
91
|
+
const report = `/browser-recipe ${name} failed. ${formatFailureHandoff(stored.data.recipe, params, outcome.failure)}`;
|
|
92
|
+
if (ctx.isIdle()) pi.sendUserMessage(report);
|
|
93
|
+
else pi.sendUserMessage(report, { deliverAs: "followUp" });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
};
|