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.
- package/CHANGELOG.md +54 -0
- package/README.md +21 -10
- package/package.json +6 -3
- package/skills/pi-browser-harness/SKILL.md +30 -1
- package/src/cdp/commands.ts +260 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +31 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +375 -265
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +90 -82
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -37
- package/src/domains/console.ts +48 -4
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +75 -30
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +64 -0
- package/src/domains/form.ts +115 -112
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +16 -12
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +32 -51
- package/src/domains/keyboard.ts +38 -34
- package/src/domains/navigate.ts +16 -26
- package/src/domains/network.ts +61 -13
- package/src/domains/page.ts +25 -10
- package/src/domains/readpage/read-page.ts +74 -20
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -90
- package/src/domains/scroll.ts +10 -12
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -21
- package/src/domains/setup.ts +1 -13
- package/src/domains/snapshot.ts +36 -280
- package/src/domains/tabs.ts +15 -16
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -4
- package/src/index.ts +23 -69
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/prompt.ts +22 -122
- package/src/registry.ts +33 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +48 -18
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/paths.ts +11 -1
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/src/prompt.ts
CHANGED
|
@@ -1,127 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* This prompt carries only cross-cutting knowledge — the mental model, tool
|
|
6
|
-
* routing, workflows, and gotchas. Per-tool specifics (schemas, detailed usage)
|
|
7
|
-
* are injected separately via promptGuidelines on each tool registration, so this
|
|
8
|
-
* file intentionally does NOT restate full tool descriptions.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export function getBrowserSystemPrompt(): string {
|
|
1
|
+
export function getBrowserSystemPrompt(connected = true): string {
|
|
2
|
+
const connectionNotice = connected
|
|
3
|
+
? ""
|
|
4
|
+
: "Browser is not connected. Run browser_setup or /browser-setup to connect.\n\n";
|
|
12
5
|
return `
|
|
13
6
|
## Browser Control
|
|
14
7
|
|
|
15
|
-
You
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
### Choosing a tool
|
|
35
|
-
|
|
36
|
-
| To… | Reach for |
|
|
37
|
-
|-----|-----------|
|
|
38
|
-
| See the page structure (for interaction) | \`browser_snapshot\` (refs) |
|
|
39
|
-
| See the page visually | \`browser_screenshot\` |
|
|
40
|
-
| Search the web | \`browser_web_search\` → ranked {title, url, snippet} |
|
|
41
|
-
| Read an article's clean text | \`browser_read_page\` (reader mode; url or owned tab) |
|
|
42
|
-
| Extract specific DOM values | \`browser_execute_js\` |
|
|
43
|
-
| Hit a JSON/API endpoint | \`browser_http_get\` (raw GET, outside the browser) |
|
|
44
|
-
| Click / fill / type / select / focus / press a key | \`browser_click\` · \`browser_fill\` · \`browser_type\` · \`browser_select_option\` · \`browser_focus\` · \`browser_press_key\` |
|
|
45
|
-
| Upload a file · drag · resize viewport | \`browser_upload_file\` · \`browser_drag_and_drop\` · \`browser_viewport_resize\` |
|
|
46
|
-
| Go to a URL / open many / manage tabs | \`browser_navigate\` · \`browser_new_tab\` · \`browser_open_urls\` · \`browser_list_tabs\` · \`browser_switch_tab\` · \`browser_go_back\`/\`go_forward\`/\`reload\` |
|
|
47
|
-
| Wait for content / load / a fixed delay | \`browser_wait_for\` (selector/text) · \`browser_wait_for_load\` (readyState) · \`browser_wait\` |
|
|
48
|
-
| Inspect page / handle a dialog | \`browser_page_info\` · \`browser_current_tab\` · \`browser_handle_dialog\` |
|
|
49
|
-
| Diagnose a broken action | \`browser_console\` (JS errors) · \`browser_get_network_log\` |
|
|
50
|
-
| Save output / config downloads | \`browser_print_to_pdf\` · \`browser_download\` |
|
|
51
|
-
| Anything the tools can't express | \`browser_run_script\` (see Extending) |
|
|
52
|
-
|
|
53
|
-
Each tool carries its own detailed guidelines — this table is only for routing.
|
|
54
|
-
|
|
55
|
-
### Workflows
|
|
56
|
-
|
|
57
|
-
**Research (search → read):**
|
|
58
|
-
\`\`\`
|
|
59
|
-
browser_web_search("query") // ranked links, no page content
|
|
60
|
-
→ browser_read_page(url) // clean article text for the top hits
|
|
61
|
-
// fall back to browser_open_urls + browser_execute_js only when reader mode misses
|
|
62
|
-
\`\`\`
|
|
63
|
-
|
|
64
|
-
**Form filling (ref-first — the reliable path on SPA forms):**
|
|
65
|
-
\`\`\`
|
|
66
|
-
browser_wait_for({ selector: "#email" }) // ensure the field is rendered
|
|
67
|
-
browser_snapshot() // interactive elements get [eN] refs
|
|
68
|
-
→ browser_fill({ ref: "e7", value: "a@b.com" })
|
|
69
|
-
→ browser_select_option({ ref: "e9", label: "India" })
|
|
70
|
-
→ browser_click({ ref: "e12" }) // re-resolves position even after reflow
|
|
71
|
-
// read each "Page changes" diff to confirm the step landed
|
|
72
|
-
\`\`\`
|
|
73
|
-
|
|
74
|
-
**Navigate + verify:**
|
|
75
|
-
\`\`\`
|
|
76
|
-
browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot()
|
|
77
|
-
\`\`\`
|
|
78
|
-
|
|
79
|
-
**Scroll:** \`browser_scroll({ deltaY: 500 })\` — W3C wheel convention: positive = down,
|
|
80
|
-
negative = up (default 300, down).
|
|
81
|
-
|
|
82
|
-
### Parallelism
|
|
83
|
-
|
|
84
|
-
Observation tools (\`browser_screenshot\`, \`browser_page_info\`, \`browser_execute_js\`,
|
|
85
|
-
\`browser_list_tabs\`, \`browser_http_get\`, \`browser_web_search\`, \`browser_read_page\`, …)
|
|
86
|
-
run in parallel with each other and with mutations. The harness serializes mutations
|
|
87
|
-
(click, type, scroll, navigate, switch_tab) so they never race. Emit independent calls
|
|
88
|
-
in one turn:
|
|
89
|
-
\`\`\`
|
|
90
|
-
browser_screenshot() + browser_page_info() + browser_execute_js("document.title")
|
|
91
|
-
\`\`\`
|
|
92
|
-
|
|
93
|
-
**Multi-agent:** tab switching by one agent changes the active tab for all agents,
|
|
94
|
-
but per-tab data (console, network, dialogs) stays isolated. Call \`browser_current_tab\`
|
|
95
|
-
before a mutation to confirm you're on the expected tab. Handle any dialog reported by
|
|
96
|
-
\`browser_page_info\` promptly — dialogs block interaction and aren't queued across agents.
|
|
97
|
-
|
|
98
|
-
**Session boundary & tab hygiene:** you operate only inside this session's own Chrome
|
|
99
|
-
window. \`browser_switch_tab\`/\`browser_close_tab\` refuse tabs this session didn't open,
|
|
100
|
-
and \`browser_list_tabs\` defaults to owned tabs — never reach into the user's other tabs
|
|
101
|
-
or windows. Close tabs when you're done with them: call \`browser_close_tab\` on any tab
|
|
102
|
-
you opened (via \`browser_new_tab\`/\`browser_open_urls\`) once you've extracted what you
|
|
103
|
-
need. Don't leave a pile of stale tabs behind — keep only what a later step still needs.
|
|
104
|
-
(Leftover tabs are closed automatically when the session ends, but close them yourself
|
|
105
|
-
as you go.)
|
|
106
|
-
|
|
107
|
-
### Extending: temporary scripts
|
|
108
|
-
|
|
109
|
-
When built-in tools can't express a multi-step workflow, write a script to a temp file
|
|
110
|
-
and run it with \`browser_run_script\`. It executes in the harness process with direct
|
|
111
|
-
daemon + Node access.
|
|
112
|
-
\`\`\`
|
|
113
|
-
write(<tempfile>.js, \`
|
|
114
|
-
const results = [];
|
|
115
|
-
for (const url of params.urls) {
|
|
116
|
-
await daemon.cdp("Page.navigate", { url });
|
|
117
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
118
|
-
results.push({ url, title: await daemon.evaluateJS("document.title") });
|
|
119
|
-
}
|
|
120
|
-
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
121
|
-
\`)
|
|
122
|
-
browser_run_script(<tempfile>.js, { urls: [...] })
|
|
123
|
-
\`\`\`
|
|
124
|
-
Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON,
|
|
125
|
-
Buffer, setTimeout, clearTimeout. Use a real temp path from the OS — don't hardcode \`/tmp\`.
|
|
8
|
+
${connectionNotice}You control the user's running Chrome through \`browser_*\` tools alongside the
|
|
9
|
+
standard pi tools. Per-tool usage lives in tool descriptions, parameter schemas,
|
|
10
|
+
and tool-specific guidelines.
|
|
11
|
+
|
|
12
|
+
- Operate only inside this session's owned Chrome window. Never reach into the
|
|
13
|
+
user's other tabs or windows. Close owned tabs once no later step needs them.
|
|
14
|
+
- Mutating tools (click, type, fill, press_key, scroll, navigate, history, tab
|
|
15
|
+
switch/new/close, upload, download, headers, viewport, drag, web_search) are
|
|
16
|
+
serialized. Observations (snapshot, screenshot, page_info, current_tab, list_tabs,
|
|
17
|
+
console, network_requests, http_get, read_page, execute_js, wait, wait_for) may run
|
|
18
|
+
concurrently with them. \`browser_run_script\` bypasses that lane: keep mutating
|
|
19
|
+
scripts sequential with other browser calls. Parallelize only independent calls.
|
|
20
|
+
- Agents sharing a session share its active tab. Check \`browser_current_tab\`
|
|
21
|
+
before a mutation to confirm the expected tab when another agent may switch it.
|
|
22
|
+
- Read any appended "Page changes" diff to confirm an interaction. Tools without a
|
|
23
|
+
diff, including \`browser_run_script\`, need explicit state verification.
|
|
24
|
+
- If a tool reports "ref is stale", call \`browser_snapshot\` for fresh refs before
|
|
25
|
+
retrying; do not reuse the stale handle.
|
|
126
26
|
`;
|
|
127
27
|
}
|
package/src/registry.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { BrowserClient } from "./client";
|
|
|
3
3
|
import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
|
|
4
4
|
import { clickTool } from "./domains/click";
|
|
5
5
|
import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
|
|
6
|
-
import { fillTool, selectOptionTool, focusTool } from "./domains/form";
|
|
6
|
+
import { fillTool, selectOptionTool, focusTool, setCheckedTool } from "./domains/form";
|
|
7
|
+
import { fillFormTool } from "./domains/form-batch";
|
|
7
8
|
import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
|
|
8
9
|
import { scrollTool } from "./domains/scroll";
|
|
9
10
|
import { handleDialogTool } from "./domains/dialog";
|
|
@@ -15,6 +16,7 @@ import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files";
|
|
|
15
16
|
import { viewportResizeTool } from "./domains/viewport";
|
|
16
17
|
import { dragAndDropTool } from "./domains/drag";
|
|
17
18
|
import { httpGetTool, networkRequestsTool } from "./domains/network";
|
|
19
|
+
import { clearHeadersTool, setHeadersTool } from "./domains/headers";
|
|
18
20
|
import { consoleTool } from "./domains/console";
|
|
19
21
|
import { snapshotTool } from "./domains/snapshot";
|
|
20
22
|
import { executeJsTool, runScriptTool } from "./domains/js";
|
|
@@ -22,53 +24,51 @@ import { setupTool } from "./domains/setup";
|
|
|
22
24
|
import { webSearchTool } from "./domains/search/web-search";
|
|
23
25
|
import { readPageTool } from "./domains/readpage/read-page";
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
// so they run through a shared async mutex. Observation / read-only tools are
|
|
27
|
-
// left unmarked and can execute in parallel.
|
|
28
|
-
const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
|
|
29
|
-
// setup comes first — the agent calls this to self-recover from not-initialized
|
|
27
|
+
export const ALL_TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
|
|
30
28
|
setupTool,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
clickTool,
|
|
30
|
+
typeTool,
|
|
31
|
+
fillTool,
|
|
32
|
+
fillFormTool,
|
|
33
|
+
selectOptionTool,
|
|
34
|
+
setCheckedTool,
|
|
35
|
+
focusTool,
|
|
36
|
+
pressKeyTool,
|
|
37
|
+
dispatchKeyTool,
|
|
38
|
+
scrollTool,
|
|
39
39
|
pageInfoTool,
|
|
40
40
|
waitTool,
|
|
41
41
|
waitForTool,
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
waitForLoadTool,
|
|
43
|
+
handleDialogTool,
|
|
44
44
|
screenshotTool,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
navigateTool,
|
|
46
|
+
openUrlsTool,
|
|
47
|
+
goBackTool,
|
|
48
|
+
goForwardTool,
|
|
49
|
+
reloadTool,
|
|
50
50
|
listTabsTool,
|
|
51
51
|
currentTabTool,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
switchTabTool,
|
|
53
|
+
newTabTool,
|
|
54
|
+
closeTabTool,
|
|
55
|
+
uploadFileTool,
|
|
56
|
+
downloadTool,
|
|
57
|
+
printToPdfTool,
|
|
58
|
+
viewportResizeTool,
|
|
59
|
+
dragAndDropTool,
|
|
60
60
|
httpGetTool,
|
|
61
61
|
networkRequestsTool,
|
|
62
|
+
setHeadersTool,
|
|
63
|
+
clearHeadersTool,
|
|
62
64
|
consoleTool,
|
|
63
65
|
snapshotTool,
|
|
64
66
|
executeJsTool,
|
|
65
67
|
runScriptTool,
|
|
66
|
-
|
|
67
|
-
{ ...webSearchTool, serialized: true },
|
|
68
|
-
// Pure isolated read (own sessionId, own tab) — safe concurrent, like executeJsTool.
|
|
68
|
+
webSearchTool,
|
|
69
69
|
readPageTool,
|
|
70
70
|
];
|
|
71
71
|
|
|
72
72
|
export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => {
|
|
73
|
-
for (const t of
|
|
73
|
+
for (const t of ALL_TOOLS) registerBrowserTool(pi, client, t);
|
|
74
74
|
};
|
|
@@ -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
|
+
};
|
package/src/setup.ts
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Setup — verifies Chrome is running with remote debugging and connects
|
|
3
|
-
* the pi agent to it. Exposed as both a slash command (/browser-setup) for
|
|
4
|
-
* users and a tool (browser_setup) for the agent to self-recover.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
1
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
8
2
|
import type { BrowserClient } from "./client";
|
|
9
3
|
import { ensureDaemon } from "./daemon/spawn";
|
|
@@ -11,8 +5,6 @@ import { DAEMON_SOCKET_PATH } from "./daemon/protocol";
|
|
|
11
5
|
import { isBrowserRunning } from "./profile/browser-process";
|
|
12
6
|
import { promptForProfileIfUnset } from "./profile/command";
|
|
13
7
|
|
|
14
|
-
// ── Public: register the /browser-setup slash command ──────────────────────
|
|
15
|
-
|
|
16
8
|
export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): void {
|
|
17
9
|
pi.registerCommand("browser-setup", {
|
|
18
10
|
description: "Connect pi to your Chrome browser",
|
|
@@ -27,32 +19,21 @@ export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): v
|
|
|
27
19
|
});
|
|
28
20
|
}
|
|
29
21
|
|
|
30
|
-
// ── Shared setup result ────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
22
|
export type SetupResult = { success: true; data: string } | { success: false; error: string };
|
|
33
23
|
|
|
34
|
-
// ── Shared setup logic (used by both the command and the tool) ─────────────
|
|
35
|
-
|
|
36
24
|
export async function performSetup(client: BrowserClient, ctx?: ExtensionContext): Promise<SetupResult> {
|
|
37
|
-
// Step 1: Check Chrome is running
|
|
38
25
|
const chromeRunning = await isBrowserRunning();
|
|
39
26
|
if (!chromeRunning) {
|
|
40
27
|
return { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." };
|
|
41
28
|
}
|
|
42
29
|
|
|
43
|
-
// Step 2: Start the browser daemon (spawns if not running, silently reuses if alive)
|
|
44
30
|
const daemonReady = await ensureDaemon();
|
|
45
31
|
if (!daemonReady) {
|
|
46
32
|
return { success: false, error: `Could not start the browser daemon. Check ${DAEMON_SOCKET_PATH}.` };
|
|
47
33
|
}
|
|
48
34
|
|
|
49
|
-
// Step 3: Ask which profile to work in, the first time only. Enumeration
|
|
50
|
-
// reads DevToolsActivePort + Local State from disk, so it works before the
|
|
51
|
-
// connection exists. Declining leaves the pre-profile behavior untouched —
|
|
52
|
-
// this must never block setup.
|
|
53
35
|
const profileNote = await promptForProfileIfUnset(client, ctx);
|
|
54
36
|
|
|
55
|
-
// Step 4: Connect to Chrome DevTools
|
|
56
37
|
const startResult = await client.start();
|
|
57
38
|
if (!startResult.success) {
|
|
58
39
|
const msg = startResult.error.message;
|
|
@@ -80,7 +61,6 @@ export async function performSetup(client: BrowserClient, ctx?: ExtensionContext
|
|
|
80
61
|
return { success: false, error: `Connection failed: ${msg}` };
|
|
81
62
|
}
|
|
82
63
|
|
|
83
|
-
// Step 5: Verify with test navigation
|
|
84
64
|
const tabResult = await client.newTab("https://github.com");
|
|
85
65
|
if (!tabResult.success) {
|
|
86
66
|
return { success: false, error: `Browser connected but test navigation failed: ${tabResult.error.message}` };
|
|
@@ -97,8 +77,3 @@ export async function performSetup(client: BrowserClient, ctx?: ExtensionContext
|
|
|
97
77
|
return { success: true, data: lines.join("\n") };
|
|
98
78
|
}
|
|
99
79
|
|
|
100
|
-
// ── Chrome process detection ───────────────────────────────────────────────
|
|
101
|
-
//
|
|
102
|
-
// Detection moved to src/profile/browser-process.ts, which answers the same
|
|
103
|
-
// "is a browser running?" question per platform and additionally reports the
|
|
104
|
-
// executable path and any explicit --user-data-dir that profile selection needs.
|
package/src/state.ts
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session persistence for pi-browser-harness.
|
|
3
|
-
*
|
|
4
|
-
* Persists the daemon namespace and (when applicable) the remote browser ID
|
|
5
|
-
* across session reloads and branch navigation.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { asArrayOf, asNumber, asString, isRecord } from "./util/guards";
|
|
9
3
|
|
|
10
4
|
export type BrowserState = {
|
|
11
5
|
readonly namespace: string;
|
|
12
|
-
readonly remoteBrowserId?: string;
|
|
13
6
|
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
14
7
|
readonly harnessWindowTargetId?: string;
|
|
15
8
|
readonly harnessWindowId?: number;
|
|
@@ -20,24 +13,61 @@ export const defaultState = (namespace = "default"): BrowserState => ({
|
|
|
20
13
|
ownedTargetIds: [],
|
|
21
14
|
});
|
|
22
15
|
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
// Pi session JSONL is append-only and every custom entry shows up in the branch navigator, so a
|
|
17
|
+
// snapshot is only worth appending when it differs from the one the branch already holds (#26).
|
|
18
|
+
// Tab order is not a difference: ownership is a set.
|
|
19
|
+
const stateKey = (state: BrowserState): string =>
|
|
20
|
+
JSON.stringify({
|
|
21
|
+
namespace: state.namespace,
|
|
22
|
+
ownedTargetIds: [...(state.ownedTargetIds ?? [])].sort(),
|
|
23
|
+
harnessWindowTargetId: state.harnessWindowTargetId ?? null,
|
|
24
|
+
harnessWindowId: state.harnessWindowId ?? null,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type StatePersister = {
|
|
28
|
+
// Appends the snapshot unless it equals the last one persisted or restored. Returns whether it wrote.
|
|
29
|
+
readonly persist: (state: BrowserState) => boolean;
|
|
30
|
+
// Records a snapshot that already lives in the session (restored from the branch) without writing it.
|
|
31
|
+
readonly markPersisted: (state: BrowserState) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const createStatePersister = (pi: ExtensionAPI): StatePersister => {
|
|
35
|
+
let last: string | undefined;
|
|
36
|
+
return {
|
|
37
|
+
persist: (state) => {
|
|
38
|
+
const key = stateKey(state);
|
|
39
|
+
if (key === last) return false;
|
|
40
|
+
pi.appendEntry<BrowserState>("browser-harness-state", state);
|
|
41
|
+
last = key;
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
markPersisted: (state) => {
|
|
45
|
+
last = stateKey(state);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const asPersistedState = (v: unknown): Partial<BrowserState> | undefined => {
|
|
51
|
+
if (!isRecord(v)) return undefined;
|
|
52
|
+
const namespace = asString(v["namespace"]);
|
|
53
|
+
const ownedTargetIds = asArrayOf(v["ownedTargetIds"], asString);
|
|
54
|
+
const harnessWindowTargetId = asString(v["harnessWindowTargetId"]);
|
|
55
|
+
const harnessWindowId = asNumber(v["harnessWindowId"]);
|
|
56
|
+
return {
|
|
57
|
+
...(namespace !== undefined ? { namespace } : {}),
|
|
58
|
+
...(ownedTargetIds !== undefined ? { ownedTargetIds } : {}),
|
|
59
|
+
...(harnessWindowTargetId !== undefined ? { harnessWindowTargetId } : {}),
|
|
60
|
+
...(harnessWindowId !== undefined ? { harnessWindowId } : {}),
|
|
61
|
+
};
|
|
25
62
|
};
|
|
26
63
|
|
|
27
|
-
/**
|
|
28
|
-
* Find the last browser-harness-state entry in the current branch
|
|
29
|
-
* and return the restored state, merged with defaults.
|
|
30
|
-
*
|
|
31
|
-
* If `currentNamespace` is supplied (e.g. from the --browser-namespace flag),
|
|
32
|
-
* it overrides whatever is in the persisted entry.
|
|
33
|
-
*/
|
|
34
64
|
export const restoreState = (ctx: ExtensionContext, currentNamespace?: string): BrowserState => {
|
|
35
65
|
const branchEntries = ctx.sessionManager.getBranch();
|
|
36
66
|
const fallback = defaultState(currentNamespace);
|
|
37
67
|
for (let i = branchEntries.length - 1; i >= 0; i--) {
|
|
38
68
|
const entry = branchEntries[i];
|
|
39
69
|
if (entry?.type === "custom" && entry.customType === "browser-harness-state") {
|
|
40
|
-
const data = entry.data
|
|
70
|
+
const data = asPersistedState(entry.data);
|
|
41
71
|
if (data) {
|
|
42
72
|
return {
|
|
43
73
|
...fallback,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
|
|
2
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3
|
+
|
|
4
|
+
export const asRecord = (v: unknown): Readonly<Record<string, unknown>> | undefined =>
|
|
5
|
+
isRecord(v) ? v : undefined;
|
|
6
|
+
|
|
7
|
+
export const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
8
|
+
|
|
9
|
+
export const asNumber = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
|
|
10
|
+
|
|
11
|
+
export const asBoolean = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined);
|
|
12
|
+
|
|
13
|
+
export const asArrayOf = <T>(
|
|
14
|
+
v: unknown,
|
|
15
|
+
item: (x: unknown) => T | undefined,
|
|
16
|
+
): ReadonlyArray<T> | undefined => {
|
|
17
|
+
if (!Array.isArray(v)) return undefined;
|
|
18
|
+
const out: T[] = [];
|
|
19
|
+
for (const x of v) {
|
|
20
|
+
const parsed = item(x);
|
|
21
|
+
if (parsed === undefined) return undefined;
|
|
22
|
+
out.push(parsed);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const errnoCode = (e: unknown): string | undefined => {
|
|
28
|
+
if (!(e instanceof Error) || !("code" in e)) return undefined;
|
|
29
|
+
return asString(e.code);
|
|
30
|
+
};
|
package/src/util/js-template.ts
CHANGED
|
@@ -1,19 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Build a JavaScript source string with safely-interpolated values.
|
|
3
|
-
* Every interpolated value is JSON.stringify'd, so strings, numbers,
|
|
4
|
-
* objects, and special characters all become valid JS literals.
|
|
5
|
-
*
|
|
6
|
-
* This is the ONLY supported way to interpolate untrusted values into
|
|
7
|
-
* evaluation source — never use raw template literals for JS that
|
|
8
|
-
* crosses the CDP boundary. The static parts of the template (the
|
|
9
|
-
* `strings` array) must remain hard-coded; only the `${...}` slots
|
|
10
|
-
* are sanitized.
|
|
11
|
-
*
|
|
12
|
-
* Values must be JSON-serializable: BigInt and Symbol throw at runtime;
|
|
13
|
-
* functions and undefined serialize to `undefined`/omitted, which yields
|
|
14
|
-
* a ReferenceError when evaluated. Pass strings, numbers, booleans,
|
|
15
|
-
* plain objects, or arrays.
|
|
16
|
-
*/
|
|
1
|
+
// The ONLY supported way to interpolate untrusted values into evaluation source — never use raw template literals for JS crossing the CDP boundary.
|
|
17
2
|
export const safeJs = (
|
|
18
3
|
strings: TemplateStringsArray,
|
|
19
4
|
...values: ReadonlyArray<unknown>
|
package/src/util/mutex.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight async mutex (~10 lines). Acquire returns a release function.
|
|
3
|
-
* When the mutex is free, acquire() resolves immediately. When held,
|
|
4
|
-
* callers are queued and execute serially in FIFO order.
|
|
5
|
-
*/
|
|
6
1
|
export type Mutex = {
|
|
7
2
|
acquire(): Promise<() => void>;
|
|
8
3
|
};
|
package/src/util/paths.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
// `relative` rather than a string prefix: it is separator-agnostic and carries each platform's
|
|
6
|
+
// own case rules, so a Windows path is not rejected for its backslashes or its drive-letter case.
|
|
7
|
+
export const isPathWithin = (root: string, target: string): boolean => {
|
|
8
|
+
const absRoot = resolve(root);
|
|
9
|
+
const absTarget = resolve(target);
|
|
10
|
+
if (absTarget === absRoot) return true;
|
|
11
|
+
const rel = relative(absRoot, absTarget);
|
|
12
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
13
|
+
};
|
|
4
14
|
|
|
5
15
|
const NAMESPACE_RE = /^[a-zA-Z0-9_-]+$/;
|
|
6
16
|
|
package/src/util/result.ts
CHANGED
|
@@ -7,9 +7,3 @@ export const err = <E>(error: E): Result<never, E> => ({ success: false, error }
|
|
|
7
7
|
|
|
8
8
|
export const map = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> =>
|
|
9
9
|
r.success ? ok(f(r.data)) : r;
|
|
10
|
-
|
|
11
|
-
export const andThen = <T, U, E>(r: Result<T, E>, f: (t: T) => Result<U, E>): Result<U, E> =>
|
|
12
|
-
r.success ? f(r.data) : r;
|
|
13
|
-
|
|
14
|
-
export const mapErr = <T, E, F>(r: Result<T, E>, f: (e: E) => F): Result<T, F> =>
|
|
15
|
-
r.success ? r : err(f(r.error));
|
package/src/util/sharp-shim.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// native module not installed in every workspace; we import dynamically and
|
|
3
|
-
// distinguish "missing" (no install) from "error" (installed but threw).
|
|
4
|
-
//
|
|
5
|
-
// We declare only the surface we use. The dynamic import returns `unknown`
|
|
6
|
-
// at the boundary; the cast inside loadSharp() is the documented escape
|
|
7
|
-
// hatch — every other consumer sees the typed SharpFactory.
|
|
1
|
+
// `sharp` is an optional dependency not installed in every workspace, so the import must stay dynamic and unresolvable to tsc.
|
|
8
2
|
|
|
9
3
|
export type SharpInstance = {
|
|
10
4
|
metadata(): Promise<{ width?: number; height?: number }>;
|
|
@@ -20,26 +14,25 @@ export type SharpLoad =
|
|
|
20
14
|
| { readonly kind: "missing" }
|
|
21
15
|
| { readonly kind: "error"; readonly message: string };
|
|
22
16
|
|
|
17
|
+
const SHARP_SPECIFIER: string = "sharp";
|
|
18
|
+
|
|
19
|
+
const MISSING_MODULE_MARKERS: ReadonlyArray<string> = [
|
|
20
|
+
"Cannot find module",
|
|
21
|
+
"MODULE_NOT_FOUND",
|
|
22
|
+
"ERR_MODULE_NOT_FOUND",
|
|
23
|
+
];
|
|
24
|
+
|
|
23
25
|
export const loadSharp = async (): Promise<SharpLoad> => {
|
|
24
26
|
let mod: unknown;
|
|
25
27
|
try {
|
|
26
|
-
|
|
27
|
-
// @ts-ignore — optional peer dependency not required at compile time
|
|
28
|
-
mod = await import("sharp");
|
|
28
|
+
mod = await import(SHARP_SPECIFIER);
|
|
29
29
|
} catch (e) {
|
|
30
30
|
const msg = e instanceof Error ? e.message : String(e);
|
|
31
|
-
if (
|
|
32
|
-
msg.includes("Cannot find module") ||
|
|
33
|
-
msg.includes("MODULE_NOT_FOUND") ||
|
|
34
|
-
msg.includes("ERR_MODULE_NOT_FOUND")
|
|
35
|
-
) {
|
|
36
|
-
return { kind: "missing" };
|
|
37
|
-
}
|
|
31
|
+
if (MISSING_MODULE_MARKERS.some((marker) => msg.includes(marker))) return { kind: "missing" };
|
|
38
32
|
return { kind: "error", message: msg };
|
|
39
33
|
}
|
|
40
34
|
if (mod === null || mod === undefined) return { kind: "missing" };
|
|
41
|
-
// The CommonJS sharp module's default export IS the factory
|
|
42
|
-
// sharp puts it on .default. Probe both.
|
|
35
|
+
// The CommonJS sharp module's default export IS the factory; ESM-wrapped sharp puts it on .default.
|
|
43
36
|
const m = mod as { default?: unknown };
|
|
44
37
|
const candidate: unknown = typeof m.default === "function" ? m.default : mod;
|
|
45
38
|
if (typeof candidate !== "function") {
|