agentic-browser 1.2.0 → 1.4.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/README.md +10 -3
- package/dist/cli/index.mjs +9 -5
- package/dist/index.mjs +1 -1
- package/dist/mcp/index.mjs +49 -23
- package/dist/{runtime-D6awVhGy.mjs → runtime-CODdeRWR.mjs} +373 -109
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,6 +84,7 @@ agentic-browser agent start --user-profile /path/to/chrome/profile
|
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
Default profile locations per platform:
|
|
87
|
+
|
|
87
88
|
- **macOS:** `~/Library/Application Support/Google/Chrome`
|
|
88
89
|
- **Linux:** `~/.config/google-chrome`
|
|
89
90
|
- **Windows:** `%LOCALAPPDATA%\Google\Chrome\User Data`
|
|
@@ -92,10 +93,12 @@ Default profile locations per platform:
|
|
|
92
93
|
|
|
93
94
|
These options can also be set via environment variables (CLI flags take precedence):
|
|
94
95
|
|
|
95
|
-
| Variable | Example
|
|
96
|
-
| ------------------------------ |
|
|
96
|
+
| Variable | Example | Description |
|
|
97
|
+
| ------------------------------ | ----------------------------- | ------------------------------- |
|
|
97
98
|
| `AGENTIC_BROWSER_CDP_URL` | `http://127.0.0.1:9222` | Connect to a running Chrome |
|
|
98
|
-
| `AGENTIC_BROWSER_USER_PROFILE` | `default` or an absolute path
|
|
99
|
+
| `AGENTIC_BROWSER_USER_PROFILE` | `default` or an absolute path | Launch with a real profile |
|
|
100
|
+
| `AGENTIC_BROWSER_HEADLESS` | `true` | Run Chrome in headless mode |
|
|
101
|
+
| `AGENTIC_BROWSER_USER_AGENT` | `MyBot/1.0` | Override the browser user-agent |
|
|
99
102
|
|
|
100
103
|
## Agent Commands (Recommended for LLMs)
|
|
101
104
|
|
|
@@ -105,6 +108,8 @@ The `agent` subcommand manages session state, auto-restarts on disconnect, gener
|
|
|
105
108
|
agentic-browser agent start
|
|
106
109
|
agentic-browser agent start --cdp-url http://127.0.0.1:9222
|
|
107
110
|
agentic-browser agent start --user-profile default
|
|
111
|
+
agentic-browser agent start --headless
|
|
112
|
+
agentic-browser agent start --user-agent "MyBot/1.0"
|
|
108
113
|
agentic-browser agent status
|
|
109
114
|
agentic-browser agent run navigate '{"url":"https://example.com"}'
|
|
110
115
|
agentic-browser agent run interact '{"action":"click","selector":"#login"}'
|
|
@@ -184,6 +189,8 @@ For direct control without session state management:
|
|
|
184
189
|
agentic-browser session:start
|
|
185
190
|
agentic-browser session:start --cdp-url http://127.0.0.1:9222
|
|
186
191
|
agentic-browser session:start --user-profile default
|
|
192
|
+
agentic-browser session:start --headless
|
|
193
|
+
agentic-browser session:start --user-agent "MyBot/1.0"
|
|
187
194
|
```
|
|
188
195
|
|
|
189
196
|
### 2. Read Session Status
|
package/dist/cli/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { r as createCliRuntime } from "../runtime-
|
|
2
|
+
import { r as createCliRuntime } from "../runtime-CODdeRWR.mjs";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import crypto from "node:crypto";
|
|
@@ -243,9 +243,11 @@ async function main() {
|
|
|
243
243
|
const runtime = createCliRuntime();
|
|
244
244
|
const program = new Command();
|
|
245
245
|
program.name("agentic-browser").description("Agentic browser CLI");
|
|
246
|
-
program.command("session:start").option("--cdp-url <url>", "connect to existing Chrome via CDP endpoint URL").option("--user-profile <path>", "use 'default' for system Chrome profile or an absolute path").action(async (options) => {
|
|
246
|
+
program.command("session:start").option("--cdp-url <url>", "connect to existing Chrome via CDP endpoint URL").option("--user-profile <path>", "use 'default' for system Chrome profile or an absolute path").option("--headless", "run Chrome in headless mode (no visible window)").option("--user-agent <string>", "override the browser user-agent string").action(async (options) => {
|
|
247
247
|
if (options.cdpUrl) runtime.context.config.cdpUrl = options.cdpUrl;
|
|
248
248
|
if (options.userProfile) runtime.context.config.userProfileDir = options.userProfile === "true" || options.userProfile === "default" ? "default" : options.userProfile;
|
|
249
|
+
if (options.headless) runtime.context.config.headless = true;
|
|
250
|
+
if (options.userAgent) runtime.context.config.userAgent = options.userAgent;
|
|
249
251
|
const result = await runSessionStart(runtime, { browser: "chrome" });
|
|
250
252
|
console.log(JSON.stringify(result));
|
|
251
253
|
});
|
|
@@ -281,7 +283,7 @@ async function main() {
|
|
|
281
283
|
});
|
|
282
284
|
console.log(JSON.stringify(result));
|
|
283
285
|
});
|
|
284
|
-
program.command("page:content").argument("<sessionId>").option("--mode <mode>", "title|text|html", "text").option("--selector <selector>", "optional CSS selector").action(async (sessionId, options) => {
|
|
286
|
+
program.command("page:content").argument("<sessionId>").option("--mode <mode>", "title|text|html|a11y", "text").option("--selector <selector>", "optional CSS selector").action(async (sessionId, options) => {
|
|
285
287
|
const result = await runPageContent(runtime, {
|
|
286
288
|
sessionId,
|
|
287
289
|
mode: options.mode,
|
|
@@ -310,9 +312,11 @@ async function main() {
|
|
|
310
312
|
console.log(JSON.stringify(result));
|
|
311
313
|
});
|
|
312
314
|
const agent = program.command("agent").description("Stateful agent wrapper with session persistence and auto-retry");
|
|
313
|
-
agent.command("start").option("--cdp-url <url>", "connect to existing Chrome via CDP endpoint URL").option("--user-profile <path>", "use 'default' for system Chrome profile or an absolute path").action(async (options) => {
|
|
315
|
+
agent.command("start").option("--cdp-url <url>", "connect to existing Chrome via CDP endpoint URL").option("--user-profile <path>", "use 'default' for system Chrome profile or an absolute path").option("--headless", "run Chrome in headless mode (no visible window)").option("--user-agent <string>", "override the browser user-agent string").action(async (options) => {
|
|
314
316
|
if (options.cdpUrl) runtime.context.config.cdpUrl = options.cdpUrl;
|
|
315
317
|
if (options.userProfile) runtime.context.config.userProfileDir = options.userProfile === "true" || options.userProfile === "default" ? "default" : options.userProfile;
|
|
318
|
+
if (options.headless) runtime.context.config.headless = true;
|
|
319
|
+
if (options.userAgent) runtime.context.config.userAgent = options.userAgent;
|
|
316
320
|
const result = await agentStart(runtime);
|
|
317
321
|
console.log(JSON.stringify(result));
|
|
318
322
|
});
|
|
@@ -331,7 +335,7 @@ async function main() {
|
|
|
331
335
|
});
|
|
332
336
|
console.log(JSON.stringify(result));
|
|
333
337
|
});
|
|
334
|
-
agent.command("content").option("--mode <mode>", "title|text|html", "text").option("--selector <selector>", "optional CSS selector").action(async (options) => {
|
|
338
|
+
agent.command("content").option("--mode <mode>", "title|text|html|a11y", "text").option("--selector <selector>", "optional CSS selector").action(async (options) => {
|
|
335
339
|
const result = await agentContent(runtime, options);
|
|
336
340
|
console.log(JSON.stringify(result));
|
|
337
341
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { i as createMockAgenticBrowserCore, n as createAgenticBrowserCore, t as AgenticBrowserCore } from "./runtime-
|
|
2
|
+
import { i as createMockAgenticBrowserCore, n as createAgenticBrowserCore, t as AgenticBrowserCore } from "./runtime-CODdeRWR.mjs";
|
|
3
3
|
|
|
4
4
|
export { AgenticBrowserCore, createAgenticBrowserCore, createMockAgenticBrowserCore };
|
package/dist/mcp/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { n as createAgenticBrowserCore } from "../runtime-
|
|
2
|
+
import { n as createAgenticBrowserCore } from "../runtime-CODdeRWR.mjs";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -14,11 +14,21 @@ function getCore() {
|
|
|
14
14
|
function genId(prefix) {
|
|
15
15
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a session ID — auto-starts a session if none exists.
|
|
19
|
+
* This means the LLM never has to call browser_start_session explicitly.
|
|
20
|
+
*/
|
|
21
|
+
async function resolveSession(sessionId) {
|
|
22
|
+
if (sessionId) return sessionId;
|
|
23
|
+
if (activeSessionId) return activeSessionId;
|
|
24
|
+
activeSessionId = (await getCore().startSession()).sessionId;
|
|
25
|
+
return activeSessionId;
|
|
26
|
+
}
|
|
17
27
|
const server = new McpServer({
|
|
18
28
|
name: "agentic-browser",
|
|
19
29
|
version: "0.1.0"
|
|
20
30
|
});
|
|
21
|
-
server.tool("browser_start_session", "Start a Chrome browser session
|
|
31
|
+
server.tool("browser_start_session", "Start a new Chrome browser session (or return the existing one if healthy). Sessions auto-start when you call any other browser tool, so you rarely need to call this explicitly. Use this to force a fresh session after stopping the previous one.", {}, async () => {
|
|
22
32
|
const session = await getCore().startSession();
|
|
23
33
|
activeSessionId = session.sessionId;
|
|
24
34
|
return { content: [{
|
|
@@ -26,12 +36,11 @@ server.tool("browser_start_session", "Start a Chrome browser session for web aut
|
|
|
26
36
|
text: JSON.stringify(session)
|
|
27
37
|
}] };
|
|
28
38
|
});
|
|
29
|
-
server.tool("browser_navigate", "Navigate the browser to a URL.
|
|
39
|
+
server.tool("browser_navigate", "Navigate the browser to a URL. A session is auto-started if needed.", {
|
|
30
40
|
url: z.string().describe("The URL to navigate to"),
|
|
31
|
-
sessionId: z.string().optional().describe("Session ID (
|
|
41
|
+
sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
|
|
32
42
|
}, async ({ url, sessionId }) => {
|
|
33
|
-
const sid = sessionId
|
|
34
|
-
if (!sid) throw new Error("No active session. Call browser_start_session first.");
|
|
43
|
+
const sid = await resolveSession(sessionId);
|
|
35
44
|
const result = await getCore().runCommand({
|
|
36
45
|
sessionId: sid,
|
|
37
46
|
commandId: genId("nav"),
|
|
@@ -43,25 +52,34 @@ server.tool("browser_navigate", "Navigate the browser to a URL. The browser must
|
|
|
43
52
|
text: JSON.stringify(result)
|
|
44
53
|
}] };
|
|
45
54
|
});
|
|
46
|
-
server.tool("browser_interact", "Interact with a page element. Actions: \"click\" (click element), \"type\" (type text into input), \"press\" (press a keyboard key like Enter), \"waitFor\" (wait for element to appear).", {
|
|
55
|
+
server.tool("browser_interact", "Interact with a page element. Actions: \"click\" (click element), \"type\" (type text into input), \"press\" (press a keyboard key like Enter), \"waitFor\" (wait for element to appear), \"scroll\" (scroll page or element), \"hover\" (hover over element), \"select\" (pick option in <select>), \"toggle\" (toggle checkbox/radio/switch). A session is auto-started if needed.", {
|
|
47
56
|
action: z.enum([
|
|
48
57
|
"click",
|
|
49
58
|
"type",
|
|
50
59
|
"press",
|
|
51
|
-
"waitFor"
|
|
60
|
+
"waitFor",
|
|
61
|
+
"scroll",
|
|
62
|
+
"hover",
|
|
63
|
+
"select",
|
|
64
|
+
"toggle"
|
|
52
65
|
]).describe("The interaction type"),
|
|
53
66
|
selector: z.string().optional().describe("CSS selector for the target element"),
|
|
54
67
|
text: z.string().optional().describe("Text to type (required for \"type\" action)"),
|
|
55
68
|
key: z.string().optional().describe("Key to press (required for \"press\" action, e.g. \"Enter\", \"Tab\")"),
|
|
69
|
+
value: z.string().optional().describe("Option value to select (required for \"select\" action)"),
|
|
70
|
+
scrollX: z.number().optional().describe("Horizontal scroll delta in pixels (for \"scroll\" action)"),
|
|
71
|
+
scrollY: z.number().optional().describe("Vertical scroll delta in pixels (for \"scroll\" action, positive = down)"),
|
|
56
72
|
timeoutMs: z.number().optional().describe("Timeout in milliseconds (for \"waitFor\" action, default 4000)"),
|
|
57
|
-
sessionId: z.string().optional().describe("Session ID (
|
|
58
|
-
}, async ({ action, selector, text, key, timeoutMs, sessionId }) => {
|
|
59
|
-
const sid = sessionId
|
|
60
|
-
if (!sid) throw new Error("No active session. Call browser_start_session first.");
|
|
73
|
+
sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
|
|
74
|
+
}, async ({ action, selector, text, key, value, scrollX, scrollY, timeoutMs, sessionId }) => {
|
|
75
|
+
const sid = await resolveSession(sessionId);
|
|
61
76
|
const payload = { action };
|
|
62
77
|
if (selector) payload.selector = selector;
|
|
63
78
|
if (text) payload.text = text;
|
|
64
79
|
if (key) payload.key = key;
|
|
80
|
+
if (value) payload.value = value;
|
|
81
|
+
if (scrollX !== void 0) payload.scrollX = scrollX;
|
|
82
|
+
if (scrollY !== void 0) payload.scrollY = scrollY;
|
|
65
83
|
if (timeoutMs) payload.timeoutMs = timeoutMs;
|
|
66
84
|
const result = await getCore().runCommand({
|
|
67
85
|
sessionId: sid,
|
|
@@ -74,17 +92,17 @@ server.tool("browser_interact", "Interact with a page element. Actions: \"click\
|
|
|
74
92
|
text: JSON.stringify(result)
|
|
75
93
|
}] };
|
|
76
94
|
});
|
|
77
|
-
server.tool("browser_get_content", "Get the current page content. Modes: \"
|
|
95
|
+
server.tool("browser_get_content", "Get the current page content. Modes: \"text\" (readable text), \"a11y\" (accessibility tree — best for understanding page structure), \"title\" (page title only), \"html\" (raw HTML). Use \"a11y\" to see the full page hierarchy with roles, names, and states. A session is auto-started if needed.", {
|
|
78
96
|
mode: z.enum([
|
|
79
97
|
"title",
|
|
80
98
|
"text",
|
|
81
|
-
"html"
|
|
99
|
+
"html",
|
|
100
|
+
"a11y"
|
|
82
101
|
]).default("text").describe("Content extraction mode"),
|
|
83
102
|
selector: z.string().optional().describe("CSS selector to scope content (e.g. \"main\", \"#content\")"),
|
|
84
|
-
sessionId: z.string().optional().describe("Session ID (
|
|
103
|
+
sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
|
|
85
104
|
}, async ({ mode, selector, sessionId }) => {
|
|
86
|
-
const sid = sessionId
|
|
87
|
-
if (!sid) throw new Error("No active session. Call browser_start_session first.");
|
|
105
|
+
const sid = await resolveSession(sessionId);
|
|
88
106
|
const result = await getCore().getPageContent({
|
|
89
107
|
sessionId: sid,
|
|
90
108
|
mode,
|
|
@@ -95,7 +113,7 @@ server.tool("browser_get_content", "Get the current page content. Modes: \"title
|
|
|
95
113
|
text: JSON.stringify(result)
|
|
96
114
|
}] };
|
|
97
115
|
});
|
|
98
|
-
server.tool("browser_get_elements", "Discover all interactive elements on the current page (buttons, links, inputs, etc.). Returns CSS selectors you can use with browser_interact.
|
|
116
|
+
server.tool("browser_get_elements", "Discover all interactive elements on the current page (buttons, links, inputs, etc.). Returns CSS selectors you can use with browser_interact. A session is auto-started if needed.", {
|
|
99
117
|
roles: z.array(z.enum([
|
|
100
118
|
"link",
|
|
101
119
|
"button",
|
|
@@ -110,10 +128,9 @@ server.tool("browser_get_elements", "Discover all interactive elements on the cu
|
|
|
110
128
|
visibleOnly: z.boolean().default(true).describe("Only return visible elements"),
|
|
111
129
|
limit: z.number().default(50).describe("Maximum number of elements to return"),
|
|
112
130
|
selector: z.string().optional().describe("CSS selector to scope element discovery to a subtree"),
|
|
113
|
-
sessionId: z.string().optional().describe("Session ID (
|
|
131
|
+
sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
|
|
114
132
|
}, async ({ roles, visibleOnly, limit, selector, sessionId }) => {
|
|
115
|
-
const sid = sessionId
|
|
116
|
-
if (!sid) throw new Error("No active session. Call browser_start_session first.");
|
|
133
|
+
const sid = await resolveSession(sessionId);
|
|
117
134
|
const result = await getCore().getInteractiveElements({
|
|
118
135
|
sessionId: sid,
|
|
119
136
|
roles,
|
|
@@ -141,9 +158,15 @@ server.tool("browser_search_memory", "Search task memory for previously learned
|
|
|
141
158
|
text: JSON.stringify(result)
|
|
142
159
|
}] };
|
|
143
160
|
});
|
|
144
|
-
server.tool("browser_stop_session", "Stop the browser session and terminate Chrome.
|
|
161
|
+
server.tool("browser_stop_session", "Stop the browser session and terminate Chrome. The next browser tool call will auto-start a fresh session.", { sessionId: z.string().optional().describe("Session ID (uses active session if omitted)") }, async ({ sessionId }) => {
|
|
145
162
|
const sid = sessionId ?? activeSessionId;
|
|
146
|
-
if (!sid)
|
|
163
|
+
if (!sid) return { content: [{
|
|
164
|
+
type: "text",
|
|
165
|
+
text: JSON.stringify({
|
|
166
|
+
ok: true,
|
|
167
|
+
message: "No active session to stop."
|
|
168
|
+
})
|
|
169
|
+
}] };
|
|
147
170
|
await getCore().stopSession(sid);
|
|
148
171
|
if (activeSessionId === sid) activeSessionId = void 0;
|
|
149
172
|
return { content: [{
|
|
@@ -163,6 +186,9 @@ async function main() {
|
|
|
163
186
|
} catch {}
|
|
164
187
|
activeSessionId = void 0;
|
|
165
188
|
}
|
|
189
|
+
try {
|
|
190
|
+
getCore().sessions.cleanupSessions({ maxAgeDays: 0 });
|
|
191
|
+
} catch {}
|
|
166
192
|
};
|
|
167
193
|
await server.connect(transport);
|
|
168
194
|
}
|
|
@@ -101,12 +101,34 @@ async function getJson(url) {
|
|
|
101
101
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`);
|
|
102
102
|
return await response.json();
|
|
103
103
|
}
|
|
104
|
+
/** Check if the debug port is accepting TCP connections (faster than an HTTP fetch). */
|
|
105
|
+
function probePort(port) {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
const socket = net.createConnection({
|
|
108
|
+
host: "127.0.0.1",
|
|
109
|
+
port
|
|
110
|
+
});
|
|
111
|
+
socket.once("connect", () => {
|
|
112
|
+
socket.destroy();
|
|
113
|
+
resolve(true);
|
|
114
|
+
});
|
|
115
|
+
socket.once("error", () => {
|
|
116
|
+
socket.destroy();
|
|
117
|
+
resolve(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
104
121
|
async function waitForDebugger(port) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
await
|
|
122
|
+
const maxMs = 15e3;
|
|
123
|
+
const start = Date.now();
|
|
124
|
+
let delay = 50;
|
|
125
|
+
while (Date.now() - start < maxMs) {
|
|
126
|
+
if (await probePort(port)) try {
|
|
127
|
+
await getJson(`http://127.0.0.1:${port}/json/version`);
|
|
128
|
+
return;
|
|
129
|
+
} catch {}
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
131
|
+
delay = Math.min(delay * 2, 250);
|
|
110
132
|
}
|
|
111
133
|
throw new Error("Chrome debug endpoint did not become ready in time");
|
|
112
134
|
}
|
|
@@ -128,6 +150,15 @@ async function createTarget(cdpUrl, url = "about:blank") {
|
|
|
128
150
|
} catch {}
|
|
129
151
|
return await ensurePageWebSocketUrl(cdpUrl);
|
|
130
152
|
}
|
|
153
|
+
async function applyUserAgent(targetWsUrl, userAgent) {
|
|
154
|
+
const conn = await CdpConnection.connect(targetWsUrl);
|
|
155
|
+
try {
|
|
156
|
+
await conn.send("Network.enable");
|
|
157
|
+
await conn.send("Network.setUserAgentOverride", { userAgent });
|
|
158
|
+
} finally {
|
|
159
|
+
conn.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
131
162
|
async function evaluateExpression(targetWsUrl, expression) {
|
|
132
163
|
const conn = await CdpConnection.connect(targetWsUrl);
|
|
133
164
|
try {
|
|
@@ -199,13 +230,14 @@ var ChromeCdpBrowserController = class {
|
|
|
199
230
|
closeConnection(targetWsUrl) {
|
|
200
231
|
this.dropConnection(targetWsUrl);
|
|
201
232
|
}
|
|
202
|
-
async connect(cdpUrl) {
|
|
233
|
+
async connect(cdpUrl, options) {
|
|
203
234
|
const parsed = new URL(cdpUrl);
|
|
204
235
|
const port = Number.parseInt(parsed.port, 10);
|
|
205
236
|
if (!port) throw new Error(`Invalid CDP URL: could not extract port from ${cdpUrl}`);
|
|
206
237
|
await waitForDebugger(port);
|
|
207
238
|
const targetWsUrl = await createTarget(cdpUrl);
|
|
208
239
|
await evaluateExpression(targetWsUrl, "window.location.href");
|
|
240
|
+
if (options?.userAgent) await applyUserAgent(targetWsUrl, options.userAgent);
|
|
209
241
|
return {
|
|
210
242
|
pid: 0,
|
|
211
243
|
cdpUrl,
|
|
@@ -224,7 +256,8 @@ var ChromeCdpBrowserController = class {
|
|
|
224
256
|
cached.enabled.runtime = true;
|
|
225
257
|
}
|
|
226
258
|
}
|
|
227
|
-
async launch(sessionId,
|
|
259
|
+
async launch(sessionId, options) {
|
|
260
|
+
const { executablePath: explicitPath, userProfileDir, headless, userAgent } = options ?? {};
|
|
228
261
|
const executablePath = discoverChrome(explicitPath);
|
|
229
262
|
const extension = loadControlExtension();
|
|
230
263
|
let profileDir;
|
|
@@ -234,7 +267,10 @@ var ChromeCdpBrowserController = class {
|
|
|
234
267
|
fs.mkdirSync(profileDir, { recursive: true });
|
|
235
268
|
const lockFile = path.join(profileDir, "SingletonLock");
|
|
236
269
|
if (fs.existsSync(lockFile)) throw new Error(`Chrome profile is already in use (lock file exists: ${lockFile}). Quit the running Chrome instance first, or use --cdp-url to connect to it instead.`);
|
|
237
|
-
const launchAttempts = [
|
|
270
|
+
const launchAttempts = headless ? [{
|
|
271
|
+
withExtension: false,
|
|
272
|
+
headless: true
|
|
273
|
+
}] : [
|
|
238
274
|
{
|
|
239
275
|
withExtension: true,
|
|
240
276
|
headless: false
|
|
@@ -271,6 +307,7 @@ var ChromeCdpBrowserController = class {
|
|
|
271
307
|
const targetWsUrl = await createTarget(cdpUrl, "about:blank");
|
|
272
308
|
await evaluateExpression(targetWsUrl, "window.location.href");
|
|
273
309
|
if (!child.pid) throw new Error("Failed to launch Chrome process");
|
|
310
|
+
if (userAgent) await applyUserAgent(targetWsUrl, userAgent);
|
|
274
311
|
return {
|
|
275
312
|
pid: child.pid,
|
|
276
313
|
cdpUrl,
|
|
@@ -285,36 +322,31 @@ var ChromeCdpBrowserController = class {
|
|
|
285
322
|
}
|
|
286
323
|
throw new Error(lastError?.message ?? "Unable to launch Chrome");
|
|
287
324
|
}
|
|
288
|
-
|
|
325
|
+
/** Execute fn with a pooled connection; on failure drop connection and retry once. */
|
|
326
|
+
async withRetry(targetWsUrl, fn) {
|
|
289
327
|
let conn = await this.getConnection(targetWsUrl);
|
|
290
328
|
try {
|
|
291
|
-
await
|
|
292
|
-
const loadPromise = Promise.race([conn.waitForEvent("Page.loadEventFired", 6e3), conn.waitForEvent("Page.frameStoppedLoading", 6e3)]);
|
|
293
|
-
await conn.send("Page.navigate", { url });
|
|
294
|
-
try {
|
|
295
|
-
await loadPromise;
|
|
296
|
-
} catch {}
|
|
297
|
-
return (await conn.send("Runtime.evaluate", {
|
|
298
|
-
expression: "window.location.href",
|
|
299
|
-
returnByValue: true
|
|
300
|
-
})).result.value ?? url;
|
|
329
|
+
return await fn(conn);
|
|
301
330
|
} catch {
|
|
302
331
|
this.dropConnection(targetWsUrl);
|
|
303
332
|
conn = await this.getConnection(targetWsUrl);
|
|
333
|
+
return await fn(conn);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async navigate(targetWsUrl, url) {
|
|
337
|
+
return await this.withRetry(targetWsUrl, async (conn) => {
|
|
304
338
|
await this.ensureEnabled(targetWsUrl);
|
|
339
|
+
const navigatedPromise = conn.waitForEvent("Page.frameNavigated", 6e3).catch(() => void 0);
|
|
305
340
|
const loadPromise = Promise.race([conn.waitForEvent("Page.loadEventFired", 6e3), conn.waitForEvent("Page.frameStoppedLoading", 6e3)]);
|
|
306
341
|
await conn.send("Page.navigate", { url });
|
|
342
|
+
const navigatedEvent = await navigatedPromise;
|
|
307
343
|
try {
|
|
308
344
|
await loadPromise;
|
|
309
345
|
} catch {}
|
|
310
|
-
return
|
|
311
|
-
|
|
312
|
-
returnByValue: true
|
|
313
|
-
})).result.value ?? url;
|
|
314
|
-
}
|
|
346
|
+
return navigatedEvent?.frame?.url ?? url;
|
|
347
|
+
});
|
|
315
348
|
}
|
|
316
349
|
async interact(targetWsUrl, payload) {
|
|
317
|
-
let conn = await this.getConnection(targetWsUrl);
|
|
318
350
|
const expression = `(async () => {
|
|
319
351
|
const payload = ${JSON.stringify(payload)};
|
|
320
352
|
if (payload.action === 'click') {
|
|
@@ -371,33 +403,68 @@ var ChromeCdpBrowserController = class {
|
|
|
371
403
|
}
|
|
372
404
|
return typeof result === 'string' ? result : JSON.stringify(result);
|
|
373
405
|
}
|
|
406
|
+
if (payload.action === 'scroll') {
|
|
407
|
+
if (payload.selector) {
|
|
408
|
+
const el = document.querySelector(payload.selector);
|
|
409
|
+
if (!el) throw new Error('Selector not found');
|
|
410
|
+
el.scrollBy({ left: payload.scrollX ?? 0, top: payload.scrollY ?? 0, behavior: 'smooth' });
|
|
411
|
+
return 'scrolled element';
|
|
412
|
+
}
|
|
413
|
+
window.scrollBy({ left: payload.scrollX ?? 0, top: payload.scrollY ?? 0, behavior: 'smooth' });
|
|
414
|
+
return 'scrolled page';
|
|
415
|
+
}
|
|
416
|
+
if (payload.action === 'hover') {
|
|
417
|
+
const el = document.querySelector(payload.selector);
|
|
418
|
+
if (!el) throw new Error('Selector not found');
|
|
419
|
+
const rect = el.getBoundingClientRect();
|
|
420
|
+
const cx = rect.left + rect.width / 2;
|
|
421
|
+
const cy = rect.top + rect.height / 2;
|
|
422
|
+
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, clientX: cx, clientY: cy }));
|
|
423
|
+
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: cx, clientY: cy }));
|
|
424
|
+
el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: cx, clientY: cy }));
|
|
425
|
+
return 'hovered';
|
|
426
|
+
}
|
|
427
|
+
if (payload.action === 'select') {
|
|
428
|
+
const el = document.querySelector(payload.selector);
|
|
429
|
+
if (!el) throw new Error('Selector not found');
|
|
430
|
+
if (el.tagName.toLowerCase() !== 'select') throw new Error('Element is not a <select>');
|
|
431
|
+
el.value = payload.value ?? '';
|
|
432
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
433
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
434
|
+
return 'selected ' + el.value;
|
|
435
|
+
}
|
|
436
|
+
if (payload.action === 'toggle') {
|
|
437
|
+
const el = document.querySelector(payload.selector);
|
|
438
|
+
if (!el) throw new Error('Selector not found');
|
|
439
|
+
el.click();
|
|
440
|
+
const checked = el.checked !== undefined ? el.checked : el.getAttribute('aria-checked') === 'true';
|
|
441
|
+
return 'toggled to ' + (checked ? 'checked' : 'unchecked');
|
|
442
|
+
}
|
|
374
443
|
throw new Error('Unsupported interact action');
|
|
375
444
|
})()`;
|
|
376
|
-
|
|
445
|
+
return await this.withRetry(targetWsUrl, async (conn) => {
|
|
377
446
|
await this.ensureEnabled(targetWsUrl);
|
|
378
|
-
const value = (await
|
|
447
|
+
const value = (await conn.send("Runtime.evaluate", {
|
|
379
448
|
expression,
|
|
380
449
|
returnByValue: true,
|
|
381
450
|
awaitPromise: true
|
|
382
451
|
})).result.value ?? "";
|
|
383
452
|
if (payload.action === "click" && value === "clicked") try {
|
|
384
|
-
await
|
|
453
|
+
await conn.waitForEvent("Page.frameNavigated", 50);
|
|
454
|
+
try {
|
|
455
|
+
await Promise.race([conn.waitForEvent("Page.loadEventFired", 3e3), conn.waitForEvent("Page.frameStoppedLoading", 3e3)]);
|
|
456
|
+
} catch {}
|
|
385
457
|
} catch {}
|
|
386
458
|
return value;
|
|
387
|
-
};
|
|
388
|
-
try {
|
|
389
|
-
return await execute(conn);
|
|
390
|
-
} catch {
|
|
391
|
-
this.dropConnection(targetWsUrl);
|
|
392
|
-
conn = await this.getConnection(targetWsUrl);
|
|
393
|
-
return await execute(conn);
|
|
394
|
-
}
|
|
459
|
+
});
|
|
395
460
|
}
|
|
396
461
|
async getContent(targetWsUrl, options) {
|
|
397
|
-
|
|
398
|
-
|
|
462
|
+
if (options.mode === "a11y") return {
|
|
463
|
+
mode: "a11y",
|
|
464
|
+
content: await this.getAccessibilityTree(targetWsUrl)
|
|
465
|
+
};
|
|
399
466
|
const expression = `(() => {
|
|
400
|
-
const options = ${
|
|
467
|
+
const options = ${JSON.stringify(options)};
|
|
401
468
|
if (options.mode === 'title') return document.title ?? '';
|
|
402
469
|
if (options.mode === 'html') {
|
|
403
470
|
if (options.selector) {
|
|
@@ -412,7 +479,7 @@ var ChromeCdpBrowserController = class {
|
|
|
412
479
|
}
|
|
413
480
|
return document.body?.innerText ?? '';
|
|
414
481
|
})()`;
|
|
415
|
-
|
|
482
|
+
return await this.withRetry(targetWsUrl, async (conn) => {
|
|
416
483
|
await this.ensureEnabled(targetWsUrl);
|
|
417
484
|
const content = (await conn.send("Runtime.evaluate", {
|
|
418
485
|
expression,
|
|
@@ -422,25 +489,56 @@ var ChromeCdpBrowserController = class {
|
|
|
422
489
|
mode: options.mode,
|
|
423
490
|
content
|
|
424
491
|
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
async getAccessibilityTree(targetWsUrl) {
|
|
495
|
+
return await this.withRetry(targetWsUrl, async (conn) => {
|
|
496
|
+
await conn.send("Accessibility.enable");
|
|
497
|
+
const { nodes } = await conn.send("Accessibility.getFullAXTree");
|
|
498
|
+
const childrenMap = /* @__PURE__ */ new Map();
|
|
499
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
500
|
+
for (const node of nodes) {
|
|
501
|
+
nodeMap.set(node.nodeId, node);
|
|
502
|
+
if (node.parentId) {
|
|
503
|
+
const siblings = childrenMap.get(node.parentId);
|
|
504
|
+
if (siblings) siblings.push(node.nodeId);
|
|
505
|
+
else childrenMap.set(node.parentId, [node.nodeId]);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const lines = [];
|
|
509
|
+
const formatNode = (nodeId, depth) => {
|
|
510
|
+
const node = nodeMap.get(nodeId);
|
|
511
|
+
if (!node) return;
|
|
512
|
+
const role = node.role?.value ?? "unknown";
|
|
513
|
+
const name = node.name?.value ?? "";
|
|
514
|
+
const value = node.value?.value ?? "";
|
|
515
|
+
if (node.ignored) {
|
|
516
|
+
const children = childrenMap.get(nodeId) ?? node.childIds ?? [];
|
|
517
|
+
for (const childId of children) formatNode(childId, depth);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const skip = !name && !value && (role === "generic" || role === "none" || role === "GenericContainer");
|
|
521
|
+
if (!skip) {
|
|
522
|
+
let line = `${" ".repeat(depth)}${role}`;
|
|
523
|
+
if (name) line += ` "${name}"`;
|
|
524
|
+
if (value) line += ` value="${value}"`;
|
|
525
|
+
if (node.properties) {
|
|
526
|
+
for (const prop of node.properties) if (prop.value.value === true) line += ` [${prop.name}]`;
|
|
527
|
+
else if (prop.name === "checked" && prop.value.value === "mixed") line += ` [indeterminate]`;
|
|
528
|
+
}
|
|
529
|
+
lines.push(line);
|
|
530
|
+
}
|
|
531
|
+
const children = childrenMap.get(nodeId) ?? node.childIds ?? [];
|
|
532
|
+
for (const childId of children) formatNode(childId, skip ? depth : depth + 1);
|
|
436
533
|
};
|
|
437
|
-
|
|
534
|
+
const roots = nodes.filter((n) => !n.parentId);
|
|
535
|
+
for (const root of roots) formatNode(root.nodeId, 0);
|
|
536
|
+
return lines.join("\n");
|
|
537
|
+
});
|
|
438
538
|
}
|
|
439
539
|
async getInteractiveElements(targetWsUrl, options) {
|
|
440
|
-
const o = JSON.stringify(options);
|
|
441
|
-
let conn = await this.getConnection(targetWsUrl);
|
|
442
540
|
const expression = `(() => {
|
|
443
|
-
const options = ${
|
|
541
|
+
const options = ${JSON.stringify(options)};
|
|
444
542
|
const visibleOnly = options.visibleOnly !== false;
|
|
445
543
|
const limit = options.limit ?? 50;
|
|
446
544
|
const scopeSelector = options.selector;
|
|
@@ -649,21 +747,13 @@ var ChromeCdpBrowserController = class {
|
|
|
649
747
|
if (v && typeof v === "object" && Array.isArray(v.elements)) return v;
|
|
650
748
|
return emptyResult;
|
|
651
749
|
};
|
|
652
|
-
|
|
653
|
-
await this.ensureEnabled(targetWsUrl);
|
|
654
|
-
return extract(await conn.send("Runtime.evaluate", {
|
|
655
|
-
expression,
|
|
656
|
-
returnByValue: true
|
|
657
|
-
}));
|
|
658
|
-
} catch {
|
|
659
|
-
this.dropConnection(targetWsUrl);
|
|
660
|
-
conn = await this.getConnection(targetWsUrl);
|
|
750
|
+
return await this.withRetry(targetWsUrl, async (conn) => {
|
|
661
751
|
await this.ensureEnabled(targetWsUrl);
|
|
662
752
|
return extract(await conn.send("Runtime.evaluate", {
|
|
663
753
|
expression,
|
|
664
754
|
returnByValue: true
|
|
665
755
|
}));
|
|
666
|
-
}
|
|
756
|
+
});
|
|
667
757
|
}
|
|
668
758
|
terminate(pid) {
|
|
669
759
|
if (pid === 0) return;
|
|
@@ -674,7 +764,7 @@ var ChromeCdpBrowserController = class {
|
|
|
674
764
|
};
|
|
675
765
|
var MockBrowserController = class {
|
|
676
766
|
pages = /* @__PURE__ */ new Map();
|
|
677
|
-
async launch(sessionId) {
|
|
767
|
+
async launch(sessionId, _options) {
|
|
678
768
|
const cdpUrl = `mock://${sessionId}`;
|
|
679
769
|
const targetWsUrl = cdpUrl;
|
|
680
770
|
this.pages.set(cdpUrl, {
|
|
@@ -689,7 +779,7 @@ var MockBrowserController = class {
|
|
|
689
779
|
targetWsUrl
|
|
690
780
|
};
|
|
691
781
|
}
|
|
692
|
-
async connect(cdpUrl) {
|
|
782
|
+
async connect(cdpUrl, _options) {
|
|
693
783
|
this.pages.set(cdpUrl, {
|
|
694
784
|
url: "about:blank",
|
|
695
785
|
title: "about:blank",
|
|
@@ -795,6 +885,15 @@ var SessionStore = class {
|
|
|
795
885
|
};
|
|
796
886
|
this.write(state);
|
|
797
887
|
}
|
|
888
|
+
/** Remove all terminated sessions from the store. Returns the count removed. */
|
|
889
|
+
purgeTerminated() {
|
|
890
|
+
const state = this.read();
|
|
891
|
+
const before = Object.keys(state.sessions).length;
|
|
892
|
+
for (const [id, record] of Object.entries(state.sessions)) if (record.session.status === "terminated" && id !== state.activeSessionId) delete state.sessions[id];
|
|
893
|
+
const removed = before - Object.keys(state.sessions).length;
|
|
894
|
+
if (removed > 0) this.write(state);
|
|
895
|
+
return removed;
|
|
896
|
+
}
|
|
798
897
|
replaceSessions(sessions, activeSessionId) {
|
|
799
898
|
const state = {
|
|
800
899
|
sessions: Object.fromEntries(sessions.map((record) => [record.session.sessionId, record])),
|
|
@@ -822,10 +921,18 @@ var SessionManager = class {
|
|
|
822
921
|
async createSession(input) {
|
|
823
922
|
if (input.browser !== "chrome") throw new Error("Only chrome is supported");
|
|
824
923
|
const active = this.store.getActive();
|
|
825
|
-
if (active && active.session.status !== "terminated")
|
|
924
|
+
if (active && active.session.status !== "terminated") {
|
|
925
|
+
if (await this.isSessionAlive(active)) return active.session;
|
|
926
|
+
await this.forceTerminate(active);
|
|
927
|
+
}
|
|
826
928
|
const sessionId = crypto.randomUUID();
|
|
827
929
|
const token = this.ctx.tokenService.issue(sessionId);
|
|
828
|
-
const launched = this.ctx.config.cdpUrl ? await this.browser.connect(this.ctx.config.cdpUrl) : await this.browser.launch(sessionId,
|
|
930
|
+
const launched = this.ctx.config.cdpUrl ? await this.browser.connect(this.ctx.config.cdpUrl, { userAgent: this.ctx.config.userAgent }) : await this.browser.launch(sessionId, {
|
|
931
|
+
executablePath: this.ctx.config.browserExecutablePath,
|
|
932
|
+
userProfileDir: this.ctx.config.userProfileDir,
|
|
933
|
+
headless: this.ctx.config.headless,
|
|
934
|
+
userAgent: this.ctx.config.userAgent
|
|
935
|
+
});
|
|
829
936
|
const session = {
|
|
830
937
|
sessionId,
|
|
831
938
|
status: "ready",
|
|
@@ -845,9 +952,25 @@ var SessionManager = class {
|
|
|
845
952
|
getSession(sessionId) {
|
|
846
953
|
return this.mustGetRecord(sessionId).session;
|
|
847
954
|
}
|
|
955
|
+
/** Return a healthy session, recovering automatically if needed. */
|
|
956
|
+
async ensureSession(sessionId) {
|
|
957
|
+
const record = this.store.get(sessionId);
|
|
958
|
+
if (!record) throw new Error("Session not found");
|
|
959
|
+
if (record.session.status === "ready") {
|
|
960
|
+
if (await this.isSessionAlive(record)) return record;
|
|
961
|
+
this.recordEvent(sessionId, "lifecycle", "warning", "Session connection lost, recovering");
|
|
962
|
+
}
|
|
963
|
+
if (record.session.status !== "terminated") try {
|
|
964
|
+
const recovered = await this.restartSession(sessionId);
|
|
965
|
+
return this.mustGetRecord(recovered.sessionId);
|
|
966
|
+
} catch (restartError) {
|
|
967
|
+
this.recordEvent(sessionId, "lifecycle", "error", `Recovery failed: ${restartError.message}`);
|
|
968
|
+
throw new Error(`Session is not ready and recovery failed: ${restartError.message}`);
|
|
969
|
+
}
|
|
970
|
+
throw new Error("Session is terminated. Start a new session.");
|
|
971
|
+
}
|
|
848
972
|
async executeCommand(sessionId, input) {
|
|
849
|
-
const record = this.
|
|
850
|
-
if (record.session.status !== "ready") throw new Error("Session is not ready. Restart session and retry.");
|
|
973
|
+
const record = await this.ensureSession(sessionId);
|
|
851
974
|
const command = {
|
|
852
975
|
commandId: input.commandId,
|
|
853
976
|
sessionId,
|
|
@@ -913,13 +1036,11 @@ var SessionManager = class {
|
|
|
913
1036
|
return completed;
|
|
914
1037
|
}
|
|
915
1038
|
async getContent(sessionId, options) {
|
|
916
|
-
const record = this.
|
|
917
|
-
if (record.session.status !== "ready") throw new Error("Session is not ready");
|
|
1039
|
+
const record = await this.ensureSession(sessionId);
|
|
918
1040
|
return await this.browser.getContent(record.targetWsUrl, options);
|
|
919
1041
|
}
|
|
920
1042
|
async getInteractiveElements(sessionId, options) {
|
|
921
|
-
const record = this.
|
|
922
|
-
if (record.session.status !== "ready") throw new Error("Session is not ready");
|
|
1043
|
+
const record = await this.ensureSession(sessionId);
|
|
923
1044
|
return await this.browser.getInteractiveElements(record.targetWsUrl, options);
|
|
924
1045
|
}
|
|
925
1046
|
setStatus(status, reason) {
|
|
@@ -956,11 +1077,18 @@ var SessionManager = class {
|
|
|
956
1077
|
}
|
|
957
1078
|
async restartSession(sessionId) {
|
|
958
1079
|
const record = this.mustGetRecord(sessionId);
|
|
959
|
-
if (record.session.status === "ready") return record.session;
|
|
960
1080
|
if (this.browser.closeConnection) try {
|
|
961
1081
|
this.browser.closeConnection(record.targetWsUrl);
|
|
962
1082
|
} catch {}
|
|
963
|
-
|
|
1083
|
+
if (record.pid > 0) try {
|
|
1084
|
+
this.browser.terminate(record.pid);
|
|
1085
|
+
} catch {}
|
|
1086
|
+
const relaunched = this.ctx.config.cdpUrl ? await this.browser.connect(this.ctx.config.cdpUrl, { userAgent: this.ctx.config.userAgent }) : await this.browser.launch(sessionId, {
|
|
1087
|
+
executablePath: this.ctx.config.browserExecutablePath,
|
|
1088
|
+
userProfileDir: this.ctx.config.userProfileDir,
|
|
1089
|
+
headless: this.ctx.config.headless,
|
|
1090
|
+
userAgent: this.ctx.config.userAgent
|
|
1091
|
+
});
|
|
964
1092
|
const restarted = {
|
|
965
1093
|
...record.session,
|
|
966
1094
|
status: "ready",
|
|
@@ -1053,6 +1181,40 @@ var SessionManager = class {
|
|
|
1053
1181
|
dryRun
|
|
1054
1182
|
};
|
|
1055
1183
|
}
|
|
1184
|
+
/** Quick health check — probe the CDP connection with a lightweight evaluate. */
|
|
1185
|
+
async isSessionAlive(record) {
|
|
1186
|
+
if (!record.targetWsUrl) return false;
|
|
1187
|
+
if (record.pid > 0) try {
|
|
1188
|
+
process.kill(record.pid, 0);
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
if (err.code === "EPERM") {} else return false;
|
|
1191
|
+
}
|
|
1192
|
+
try {
|
|
1193
|
+
await this.browser.getContent(record.targetWsUrl, { mode: "title" });
|
|
1194
|
+
return true;
|
|
1195
|
+
} catch {
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
/** Force-terminate a session record, cleaning up process, connection, and store. */
|
|
1200
|
+
async forceTerminate(record) {
|
|
1201
|
+
const { sessionId } = record.session;
|
|
1202
|
+
if (this.browser.closeConnection) try {
|
|
1203
|
+
this.browser.closeConnection(record.targetWsUrl);
|
|
1204
|
+
} catch {}
|
|
1205
|
+
if (record.pid > 0) try {
|
|
1206
|
+
this.browser.terminate(record.pid);
|
|
1207
|
+
} catch {}
|
|
1208
|
+
this.ctx.tokenService.revoke(sessionId);
|
|
1209
|
+
const terminated = {
|
|
1210
|
+
...record.session,
|
|
1211
|
+
status: "terminated",
|
|
1212
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1213
|
+
};
|
|
1214
|
+
this.store.setSession(terminated);
|
|
1215
|
+
this.store.clearActive(sessionId);
|
|
1216
|
+
this.recordEvent(sessionId, "lifecycle", "warning", "Session force-terminated (stale)");
|
|
1217
|
+
}
|
|
1056
1218
|
mustGetRecord(sessionId) {
|
|
1057
1219
|
const record = this.store.get(sessionId);
|
|
1058
1220
|
if (!record) throw new Error("Session not found");
|
|
@@ -1187,7 +1349,9 @@ function loadConfig(env = process.env) {
|
|
|
1187
1349
|
logDir: env.AGENTIC_BROWSER_LOG_DIR ?? path.resolve(process.cwd(), ".agentic-browser"),
|
|
1188
1350
|
browserExecutablePath: env.AGENTIC_BROWSER_CHROME_PATH,
|
|
1189
1351
|
cdpUrl: env.AGENTIC_BROWSER_CDP_URL,
|
|
1190
|
-
userProfileDir
|
|
1352
|
+
userProfileDir,
|
|
1353
|
+
headless: env.AGENTIC_BROWSER_HEADLESS === "true" || env.AGENTIC_BROWSER_HEADLESS === "1",
|
|
1354
|
+
userAgent: env.AGENTIC_BROWSER_USER_AGENT || void 0
|
|
1191
1355
|
};
|
|
1192
1356
|
}
|
|
1193
1357
|
|
|
@@ -1345,29 +1509,64 @@ function selectorSignal(insight) {
|
|
|
1345
1509
|
const evidenceStrength = Math.min(selectorEvidence / 5, 1);
|
|
1346
1510
|
return .7 * recipeCoverage + .3 * evidenceStrength;
|
|
1347
1511
|
}
|
|
1512
|
+
function scoreInsight(insight, normalizedIntent, normalizedDomain) {
|
|
1513
|
+
const insightIntent = normalize(insight.taskIntent);
|
|
1514
|
+
const intentMatch = insightIntent === normalizedIntent ? 1 : 0;
|
|
1515
|
+
const intentPartial = intentMatch === 1 || insightIntent.includes(normalizedIntent) || normalizedIntent.includes(insightIntent) ? .65 : 0;
|
|
1516
|
+
const domainMatch = normalizedDomain && normalize(insight.siteDomain) === normalizedDomain ? 1 : normalizedDomain ? 0 : .6;
|
|
1517
|
+
const reliability = .6 * confidenceFromCounts(insight.successCount, insight.failureCount) + .4 * freshnessWeight(insight.freshness);
|
|
1518
|
+
const selectorQuality = selectorSignal(insight);
|
|
1519
|
+
const score = .5 * Math.max(intentMatch, intentPartial) + .2 * domainMatch + .15 * reliability + .15 * selectorQuality;
|
|
1520
|
+
return {
|
|
1521
|
+
insightId: insight.insightId,
|
|
1522
|
+
taskIntent: insight.taskIntent,
|
|
1523
|
+
siteDomain: insight.siteDomain,
|
|
1524
|
+
confidence: insight.confidence,
|
|
1525
|
+
freshness: insight.freshness,
|
|
1526
|
+
lastVerifiedAt: insight.lastVerifiedAt,
|
|
1527
|
+
selectorHints: buildSelectorHints(insight),
|
|
1528
|
+
score
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1348
1531
|
var MemoryIndex = class {
|
|
1532
|
+
/** Domain → insights index, rebuilt lazily when insight list changes. */
|
|
1533
|
+
domainIndex = /* @__PURE__ */ new Map();
|
|
1534
|
+
indexedInsights = null;
|
|
1535
|
+
indexedLength = 0;
|
|
1536
|
+
/** Rebuild the domain index when the underlying array or its size changes. */
|
|
1537
|
+
ensureIndex(insights) {
|
|
1538
|
+
if (this.indexedInsights === insights && this.indexedLength === insights.length) return;
|
|
1539
|
+
this.domainIndex.clear();
|
|
1540
|
+
for (const insight of insights) {
|
|
1541
|
+
const domain = normalize(insight.siteDomain);
|
|
1542
|
+
let bucket = this.domainIndex.get(domain);
|
|
1543
|
+
if (!bucket) {
|
|
1544
|
+
bucket = [];
|
|
1545
|
+
this.domainIndex.set(domain, bucket);
|
|
1546
|
+
}
|
|
1547
|
+
bucket.push(insight);
|
|
1548
|
+
}
|
|
1549
|
+
this.indexedInsights = insights;
|
|
1550
|
+
this.indexedLength = insights.length;
|
|
1551
|
+
}
|
|
1349
1552
|
search(insights, input) {
|
|
1350
1553
|
const normalizedIntent = normalize(input.taskIntent);
|
|
1351
1554
|
const normalizedDomain = input.siteDomain ? normalize(input.siteDomain) : void 0;
|
|
1352
1555
|
const limit = input.limit ?? 10;
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
selectorHints: buildSelectorHints(insight),
|
|
1368
|
-
score
|
|
1369
|
-
};
|
|
1370
|
-
}).filter((result) => result.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1556
|
+
this.ensureIndex(insights);
|
|
1557
|
+
const candidates = normalizedDomain ? this.domainIndex.get(normalizedDomain) ?? [] : insights;
|
|
1558
|
+
if (limit === 1 && normalizedDomain) {
|
|
1559
|
+
let best;
|
|
1560
|
+
for (const insight of candidates) {
|
|
1561
|
+
const result = scoreInsight(insight, normalizedIntent, normalizedDomain);
|
|
1562
|
+
if (result.score > 0 && (!best || result.score > best.score)) {
|
|
1563
|
+
best = result;
|
|
1564
|
+
if (best.score >= .95) break;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return best ? [best] : [];
|
|
1568
|
+
}
|
|
1569
|
+
return candidates.map((insight) => scoreInsight(insight, normalizedIntent, normalizedDomain)).filter((result) => result.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1371
1570
|
}
|
|
1372
1571
|
};
|
|
1373
1572
|
|
|
@@ -1464,8 +1663,13 @@ const MemoryStateSchema = z.object({ insights: z.array(TaskInsightSchema) });
|
|
|
1464
1663
|
//#endregion
|
|
1465
1664
|
//#region src/memory/task-insight-store.ts
|
|
1466
1665
|
const EMPTY_STATE = { insights: [] };
|
|
1666
|
+
const FLUSH_DELAY_MS = 500;
|
|
1467
1667
|
var TaskInsightStore = class {
|
|
1468
1668
|
filePath;
|
|
1669
|
+
/** In-memory cache – authoritative after first load. */
|
|
1670
|
+
cached = null;
|
|
1671
|
+
dirty = false;
|
|
1672
|
+
flushTimer = null;
|
|
1469
1673
|
constructor(baseDir) {
|
|
1470
1674
|
const memoryDir = path.join(baseDir, "memory");
|
|
1471
1675
|
fs.mkdirSync(memoryDir, { recursive: true });
|
|
@@ -1474,27 +1678,66 @@ var TaskInsightStore = class {
|
|
|
1474
1678
|
try {
|
|
1475
1679
|
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
|
1476
1680
|
} catch {}
|
|
1477
|
-
if (!fs.existsSync(this.filePath)) this.
|
|
1681
|
+
if (!fs.existsSync(this.filePath)) this.writeDisk(EMPTY_STATE);
|
|
1682
|
+
const onExit = () => this.flushSync();
|
|
1683
|
+
process.on("exit", onExit);
|
|
1684
|
+
process.on("SIGINT", () => {
|
|
1685
|
+
this.flushSync();
|
|
1686
|
+
process.exit(0);
|
|
1687
|
+
});
|
|
1688
|
+
process.on("SIGTERM", () => {
|
|
1689
|
+
this.flushSync();
|
|
1690
|
+
process.exit(0);
|
|
1691
|
+
});
|
|
1478
1692
|
}
|
|
1479
1693
|
list() {
|
|
1480
|
-
return this.
|
|
1694
|
+
return this.getCache();
|
|
1481
1695
|
}
|
|
1482
1696
|
get(insightId) {
|
|
1483
|
-
return this.
|
|
1697
|
+
return this.getCache().find((insight) => insight.insightId === insightId);
|
|
1484
1698
|
}
|
|
1485
1699
|
upsert(insight) {
|
|
1486
1700
|
TaskInsightSchema.parse(insight);
|
|
1487
|
-
const
|
|
1488
|
-
const index =
|
|
1489
|
-
if (index >= 0)
|
|
1490
|
-
else
|
|
1491
|
-
this.
|
|
1701
|
+
const insights = this.getCache();
|
|
1702
|
+
const index = insights.findIndex((entry) => entry.insightId === insight.insightId);
|
|
1703
|
+
if (index >= 0) insights[index] = insight;
|
|
1704
|
+
else insights.push(insight);
|
|
1705
|
+
this.markDirty();
|
|
1492
1706
|
}
|
|
1493
1707
|
replaceMany(insights) {
|
|
1494
1708
|
for (const insight of insights) TaskInsightSchema.parse(insight);
|
|
1495
|
-
this.
|
|
1709
|
+
this.cached = insights;
|
|
1710
|
+
this.markDirty();
|
|
1711
|
+
}
|
|
1712
|
+
/** Force an immediate synchronous flush (used at shutdown). */
|
|
1713
|
+
flushSync() {
|
|
1714
|
+
if (this.flushTimer) {
|
|
1715
|
+
clearTimeout(this.flushTimer);
|
|
1716
|
+
this.flushTimer = null;
|
|
1717
|
+
}
|
|
1718
|
+
if (this.dirty && this.cached) {
|
|
1719
|
+
this.writeDisk({ insights: this.cached });
|
|
1720
|
+
this.dirty = false;
|
|
1721
|
+
}
|
|
1496
1722
|
}
|
|
1497
|
-
|
|
1723
|
+
/** Return the in-memory cache, loading from disk on first access. */
|
|
1724
|
+
getCache() {
|
|
1725
|
+
if (this.cached) return this.cached;
|
|
1726
|
+
this.cached = this.readDisk().insights;
|
|
1727
|
+
return this.cached;
|
|
1728
|
+
}
|
|
1729
|
+
markDirty() {
|
|
1730
|
+
this.dirty = true;
|
|
1731
|
+
if (!this.flushTimer) {
|
|
1732
|
+
this.flushTimer = setTimeout(() => {
|
|
1733
|
+
this.flushTimer = null;
|
|
1734
|
+
this.flushSync();
|
|
1735
|
+
}, FLUSH_DELAY_MS);
|
|
1736
|
+
if (this.flushTimer && typeof this.flushTimer === "object" && "unref" in this.flushTimer) this.flushTimer.unref();
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
/** Read and validate from disk (only on first load or corruption recovery). */
|
|
1740
|
+
readDisk() {
|
|
1498
1741
|
let raw;
|
|
1499
1742
|
try {
|
|
1500
1743
|
raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
|
|
@@ -1511,7 +1754,7 @@ var TaskInsightStore = class {
|
|
|
1511
1754
|
if (salvaged.length > 0) {
|
|
1512
1755
|
const state = { insights: salvaged };
|
|
1513
1756
|
this.backupAndReset();
|
|
1514
|
-
this.
|
|
1757
|
+
this.writeDisk(state);
|
|
1515
1758
|
return state;
|
|
1516
1759
|
}
|
|
1517
1760
|
}
|
|
@@ -1525,7 +1768,7 @@ var TaskInsightStore = class {
|
|
|
1525
1768
|
fs.copyFileSync(this.filePath, corruptPath);
|
|
1526
1769
|
} catch {}
|
|
1527
1770
|
}
|
|
1528
|
-
|
|
1771
|
+
writeDisk(state) {
|
|
1529
1772
|
const tempPath = `${this.filePath}.tmp`;
|
|
1530
1773
|
fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf8");
|
|
1531
1774
|
fs.renameSync(tempPath, this.filePath);
|
|
@@ -1534,15 +1777,31 @@ var TaskInsightStore = class {
|
|
|
1534
1777
|
|
|
1535
1778
|
//#endregion
|
|
1536
1779
|
//#region src/memory/memory-service.ts
|
|
1780
|
+
const SEARCH_CACHE_TTL_MS = 2e3;
|
|
1537
1781
|
var MemoryService = class {
|
|
1538
1782
|
store;
|
|
1539
1783
|
index;
|
|
1784
|
+
/** Simple TTL cache for search results keyed by intent+domain+limit. */
|
|
1785
|
+
searchCache = /* @__PURE__ */ new Map();
|
|
1540
1786
|
constructor(baseDir) {
|
|
1541
1787
|
this.store = new TaskInsightStore(baseDir);
|
|
1542
1788
|
this.index = new MemoryIndex();
|
|
1543
1789
|
}
|
|
1544
1790
|
search(input) {
|
|
1545
|
-
|
|
1791
|
+
const cacheKey = `${input.taskIntent}\0${input.siteDomain ?? ""}\0${input.limit ?? 10}`;
|
|
1792
|
+
const now = Date.now();
|
|
1793
|
+
const cached = this.searchCache.get(cacheKey);
|
|
1794
|
+
if (cached && now - cached.ts < SEARCH_CACHE_TTL_MS) return cached.results;
|
|
1795
|
+
const results = this.index.search(this.store.list(), input);
|
|
1796
|
+
this.searchCache.set(cacheKey, {
|
|
1797
|
+
results,
|
|
1798
|
+
ts: now
|
|
1799
|
+
});
|
|
1800
|
+
return results;
|
|
1801
|
+
}
|
|
1802
|
+
/** Invalidate search cache when data changes. */
|
|
1803
|
+
invalidateSearchCache() {
|
|
1804
|
+
this.searchCache.clear();
|
|
1546
1805
|
}
|
|
1547
1806
|
inspect(insightId) {
|
|
1548
1807
|
const insight = this.store.get(insightId);
|
|
@@ -1573,6 +1832,7 @@ var MemoryService = class {
|
|
|
1573
1832
|
updatedAt: now
|
|
1574
1833
|
};
|
|
1575
1834
|
this.store.upsert(verified);
|
|
1835
|
+
this.invalidateSearchCache();
|
|
1576
1836
|
return verified;
|
|
1577
1837
|
}
|
|
1578
1838
|
recordSuccess(input) {
|
|
@@ -1600,6 +1860,7 @@ var MemoryService = class {
|
|
|
1600
1860
|
evidence: [evidence]
|
|
1601
1861
|
};
|
|
1602
1862
|
this.store.upsert(created);
|
|
1863
|
+
this.invalidateSearchCache();
|
|
1603
1864
|
return created;
|
|
1604
1865
|
}
|
|
1605
1866
|
const refreshed = applySuccess({
|
|
@@ -1620,9 +1881,11 @@ var MemoryService = class {
|
|
|
1620
1881
|
updatedAt: now
|
|
1621
1882
|
};
|
|
1622
1883
|
this.store.upsert(versioned);
|
|
1884
|
+
this.invalidateSearchCache();
|
|
1623
1885
|
return versioned;
|
|
1624
1886
|
}
|
|
1625
1887
|
this.store.upsert(refreshed);
|
|
1888
|
+
this.invalidateSearchCache();
|
|
1626
1889
|
return refreshed;
|
|
1627
1890
|
}
|
|
1628
1891
|
recordFailure(input, errorMessage) {
|
|
@@ -1637,6 +1900,7 @@ var MemoryService = class {
|
|
|
1637
1900
|
evidence: [...matched.evidence.slice(-49), evidence]
|
|
1638
1901
|
}, signal);
|
|
1639
1902
|
this.store.upsert(failed);
|
|
1903
|
+
this.invalidateSearchCache();
|
|
1640
1904
|
return failed;
|
|
1641
1905
|
}
|
|
1642
1906
|
findBestExactMatch(insights, taskIntent, siteDomain) {
|