pi-feats 0.1.2 → 0.1.4
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/README.md +24 -1
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/pi-feats-preview.png +0 -0
- package/extensions/api-server/application-log-store.ts +31 -0
- package/extensions/api-server/application-runtime.ts +80 -18
- package/extensions/api-server/application-store.ts +6 -2
- package/extensions/api-server/profile-store.ts +47 -20
- package/extensions/api-server/server.ts +182 -47
- package/extensions/browser-harness/cdp/commands.ts +260 -0
- package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
- package/extensions/browser-harness/cdp/discovery.ts +198 -0
- package/extensions/browser-harness/cdp/errors.ts +23 -0
- package/extensions/browser-harness/cdp/events.ts +170 -0
- package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
- package/extensions/browser-harness/cdp/ownership.ts +73 -0
- package/extensions/browser-harness/cdp/record-store.ts +61 -0
- package/extensions/browser-harness/cdp/session.ts +403 -0
- package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
- package/extensions/browser-harness/cdp/target-factory.ts +153 -0
- package/extensions/browser-harness/cdp/types.ts +82 -0
- package/extensions/browser-harness/client.ts +366 -0
- package/extensions/browser-harness/daemon/bridge.ts +531 -0
- package/extensions/browser-harness/daemon/index.ts +86 -0
- package/extensions/browser-harness/daemon/protocol.ts +110 -0
- package/extensions/browser-harness/daemon/queue.ts +85 -0
- package/extensions/browser-harness/daemon/server.ts +170 -0
- package/extensions/browser-harness/daemon/spawn.ts +124 -0
- package/extensions/browser-harness/daemon/transport.ts +178 -0
- package/extensions/browser-harness/domains/ax-tree.ts +179 -0
- package/extensions/browser-harness/domains/box.ts +51 -0
- package/extensions/browser-harness/domains/cdp-call.ts +55 -0
- package/extensions/browser-harness/domains/click.ts +97 -0
- package/extensions/browser-harness/domains/console.ts +219 -0
- package/extensions/browser-harness/domains/dialog.ts +34 -0
- package/extensions/browser-harness/domains/drag.ts +67 -0
- package/extensions/browser-harness/domains/element-call.ts +117 -0
- package/extensions/browser-harness/domains/files.ts +219 -0
- package/extensions/browser-harness/domains/fill-engine.ts +80 -0
- package/extensions/browser-harness/domains/form-batch.ts +65 -0
- package/extensions/browser-harness/domains/form.ts +283 -0
- package/extensions/browser-harness/domains/headers.ts +89 -0
- package/extensions/browser-harness/domains/history.ts +80 -0
- package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
- package/extensions/browser-harness/domains/js.ts +241 -0
- package/extensions/browser-harness/domains/keyboard.ts +197 -0
- package/extensions/browser-harness/domains/navigate.ts +161 -0
- package/extensions/browser-harness/domains/network.ts +301 -0
- package/extensions/browser-harness/domains/page.ts +154 -0
- package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
- package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
- package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
- package/extensions/browser-harness/domains/render.ts +43 -0
- package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
- package/extensions/browser-harness/domains/screenshot.ts +76 -0
- package/extensions/browser-harness/domains/scroll.ts +59 -0
- package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
- package/extensions/browser-harness/domains/search/web-search.ts +144 -0
- package/extensions/browser-harness/domains/setup.ts +35 -0
- package/extensions/browser-harness/domains/snapshot.ts +256 -0
- package/extensions/browser-harness/domains/tabs.ts +202 -0
- package/extensions/browser-harness/domains/target.ts +35 -0
- package/extensions/browser-harness/domains/viewport.ts +42 -0
- package/extensions/browser-harness/index.ts +88 -0
- package/extensions/browser-harness/profile/bind.ts +90 -0
- package/extensions/browser-harness/profile/browser-process.ts +320 -0
- package/extensions/browser-harness/profile/command.ts +189 -0
- package/extensions/browser-harness/profile/launch.ts +80 -0
- package/extensions/browser-harness/profile/list.ts +131 -0
- package/extensions/browser-harness/profile/paths.ts +103 -0
- package/extensions/browser-harness/profile/store.ts +84 -0
- package/extensions/browser-harness/prompt.ts +16 -0
- package/extensions/browser-harness/registry.ts +80 -0
- package/extensions/browser-harness/schemas/common.ts +11 -0
- package/extensions/browser-harness/schemas/parse.ts +23 -0
- package/extensions/browser-harness/setup.ts +47 -0
- package/extensions/browser-harness/state.ts +51 -0
- package/extensions/browser-harness/steel.ts +215 -0
- package/extensions/browser-harness/util/debug.ts +8 -0
- package/extensions/browser-harness/util/guards.ts +30 -0
- package/extensions/browser-harness/util/js-template.ts +12 -0
- package/extensions/browser-harness/util/mutex.ts +20 -0
- package/extensions/browser-harness/util/paths.ts +31 -0
- package/extensions/browser-harness/util/result.ts +9 -0
- package/extensions/browser-harness/util/sharp-shim.ts +42 -0
- package/extensions/browser-harness/util/tool.ts +185 -0
- package/extensions/browser-harness/util/truncate.ts +41 -0
- package/extensions/cli-resources.ts +35 -16
- package/extensions/composio/index.ts +163 -0
- package/extensions/context-memory/index.ts +58 -0
- package/extensions/lib/application-context.ts +22 -0
- package/extensions/lib/application-handler-templates.ts +17 -1
- package/extensions/lib/context-memory.ts +165 -0
- package/extensions/lib/profile-sandbox.ts +3 -0
- package/extensions/lib/workflow-harness.ts +238 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
- package/extensions/pi-console-webui/app/layout.tsx +6 -1
- package/extensions/pi-console-webui/app/login/page.tsx +5 -9
- package/extensions/pi-console-webui/app/not-found.tsx +5 -0
- package/extensions/pi-console-webui/app/page.tsx +6 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
- package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
- package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
- package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
- package/extensions/pi-console-webui/components/console-page.tsx +3 -1
- package/extensions/pi-console-webui/components/console.tsx +53 -7
- package/extensions/pi-console-webui/components/login-form.tsx +14 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
- package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
- package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
- package/extensions/profiles.ts +18 -7
- package/extensions/pulse/store.ts +5 -2
- package/extensions/sequential-workflow.ts +340 -256
- package/extensions/skill-sources/store.ts +24 -3
- package/licenses/pi-browser-harness-MIT.txt +21 -0
- package/package.json +25 -4
- package/schemas/sequential-workflow-template.schema.json +62 -0
- package/skills/browser-harness/SKILL.md +33 -0
- package/skills/composio-connections/SKILL.md +75 -0
- package/skills/sequential-workflow/SKILL.md +70 -0
- package/skills/sequential-workflow-template/SKILL.md +44 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { Compile } from "typebox/compile";
|
|
5
|
+
import { parseJson } from "../schemas/parse";
|
|
6
|
+
|
|
7
|
+
export type BrowserProfile = {
|
|
8
|
+
readonly dir: string;
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly email?: string;
|
|
11
|
+
readonly label: string;
|
|
12
|
+
readonly lastUsed: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const NON_PROFILE_DIRS: ReadonlySet<string> = new Set([
|
|
16
|
+
"System Profile",
|
|
17
|
+
"Guest Profile",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export const formatProfileLabel = (name: string, email: string | undefined, dir: string): string =>
|
|
21
|
+
`${name} (${email && email.length > 0 ? email : dir})`;
|
|
22
|
+
|
|
23
|
+
const JsonObject = Type.Record(Type.String(), Type.Unknown());
|
|
24
|
+
|
|
25
|
+
const jsonObjectValidator = Compile(JsonObject);
|
|
26
|
+
|
|
27
|
+
const readJsonFile = async (path: string): Promise<Record<string, unknown> | null> => {
|
|
28
|
+
let raw: string;
|
|
29
|
+
try {
|
|
30
|
+
raw = await readFile(path, "utf8");
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const parsed = parseJson(raw, jsonObjectValidator);
|
|
35
|
+
return parsed.success ? parsed.data : null;
|
|
36
|
+
};
|
|
37
|
+
|
|
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
|
+
const orderDirs = (dirs: ReadonlyArray<string>, order: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
45
|
+
const known = order.filter((d) => dirs.includes(d));
|
|
46
|
+
const rest = dirs.filter((d) => !known.includes(d)).sort((a, b) => a.localeCompare(b));
|
|
47
|
+
return [...known, ...rest];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const buildProfile = (
|
|
51
|
+
dir: string,
|
|
52
|
+
name: string | undefined,
|
|
53
|
+
email: string | undefined,
|
|
54
|
+
lastUsed: boolean,
|
|
55
|
+
): BrowserProfile => {
|
|
56
|
+
const displayName = name ?? dir;
|
|
57
|
+
return {
|
|
58
|
+
dir,
|
|
59
|
+
name: displayName,
|
|
60
|
+
...(email !== undefined ? { email } : {}),
|
|
61
|
+
label: formatProfileLabel(displayName, email, dir),
|
|
62
|
+
lastUsed,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const listFromLocalState = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
|
|
67
|
+
const localState = await readJsonFile(join(userDataDir, "Local State"));
|
|
68
|
+
if (!localState) return [];
|
|
69
|
+
const profileNode = asRecord(localState["profile"]);
|
|
70
|
+
if (!profileNode) return [];
|
|
71
|
+
const infoCache = asRecord(profileNode["info_cache"]);
|
|
72
|
+
if (!infoCache) return [];
|
|
73
|
+
|
|
74
|
+
const rawOrder = profileNode["profiles_order"];
|
|
75
|
+
const order = Array.isArray(rawOrder) ? rawOrder.filter((v): v is string => typeof v === "string") : [];
|
|
76
|
+
const lastUsed = asString(profileNode["last_used"]);
|
|
77
|
+
|
|
78
|
+
const entries: Array<{ dir: string; entry: Record<string, unknown> }> = [];
|
|
79
|
+
for (const [dir, value] of Object.entries(infoCache)) {
|
|
80
|
+
if (NON_PROFILE_DIRS.has(dir)) continue;
|
|
81
|
+
const entry = asRecord(value);
|
|
82
|
+
if (!entry) continue;
|
|
83
|
+
// Ephemeral (guest-like) profiles vanish when their window closes, so they can never be a durable pin.
|
|
84
|
+
if (entry["is_ephemeral"] === true) continue;
|
|
85
|
+
entries.push({ dir, entry });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ordered = orderDirs(entries.map((e) => e.dir), order);
|
|
89
|
+
const byDir = new Map(entries.map((e) => [e.dir, e.entry]));
|
|
90
|
+
|
|
91
|
+
return ordered.flatMap((dir) => {
|
|
92
|
+
const entry = byDir.get(dir);
|
|
93
|
+
if (!entry) return [];
|
|
94
|
+
const name = asString(entry["name"]) ?? asString(entry["gaia_name"]);
|
|
95
|
+
const email = asString(entry["user_name"]);
|
|
96
|
+
return [buildProfile(dir, name, email, dir === lastUsed)];
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const listFromPreferencesScan = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
|
|
101
|
+
let dirents;
|
|
102
|
+
try {
|
|
103
|
+
dirents = await readdir(userDataDir, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const candidates = dirents
|
|
109
|
+
.filter((d) => d.isDirectory() && !NON_PROFILE_DIRS.has(d.name))
|
|
110
|
+
.map((d) => d.name)
|
|
111
|
+
.sort((a, b) => a.localeCompare(b));
|
|
112
|
+
|
|
113
|
+
const profiles: BrowserProfile[] = [];
|
|
114
|
+
for (const dir of candidates) {
|
|
115
|
+
const prefs = await readJsonFile(join(userDataDir, dir, "Preferences"));
|
|
116
|
+
if (!prefs) continue;
|
|
117
|
+
const profileNode = asRecord(prefs["profile"]);
|
|
118
|
+
const name = profileNode ? asString(profileNode["name"]) : undefined;
|
|
119
|
+
const accounts = prefs["account_info"];
|
|
120
|
+
const firstAccount = Array.isArray(accounts) ? asRecord(accounts[0]) : null;
|
|
121
|
+
const email = firstAccount ? asString(firstAccount["email"]) : undefined;
|
|
122
|
+
profiles.push(buildProfile(dir, name, email, false));
|
|
123
|
+
}
|
|
124
|
+
return profiles;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const listProfiles = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
|
|
128
|
+
const fromLocalState = await listFromLocalState(userDataDir);
|
|
129
|
+
if (fromLocalState.length > 0) return fromLocalState;
|
|
130
|
+
return listFromPreferencesScan(userDataDir);
|
|
131
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
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:...`.
|
|
2
|
+
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const ENV_AGENT_DIR = "PI_CODING_AGENT_DIR";
|
|
7
|
+
|
|
8
|
+
// Deliberately mirrors pi's expandTildePath (never `~\…`) rather than improving on it, or the pin lands somewhere pi does not look.
|
|
9
|
+
const expandTildePiCompatible = (path: string): string => {
|
|
10
|
+
if (path === "~") return homedir();
|
|
11
|
+
if (path.startsWith("~/")) return homedir() + path.slice(1);
|
|
12
|
+
return path;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const expandTildeLenient = (path: string): string => {
|
|
16
|
+
if (path === "~") return homedir();
|
|
17
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
|
|
18
|
+
return path;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const agentDir = (): string => {
|
|
22
|
+
const fromEnv = process.env[ENV_AGENT_DIR];
|
|
23
|
+
if (fromEnv) return expandTildePiCompatible(fromEnv);
|
|
24
|
+
return join(homedir(), ".pi", "agent");
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const pinFilePath = (): string => join(agentDir(), "browser-harness.json");
|
|
28
|
+
|
|
29
|
+
const localAppData = (): string => process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local");
|
|
30
|
+
|
|
31
|
+
export const userDataDirCandidates = (): ReadonlyArray<string> => {
|
|
32
|
+
const home = homedir();
|
|
33
|
+
const dirs: string[] = [];
|
|
34
|
+
|
|
35
|
+
const envOverride = process.env["CHROME_USER_DATA_DIR"];
|
|
36
|
+
if (envOverride) dirs.push(expandTildeLenient(envOverride));
|
|
37
|
+
|
|
38
|
+
if (process.platform === "darwin") {
|
|
39
|
+
const support = join(home, "Library", "Application Support");
|
|
40
|
+
dirs.push(
|
|
41
|
+
join(support, "Google/Chrome"),
|
|
42
|
+
join(support, "Google/Chrome Beta"),
|
|
43
|
+
join(support, "Google/Chrome Dev"),
|
|
44
|
+
join(support, "Google/Chrome Canary"),
|
|
45
|
+
join(support, "Chromium"),
|
|
46
|
+
join(support, "BraveSoftware/Brave-Browser"),
|
|
47
|
+
join(support, "BraveSoftware/Brave-Browser-Beta"),
|
|
48
|
+
join(support, "BraveSoftware/Brave-Browser-Nightly"),
|
|
49
|
+
join(support, "BraveSoftware/Brave-Browser-Dev"),
|
|
50
|
+
join(support, "Microsoft Edge"),
|
|
51
|
+
join(support, "Microsoft Edge Beta"),
|
|
52
|
+
join(support, "Microsoft Edge Dev"),
|
|
53
|
+
join(support, "Microsoft Edge Canary"),
|
|
54
|
+
);
|
|
55
|
+
} else if (process.platform === "win32") {
|
|
56
|
+
const lad = localAppData();
|
|
57
|
+
dirs.push(
|
|
58
|
+
join(lad, "Google/Chrome/User Data"),
|
|
59
|
+
join(lad, "Google/Chrome Beta/User Data"),
|
|
60
|
+
join(lad, "Google/Chrome Dev/User Data"),
|
|
61
|
+
join(lad, "Google/Chrome SxS/User Data"),
|
|
62
|
+
join(lad, "Chromium/User Data"),
|
|
63
|
+
join(lad, "BraveSoftware/Brave-Browser/User Data"),
|
|
64
|
+
join(lad, "BraveSoftware/Brave-Browser-Beta/User Data"),
|
|
65
|
+
join(lad, "Microsoft/Edge/User Data"),
|
|
66
|
+
join(lad, "Microsoft/Edge Beta/User Data"),
|
|
67
|
+
join(lad, "Microsoft/Edge Dev/User Data"),
|
|
68
|
+
join(lad, "Microsoft/Edge SxS/User Data"),
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
dirs.push(
|
|
72
|
+
join(home, ".config/google-chrome"),
|
|
73
|
+
join(home, ".config/google-chrome-beta"),
|
|
74
|
+
join(home, ".config/google-chrome-unstable"),
|
|
75
|
+
join(home, ".config/chromium"),
|
|
76
|
+
join(home, ".config/chromium-browser"),
|
|
77
|
+
join(home, ".config/BraveSoftware/Brave-Browser"),
|
|
78
|
+
join(home, ".config/BraveSoftware/Brave-Browser-Beta"),
|
|
79
|
+
join(home, ".config/BraveSoftware/Brave-Browser-Nightly"),
|
|
80
|
+
join(home, ".config/microsoft-edge"),
|
|
81
|
+
join(home, ".config/microsoft-edge-beta"),
|
|
82
|
+
join(home, ".config/microsoft-edge-dev"),
|
|
83
|
+
join(home, "snap/chromium/common/chromium"),
|
|
84
|
+
join(home, ".var/app/org.chromium.Chromium/config/chromium"),
|
|
85
|
+
join(home, ".var/app/com.google.Chrome/config/google-chrome"),
|
|
86
|
+
join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
|
|
87
|
+
join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Array.from(new Set(dirs));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const browserNameForUserDataDir = (userDataDir: string): string => {
|
|
95
|
+
const p = userDataDir.replace(/\\/g, "/");
|
|
96
|
+
const has = (needle: string): boolean => p.toLowerCase().includes(needle);
|
|
97
|
+
if (has("brave")) return "Brave";
|
|
98
|
+
if (has("edge")) return "Edge";
|
|
99
|
+
if (has("chromium")) return "Chromium";
|
|
100
|
+
if (has("chrome")) return "Chrome";
|
|
101
|
+
const last = p.split("/").filter(Boolean).pop();
|
|
102
|
+
return last ?? userDataDir;
|
|
103
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
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.
|
|
2
|
+
|
|
3
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { Compile } from "typebox/compile";
|
|
8
|
+
import { parseJson } from "../schemas/parse";
|
|
9
|
+
import { type Result, err, ok } from "../util/result";
|
|
10
|
+
import { pinFilePath } from "./paths";
|
|
11
|
+
|
|
12
|
+
export type ProfilePin = {
|
|
13
|
+
readonly userDataDir: string;
|
|
14
|
+
readonly profileDir: string;
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly savedAt: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type PinFile = {
|
|
20
|
+
readonly version: 1;
|
|
21
|
+
readonly profile: ProfilePin | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const CURRENT_VERSION = 1;
|
|
25
|
+
|
|
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);
|
|
46
|
+
|
|
47
|
+
export const readPin = async (): Promise<ProfilePin | null> => {
|
|
48
|
+
let raw: string;
|
|
49
|
+
try {
|
|
50
|
+
raw = await readFile(pinFilePath(), "utf8");
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
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
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const writePinFile = async (profile: ProfilePin | null): Promise<Result<void, string>> => {
|
|
68
|
+
const target = pinFilePath();
|
|
69
|
+
const tmp = `${target}.${randomUUID()}.tmp`;
|
|
70
|
+
const payload: PinFile = { version: CURRENT_VERSION, profile };
|
|
71
|
+
try {
|
|
72
|
+
await mkdir(dirname(target), { recursive: true });
|
|
73
|
+
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
74
|
+
await rename(tmp, target);
|
|
75
|
+
return ok(undefined);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
await unlink(tmp).catch(() => {});
|
|
78
|
+
return err(`could not save profile selection to ${target}: ${e instanceof Error ? e.message : String(e)}`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const writePin = (pin: ProfilePin): Promise<Result<void, string>> => writePinFile(pin);
|
|
83
|
+
|
|
84
|
+
export const clearPin = (): Promise<Result<void, string>> => writePinFile(null);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function getBrowserSystemPrompt(): string {
|
|
2
|
+
return `
|
|
3
|
+
## Steel Browser Control
|
|
4
|
+
|
|
5
|
+
The browser belongs to the persistent Steel Profile of the active Pi Profile. Use only
|
|
6
|
+
\`browser_*\` tools for browser work. The harness owns its tabs and never uses a desktop
|
|
7
|
+
browser profile.
|
|
8
|
+
|
|
9
|
+
Call \`browser_snapshot\` first to inspect a page. It returns stable accessibility refs
|
|
10
|
+
such as \`[e7]\`; use those refs with click, fill, select, focus, and keyboard tools.
|
|
11
|
+
Prefer \`browser_fill\` to typing, use \`browser_wait_for\` instead of sleeping, and take a
|
|
12
|
+
fresh snapshot when a ref is stale. Use screenshots only to validate visual rendering.
|
|
13
|
+
|
|
14
|
+
Do not enter credentials, bypass authentication, solve CAPTCHAs, or submit a sensitive
|
|
15
|
+
transaction without the user's explicit participation.\n`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { BrowserClient } from "./client";
|
|
3
|
+
import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
|
|
4
|
+
import { clickTool } from "./domains/click";
|
|
5
|
+
import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
|
|
6
|
+
import { fillTool, selectOptionTool, focusTool, setCheckedTool } from "./domains/form";
|
|
7
|
+
import { fillFormTool } from "./domains/form-batch";
|
|
8
|
+
import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
|
|
9
|
+
import { scrollTool } from "./domains/scroll";
|
|
10
|
+
import { handleDialogTool } from "./domains/dialog";
|
|
11
|
+
import { screenshotTool } from "./domains/screenshot";
|
|
12
|
+
import { navigateTool, openUrlsTool } from "./domains/navigate";
|
|
13
|
+
import { goBackTool, goForwardTool, reloadTool } from "./domains/history";
|
|
14
|
+
import { listTabsTool, currentTabTool, switchTabTool, newTabTool, closeTabTool } from "./domains/tabs";
|
|
15
|
+
import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files";
|
|
16
|
+
import { viewportResizeTool } from "./domains/viewport";
|
|
17
|
+
import { dragAndDropTool } from "./domains/drag";
|
|
18
|
+
import { httpGetTool, networkRequestsTool } from "./domains/network";
|
|
19
|
+
import { clearHeadersTool, setHeadersTool } from "./domains/headers";
|
|
20
|
+
import { consoleTool } from "./domains/console";
|
|
21
|
+
import { snapshotTool } from "./domains/snapshot";
|
|
22
|
+
import { executeJsTool } from "./domains/js";
|
|
23
|
+
import { createSetupTool, setupTool } from "./domains/setup";
|
|
24
|
+
import type { ViewerUrlProvider } from "./setup";
|
|
25
|
+
import { webSearchTool } from "./domains/search/web-search";
|
|
26
|
+
import { readPageTool } from "./domains/readpage/read-page";
|
|
27
|
+
|
|
28
|
+
export const ALL_TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
|
|
29
|
+
setupTool,
|
|
30
|
+
clickTool,
|
|
31
|
+
typeTool,
|
|
32
|
+
fillTool,
|
|
33
|
+
fillFormTool,
|
|
34
|
+
selectOptionTool,
|
|
35
|
+
setCheckedTool,
|
|
36
|
+
focusTool,
|
|
37
|
+
pressKeyTool,
|
|
38
|
+
dispatchKeyTool,
|
|
39
|
+
scrollTool,
|
|
40
|
+
pageInfoTool,
|
|
41
|
+
waitTool,
|
|
42
|
+
waitForTool,
|
|
43
|
+
waitForLoadTool,
|
|
44
|
+
handleDialogTool,
|
|
45
|
+
screenshotTool,
|
|
46
|
+
navigateTool,
|
|
47
|
+
openUrlsTool,
|
|
48
|
+
goBackTool,
|
|
49
|
+
goForwardTool,
|
|
50
|
+
reloadTool,
|
|
51
|
+
listTabsTool,
|
|
52
|
+
currentTabTool,
|
|
53
|
+
switchTabTool,
|
|
54
|
+
newTabTool,
|
|
55
|
+
closeTabTool,
|
|
56
|
+
uploadFileTool,
|
|
57
|
+
downloadTool,
|
|
58
|
+
printToPdfTool,
|
|
59
|
+
viewportResizeTool,
|
|
60
|
+
dragAndDropTool,
|
|
61
|
+
httpGetTool,
|
|
62
|
+
networkRequestsTool,
|
|
63
|
+
setHeadersTool,
|
|
64
|
+
clearHeadersTool,
|
|
65
|
+
consoleTool,
|
|
66
|
+
snapshotTool,
|
|
67
|
+
executeJsTool,
|
|
68
|
+
webSearchTool,
|
|
69
|
+
readPageTool,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export const registerAllTools = (
|
|
73
|
+
pi: ExtensionAPI,
|
|
74
|
+
client: BrowserClient,
|
|
75
|
+
viewerUrl?: ViewerUrlProvider,
|
|
76
|
+
): void => {
|
|
77
|
+
for (const tool of ALL_TOOLS) {
|
|
78
|
+
registerBrowserTool(pi, client, tool === setupTool ? createSetupTool(viewerUrl) : tool);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const Coords = Type.Object({
|
|
4
|
+
x: Type.Number({ description: "X coordinate in CSS pixels from left edge of viewport" }),
|
|
5
|
+
y: Type.Number({ description: "Y coordinate in CSS pixels from top edge of viewport" }),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const MouseButton = Type.Union(
|
|
9
|
+
[Type.Literal("left"), Type.Literal("right"), Type.Literal("middle")],
|
|
10
|
+
{ description: 'Mouse button: "left", "right", or "middle"' },
|
|
11
|
+
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Static, TSchema } from "typebox";
|
|
2
|
+
import type { Validator } from "typebox/compile";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
|
|
5
|
+
export const parseJson = <S extends TSchema>(
|
|
6
|
+
raw: string,
|
|
7
|
+
validator: Validator<{}, S>,
|
|
8
|
+
): Result<Static<S>, string> => {
|
|
9
|
+
let parsed: unknown;
|
|
10
|
+
try {
|
|
11
|
+
parsed = JSON.parse(raw);
|
|
12
|
+
} catch (e) {
|
|
13
|
+
return err(`JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
14
|
+
}
|
|
15
|
+
if (!validator.Check(parsed)) {
|
|
16
|
+
const messages = validator
|
|
17
|
+
.Errors(parsed)
|
|
18
|
+
.map((x) => x.message)
|
|
19
|
+
.join("; ");
|
|
20
|
+
return err(messages.length > 0 ? messages : "JSON did not match the expected shape");
|
|
21
|
+
}
|
|
22
|
+
return ok(parsed);
|
|
23
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { BrowserClient } from "./client";
|
|
3
|
+
|
|
4
|
+
export type ViewerUrlProvider = () => string | undefined;
|
|
5
|
+
|
|
6
|
+
export function registerSetupCommand(
|
|
7
|
+
pi: ExtensionAPI,
|
|
8
|
+
client: BrowserClient,
|
|
9
|
+
viewerUrl?: ViewerUrlProvider,
|
|
10
|
+
): void {
|
|
11
|
+
pi.registerCommand("browser-setup", {
|
|
12
|
+
description: "Start a Steel API session for the active Pi Profile",
|
|
13
|
+
handler: async (_args, ctx) => {
|
|
14
|
+
const result = await performSetup(client, ctx, viewerUrl);
|
|
15
|
+
ctx.ui.notify(result.success ? result.data : result.error, result.success ? "info" : "error");
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SetupResult = { success: true; data: string } | { success: false; error: string };
|
|
21
|
+
|
|
22
|
+
export async function performSetup(
|
|
23
|
+
client: BrowserClient,
|
|
24
|
+
_ctx?: ExtensionContext,
|
|
25
|
+
viewerUrl?: ViewerUrlProvider,
|
|
26
|
+
): Promise<SetupResult> {
|
|
27
|
+
const wasConnected = client.status().alive;
|
|
28
|
+
const started = await client.start();
|
|
29
|
+
if (!started.success) return { success: false, error: `Steel browser connection failed: ${started.error.message}` };
|
|
30
|
+
|
|
31
|
+
if (!wasConnected) {
|
|
32
|
+
const tab = await client.newTab("about:blank");
|
|
33
|
+
if (!tab.success) return { success: false, error: `Steel browser connected but could not open an agent tab: ${tab.error.message}` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const info = await client.pageInfo();
|
|
37
|
+
const page = info.success && !("dialog" in info.data) ? info.data.url : "about:blank";
|
|
38
|
+
const viewer = viewerUrl?.();
|
|
39
|
+
return {
|
|
40
|
+
success: true,
|
|
41
|
+
data: [
|
|
42
|
+
`Steel browser ${wasConnected ? "already connected" : "connected"}.`,
|
|
43
|
+
`Current page: ${page}`,
|
|
44
|
+
...(viewer ? [`Live viewer: ${viewer}`] : []),
|
|
45
|
+
].join("\n"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { asArrayOf, asNumber, asString, isRecord } from "./util/guards";
|
|
3
|
+
|
|
4
|
+
export type BrowserState = {
|
|
5
|
+
readonly namespace: string;
|
|
6
|
+
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
7
|
+
readonly harnessWindowTargetId?: string;
|
|
8
|
+
readonly harnessWindowId?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const defaultState = (namespace = "default"): BrowserState => ({
|
|
12
|
+
namespace,
|
|
13
|
+
ownedTargetIds: [],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const persistState = (pi: ExtensionAPI, state: BrowserState): void => {
|
|
17
|
+
pi.appendEntry<BrowserState>("browser-harness-state", state);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const asPersistedState = (v: unknown): Partial<BrowserState> | undefined => {
|
|
21
|
+
if (!isRecord(v)) return undefined;
|
|
22
|
+
const namespace = asString(v["namespace"]);
|
|
23
|
+
const ownedTargetIds = asArrayOf(v["ownedTargetIds"], asString);
|
|
24
|
+
const harnessWindowTargetId = asString(v["harnessWindowTargetId"]);
|
|
25
|
+
const harnessWindowId = asNumber(v["harnessWindowId"]);
|
|
26
|
+
return {
|
|
27
|
+
...(namespace !== undefined ? { namespace } : {}),
|
|
28
|
+
...(ownedTargetIds !== undefined ? { ownedTargetIds } : {}),
|
|
29
|
+
...(harnessWindowTargetId !== undefined ? { harnessWindowTargetId } : {}),
|
|
30
|
+
...(harnessWindowId !== undefined ? { harnessWindowId } : {}),
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const restoreState = (ctx: ExtensionContext, currentNamespace?: string): BrowserState => {
|
|
35
|
+
const branchEntries = ctx.sessionManager.getBranch();
|
|
36
|
+
const fallback = defaultState(currentNamespace);
|
|
37
|
+
for (let i = branchEntries.length - 1; i >= 0; i--) {
|
|
38
|
+
const entry = branchEntries[i];
|
|
39
|
+
if (entry?.type === "custom" && entry.customType === "browser-harness-state") {
|
|
40
|
+
const data = asPersistedState(entry.data);
|
|
41
|
+
if (data) {
|
|
42
|
+
return {
|
|
43
|
+
...fallback,
|
|
44
|
+
...data,
|
|
45
|
+
namespace: currentNamespace ?? data.namespace ?? fallback.namespace,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return fallback;
|
|
51
|
+
};
|