pi-browser-use 0.9.6 → 0.10.1
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 +3 -0
- package/dist/artifacts.d.ts +1 -1
- package/dist/artifacts.js +2 -2
- package/dist/chrome-launcher.d.ts +3 -0
- package/dist/chrome-launcher.js +38 -5
- package/dist/client.js +11 -2
- package/dist/config.d.ts +2 -2
- package/dist/config.js +12 -8
- package/dist/existing-flow.js +4 -2
- package/dist/index.d.ts +8 -35
- package/dist/index.js +17 -904
- package/dist/mcp-server.d.ts +47 -0
- package/dist/mcp-server.js +218 -0
- package/dist/persistent-backend.d.ts +1 -1
- package/dist/persistent-backend.js +10 -5
- package/dist/profile-lock.js +1 -1
- package/dist/runtime.d.ts +45 -0
- package/dist/runtime.js +1019 -0
- package/dist/setup-flow.d.ts +7 -1
- package/dist/setup-flow.js +17 -1
- package/dist/tab-bridge.js +5 -1
- package/docs/agent-plugins.md +131 -0
- package/mcp.json +14 -0
- package/package.json +9 -4
- package/plugin.json +18 -0
- package/skills/auth-bootstrap/SKILL.md +39 -36
- package/skills/browser-policy/SKILL.md +17 -13
- package/skills/gmail-auth/SKILL.md +6 -3
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { Type } from 'typebox';
|
|
4
|
+
import { DevToolsClient } from './client.js';
|
|
5
|
+
import { DEFAULT_PROFILE_DIR, resolveConfig, resolveModeTarget, } from './config.js';
|
|
6
|
+
import { augmentToolDescription, classifyPageState, extractTextContent, looksLikeLoginWall, looksOverlayBlocked, OVERLAY_RECOVERABLE, postProcessToolResult, } from './tool-augment.js';
|
|
7
|
+
import { defaultArtifactDir, pickImageData, resolveArtifactTarget, } from './artifacts.js';
|
|
8
|
+
import { CLEANUP_ANNOTATIONS, formatAnnotatedMap, INJECT_ANNOTATIONS, parseAnnotatedElements, } from './annotate.js';
|
|
9
|
+
import { diagnose, formatDoctorReport } from './doctor.js';
|
|
10
|
+
import { checkExistingCloseAllowed, correlateNewPage, normalizeTabUrl, openExistingPage, parseMcpPageList, } from './existing-flow.js';
|
|
11
|
+
import { checkSharedCloseAllowed, claimPage, livePeerSessions, newSessionId, releasePages, } from './shared-backend.js';
|
|
12
|
+
import { applyNewPageDefaults, applySelectPageDefaults } from './focus-policy.js';
|
|
13
|
+
import { frontProcessByPid } from './chrome-launcher.js';
|
|
14
|
+
import { PersistentBackend, shouldSelfLaunch, } from './persistent-backend.js';
|
|
15
|
+
import { loadPersistentMetadata, loadSitePreferences, markAutomationResult, saveSitePreferences, } from './persistent-store.js';
|
|
16
|
+
import { prepareBrowserProfile } from './profile.js';
|
|
17
|
+
import { withProfileLock } from './profile-lock.js';
|
|
18
|
+
import { runBootstrap, runReauth } from './setup-flow.js';
|
|
19
|
+
import { normalizeOrigin, rememberExecutionPreference, resolveExecutionForOrigin, } from './session-manager.js';
|
|
20
|
+
import { DEFAULT_BRIDGE_PORT, TabBridge } from './tab-bridge.js';
|
|
21
|
+
import { handleAnalyzeScreenshot } from './vision.js';
|
|
22
|
+
// All upstream tools are re-exported with this prefix to avoid name collisions.
|
|
23
|
+
const TOOL_PREFIX = 'browser_';
|
|
24
|
+
async function callUpstream(client, name, params, signal) {
|
|
25
|
+
return (await client.callTool(name, params, signal));
|
|
26
|
+
}
|
|
27
|
+
// Noisy, slow, or privileged upstream tools; skipped during registration.
|
|
28
|
+
const EXCLUDED_TOOLS = new Set([
|
|
29
|
+
'lighthouse_audit',
|
|
30
|
+
'performance_analyze_insight',
|
|
31
|
+
'performance_start_trace',
|
|
32
|
+
'performance_stop_trace',
|
|
33
|
+
'screencast_start',
|
|
34
|
+
'screencast_stop',
|
|
35
|
+
'install_extension',
|
|
36
|
+
'list_extensions',
|
|
37
|
+
'reload_extension',
|
|
38
|
+
'trigger_extension_action',
|
|
39
|
+
'uninstall_extension',
|
|
40
|
+
]);
|
|
41
|
+
function ignoreResult() { }
|
|
42
|
+
function sameOrigin(a, b) {
|
|
43
|
+
try {
|
|
44
|
+
return !!a && !!b && new URL(a).origin === new URL(b).origin;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function pageUrlFromSnapshot(text) {
|
|
51
|
+
return text.match(/\burl="([^"]+)"/)?.[1];
|
|
52
|
+
}
|
|
53
|
+
function toToolContent(result, originalName) {
|
|
54
|
+
const textContent = extractTextContent(result.content);
|
|
55
|
+
const processed = postProcessToolResult(originalName, textContent);
|
|
56
|
+
const content = [];
|
|
57
|
+
if (processed !== textContent) {
|
|
58
|
+
content.push({ type: 'text', text: processed });
|
|
59
|
+
}
|
|
60
|
+
else if (result.content) {
|
|
61
|
+
for (const item of result.content) {
|
|
62
|
+
if (item.type === 'text' && item.text)
|
|
63
|
+
content.push({ type: 'text', text: item.text });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (result.content) {
|
|
67
|
+
for (const item of result.content) {
|
|
68
|
+
if (item.type === 'image' && item.data) {
|
|
69
|
+
content.push({ type: 'image', data: item.data, mimeType: item.mimeType ?? 'image/png' });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (content.length === 0)
|
|
74
|
+
content.push({ type: 'text', text: '' });
|
|
75
|
+
return result.isError ? { content, isError: true } : { content };
|
|
76
|
+
}
|
|
77
|
+
/** Shared browser lifecycle, policy, tools, and ownership. No Pi host is required. */
|
|
78
|
+
export function createBrowserRuntime(options = {}) {
|
|
79
|
+
const defaultProfileDir = options.defaultProfileDir ?? DEFAULT_PROFILE_DIR;
|
|
80
|
+
const artifactDir = options.artifactDir ?? defaultArtifactDir();
|
|
81
|
+
let config = resolveConfig(options.config, defaultProfileDir);
|
|
82
|
+
// Remember identity even when a fresh/existing backend deliberately drops userDataDir.
|
|
83
|
+
const identityProfileDir = config.userDataDir ?? defaultProfileDir;
|
|
84
|
+
if (config.sessionMode === 'isolated')
|
|
85
|
+
config.userDataDir = undefined;
|
|
86
|
+
let client;
|
|
87
|
+
let backendInitialized = false;
|
|
88
|
+
let stopped = false;
|
|
89
|
+
const lifetime = new AbortController();
|
|
90
|
+
const tools = new Map();
|
|
91
|
+
let startPromise;
|
|
92
|
+
let stopPromise;
|
|
93
|
+
let operations = Promise.resolve();
|
|
94
|
+
function createClient(cfg) {
|
|
95
|
+
return options.createClient?.(cfg) ?? new DevToolsClient(cfg);
|
|
96
|
+
}
|
|
97
|
+
function createBackend(cfg) {
|
|
98
|
+
return options.createBackend?.(cfg) ?? new PersistentBackend(cfg);
|
|
99
|
+
}
|
|
100
|
+
function registerTool(tool) {
|
|
101
|
+
tools.set(tool.name, {
|
|
102
|
+
...tool,
|
|
103
|
+
execute(params, signal, context) {
|
|
104
|
+
const combined = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
105
|
+
const result = operations.then(() => {
|
|
106
|
+
combined.throwIfAborted();
|
|
107
|
+
return tool.execute(params, combined, context);
|
|
108
|
+
});
|
|
109
|
+
// Serializing calls prevents mode switches/reauth racing in-flight page actions.
|
|
110
|
+
operations = result.then(ignoreResult, ignoreResult);
|
|
111
|
+
return result;
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// Plugin-owned persistent Chrome (self-launched, MCP attached via browserUrl).
|
|
116
|
+
// Set only for persistent mode when the legacy MCP-launch path is off.
|
|
117
|
+
let ownBackend;
|
|
118
|
+
// Existing-mode tab broker bridge. Lazy; lives for the whole session.
|
|
119
|
+
let bridge;
|
|
120
|
+
// Last navigated origin: drives the per-origin headed-background fallback.
|
|
121
|
+
let lastOrigin;
|
|
122
|
+
// URLs this session opened or navigated to in Existing mode (raw + normalized): the
|
|
123
|
+
// only close_page targets allowed there without explicit force:true.
|
|
124
|
+
const ownedUrls = new Set();
|
|
125
|
+
// This extension load's agent-session identity for the shared registry.
|
|
126
|
+
const sessionId = newSessionId();
|
|
127
|
+
const myOwner = { sessionId, pid: process.pid };
|
|
128
|
+
const shortSession = sessionId.slice(0, 8);
|
|
129
|
+
/** Registry home: the persistent profile, or the default home for existing. */
|
|
130
|
+
function registryDir() {
|
|
131
|
+
return currentMode === 'persistent' ? persistentProfileDir(config ?? {}) : defaultProfileDir;
|
|
132
|
+
}
|
|
133
|
+
function trackUrl(url) {
|
|
134
|
+
ownedUrls.add(url);
|
|
135
|
+
ownedUrls.add(normalizeTabUrl(url));
|
|
136
|
+
}
|
|
137
|
+
// Tracks which identity the live backend holds, so results can suggest
|
|
138
|
+
// escalation. 'custom' covers user-configured attach setups we did not pick.
|
|
139
|
+
let currentMode = describeMode();
|
|
140
|
+
function describeMode() {
|
|
141
|
+
if (config.sessionMode === 'existing')
|
|
142
|
+
return 'existing';
|
|
143
|
+
if (config.browserUrl || config.wsEndpoint || config.autoConnect)
|
|
144
|
+
return 'custom';
|
|
145
|
+
if (config?.sessionMode === 'isolated')
|
|
146
|
+
return 'fresh';
|
|
147
|
+
if (config?.sessionMode === 'persistent')
|
|
148
|
+
return 'persistent';
|
|
149
|
+
if (config?.sessionMode === 'existing')
|
|
150
|
+
return 'existing';
|
|
151
|
+
return 'custom';
|
|
152
|
+
}
|
|
153
|
+
function persistentProfileDir(cfg) {
|
|
154
|
+
return cfg.userDataDir ?? identityProfileDir;
|
|
155
|
+
}
|
|
156
|
+
/** Close the MCP transport and any Plugin-owned Chrome. The bridge survives. */
|
|
157
|
+
async function teardownBackend() {
|
|
158
|
+
backendInitialized = false;
|
|
159
|
+
if (client) {
|
|
160
|
+
try {
|
|
161
|
+
await client.close();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// A half-dead transport must not block the switch.
|
|
165
|
+
}
|
|
166
|
+
client = undefined;
|
|
167
|
+
}
|
|
168
|
+
if (ownBackend) {
|
|
169
|
+
try {
|
|
170
|
+
await ownBackend.stop();
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Shutdown is best-effort; the profile lock release inside never
|
|
174
|
+
// throws fatally, so a new backend can still start.
|
|
175
|
+
}
|
|
176
|
+
ownBackend = undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function pinCurrentSite(profileDir, headed) {
|
|
180
|
+
if (!lastOrigin)
|
|
181
|
+
return false;
|
|
182
|
+
const prefs = rememberExecutionPreference(loadSitePreferences(profileDir), lastOrigin, headed ? 'headed-background' : 'headless');
|
|
183
|
+
saveSitePreferences(profileDir, prefs);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Rebuild the backend for a mode switch. Shared by the switch tool and
|
|
188
|
+
* automatic escalation so both paths behave identically. Persistent
|
|
189
|
+
* self-launches Plugin-owned Chrome (MCP attaches via browserUrl) unless
|
|
190
|
+
* PI_BROWSER_USE_LEGACY_PERSISTENT=1. A per-origin headed-background pin
|
|
191
|
+
* wins over a headless request so one headless-hostile site never
|
|
192
|
+
* downgrades every site.
|
|
193
|
+
*/
|
|
194
|
+
async function switchBackend(mode, headed, signal, opts) {
|
|
195
|
+
const next = resolveConfig(resolveModeTarget(config, mode, headed, identityProfileDir), defaultProfileDir);
|
|
196
|
+
await teardownBackend();
|
|
197
|
+
try {
|
|
198
|
+
let effectiveHeaded = headed;
|
|
199
|
+
let backendNote = '';
|
|
200
|
+
if (mode === 'persistent' && shouldSelfLaunch(next)) {
|
|
201
|
+
const profileDir = persistentProfileDir(next);
|
|
202
|
+
if (opts?.rememberSite === true)
|
|
203
|
+
pinCurrentSite(profileDir, headed);
|
|
204
|
+
if (!headed && lastOrigin) {
|
|
205
|
+
const pinned = resolveExecutionForOrigin(loadSitePreferences(profileDir), lastOrigin, 'headless');
|
|
206
|
+
if (pinned === 'headed-background') {
|
|
207
|
+
effectiveHeaded = true;
|
|
208
|
+
backendNote = ` (${normalizeOrigin(lastOrigin)} prefers the visible fallback)`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
ownBackend = createBackend({ config: next, headed: effectiveHeaded, sessionId });
|
|
212
|
+
const attach = await ownBackend.start(signal);
|
|
213
|
+
client = createClient(attach);
|
|
214
|
+
markAutomationResult(profileDir, effectiveHeaded ? 'headed' : 'headless');
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
if (mode === 'persistent' && opts?.rememberSite === true) {
|
|
218
|
+
pinCurrentSite(persistentProfileDir(next), headed);
|
|
219
|
+
}
|
|
220
|
+
prepareBrowserProfile(next);
|
|
221
|
+
client = createClient(next);
|
|
222
|
+
}
|
|
223
|
+
await client.ensureReady(signal);
|
|
224
|
+
// Record what actually launched (a per-origin pin may have upgraded
|
|
225
|
+
// headless to headed-background) so status/doctor tell the truth.
|
|
226
|
+
next.headless = !effectiveHeaded;
|
|
227
|
+
config = next;
|
|
228
|
+
currentMode = mode;
|
|
229
|
+
backendInitialized = true;
|
|
230
|
+
return { next, effectiveHeaded, backendNote };
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
await teardownBackend();
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Start the Existing-mode tab broker bridge on demand. */
|
|
238
|
+
async function ensureBridge() {
|
|
239
|
+
if (bridge)
|
|
240
|
+
return bridge;
|
|
241
|
+
const port = config?.tabBridgePort ?? DEFAULT_BRIDGE_PORT;
|
|
242
|
+
if (port === 0)
|
|
243
|
+
throw new Error('The tab bridge is disabled (tabBridgePort: 0).');
|
|
244
|
+
bridge = new TabBridge({ port });
|
|
245
|
+
await bridge.start();
|
|
246
|
+
return bridge;
|
|
247
|
+
}
|
|
248
|
+
function loginWallHint(url, text) {
|
|
249
|
+
if (currentMode !== 'fresh')
|
|
250
|
+
return '';
|
|
251
|
+
if (!looksLikeLoginWall(url, text))
|
|
252
|
+
return '';
|
|
253
|
+
return '\n\nHint: this looks like a login wall in a fresh (logged-out) session. If the page needs your identity, call browser_switch_mode({"mode": "persistent"}) — a human must complete any SSO, 2FA, or passkey step, ideally headed.';
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Automatic escalation for hard blocks the agent cannot clear alone.
|
|
257
|
+
* Returns prompt text for the agent to relay, or empty when nothing
|
|
258
|
+
* applies. Escalates at most once per call; never loops, never retries
|
|
259
|
+
* a challenge page, and never switches away from an attached session.
|
|
260
|
+
*
|
|
261
|
+
* Challenges only escalate with navigation context: a stale "Just a
|
|
262
|
+
* moment..." shortcut tile on a New Tab snapshot must not rebuild the
|
|
263
|
+
* backend, and a challenge on another origin than requested means the
|
|
264
|
+
* navigation never landed there.
|
|
265
|
+
*/
|
|
266
|
+
async function escalateBlockedPage(url, text, signal, context) {
|
|
267
|
+
const state = classifyPageState(url, text);
|
|
268
|
+
if (state === 'ok')
|
|
269
|
+
return '';
|
|
270
|
+
if (state === 'challenge') {
|
|
271
|
+
if (context?.tool !== 'navigate_page')
|
|
272
|
+
return '';
|
|
273
|
+
if (context.requestedUrl && url && !sameOrigin(context.requestedUrl, url)) {
|
|
274
|
+
// Challenge-provider handoffs (dedicated challenge domains) still
|
|
275
|
+
// count; anything else means the navigation never landed there.
|
|
276
|
+
const host = (() => {
|
|
277
|
+
try {
|
|
278
|
+
return new URL(url).hostname;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
284
|
+
if (!/challenge|turnstile|captcha|cf-chl|kasada|perimeterx|datadome/i.test(host))
|
|
285
|
+
return '';
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (state === 'login-wall') {
|
|
289
|
+
if (currentMode === 'fresh')
|
|
290
|
+
return loginWallHint(url, text);
|
|
291
|
+
if (currentMode === 'custom' || currentMode === 'existing') {
|
|
292
|
+
return '\n\nThis page needs an identity this session does not have. Sign in is required — complete it in the visible browser, then retry.';
|
|
293
|
+
}
|
|
294
|
+
if (config?.headless === false) {
|
|
295
|
+
return '\n\nThis page needs a login and the browser is already visible. Sign in in that window, then retry — no relaunch, nothing closed.';
|
|
296
|
+
}
|
|
297
|
+
return await escalateToHeaded(url ?? 'this page', signal);
|
|
298
|
+
}
|
|
299
|
+
// Challenge (bot check): identity never helps; only a human-gated
|
|
300
|
+
// headed window can clear it. Stay in the same mode.
|
|
301
|
+
if (config?.headless === false) {
|
|
302
|
+
return '\n\nA bot challenge is blocking this page and the browser is already visible. Complete the challenge in the window, then retry.';
|
|
303
|
+
}
|
|
304
|
+
if (currentMode === 'custom' || currentMode === 'existing') {
|
|
305
|
+
return '\n\nA bot challenge is blocking this page. Complete it in the visible browser, then retry — do not loop against the challenge.';
|
|
306
|
+
}
|
|
307
|
+
return await escalateToHeaded(url ?? 'this page', signal);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Rebuild the current backend headed so a human can act (log in, clear
|
|
311
|
+
* a challenge), then tell the agent exactly what to relay. Attach
|
|
312
|
+
* sessions are already visible and owned by the user: prompt only. The
|
|
313
|
+
* headed window is navigated to the blocked page and fronted: auth
|
|
314
|
+
* handoff is the one case where taking foreground is the job, not a bug.
|
|
315
|
+
*/
|
|
316
|
+
async function escalateToHeaded(url, signal) {
|
|
317
|
+
const mode = currentMode === 'persistent' ? 'persistent' : 'fresh';
|
|
318
|
+
try {
|
|
319
|
+
await switchBackend(mode, true, signal);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
return `\n\nBlocked on ${url} and the headed browser failed to launch (${error instanceof Error ? error.message : String(error)}). Ask the user to proceed manually.`;
|
|
323
|
+
}
|
|
324
|
+
if (/^https?:\/\//.test(url)) {
|
|
325
|
+
// Best effort: the window is already open for manual navigation.
|
|
326
|
+
try {
|
|
327
|
+
await callUpstream(client, 'new_page', { url, background: false }, signal);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Manual navigation in the opened window covers this.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// Front Plugin-owned Chrome so the handoff window is actually visible.
|
|
334
|
+
// Fresh MCP-launched Chrome fronts itself; only Plugin-owned needs help.
|
|
335
|
+
if (ownBackend)
|
|
336
|
+
frontProcessByPid(ownBackend.pid());
|
|
337
|
+
return `\n\nBlocked on ${url}: a browser window just opened on that page (same ${mode} session — previous tabs are gone, re-list pages after). Please complete the login or challenge in that window, then tell the agent to continue. Do not close the window until done.`;
|
|
338
|
+
}
|
|
339
|
+
async function ensureConnected(signal) {
|
|
340
|
+
signal?.throwIfAborted();
|
|
341
|
+
try {
|
|
342
|
+
if (!backendInitialized) {
|
|
343
|
+
await client?.close();
|
|
344
|
+
if (currentMode === 'persistent' && shouldSelfLaunch(config)) {
|
|
345
|
+
ownBackend = createBackend({ config, headed: config.headless === false, sessionId });
|
|
346
|
+
client = createClient(await ownBackend.start(signal));
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
prepareBrowserProfile(config);
|
|
350
|
+
client = createClient(config);
|
|
351
|
+
}
|
|
352
|
+
backendInitialized = true;
|
|
353
|
+
}
|
|
354
|
+
await client.ensureReady(signal);
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
await teardownBackend();
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function registerUpstreamTools(startSignal) {
|
|
362
|
+
const upstreamTools = await client.listAllTools(startSignal);
|
|
363
|
+
for (const tool of upstreamTools) {
|
|
364
|
+
if (EXCLUDED_TOOLS.has(tool.name))
|
|
365
|
+
continue;
|
|
366
|
+
const prefixedName = `${TOOL_PREFIX}${tool.name}`;
|
|
367
|
+
const originalName = tool.name;
|
|
368
|
+
const description = augmentToolDescription(prefixedName, tool.description ?? '');
|
|
369
|
+
// close_page carries an extra force gate so Existing mode can refuse
|
|
370
|
+
// to close tabs this session did not open (spec 19); stripped before upstream.
|
|
371
|
+
const parameters = originalName === 'close_page'
|
|
372
|
+
? Type.Object({
|
|
373
|
+
pageId: Type.Number({
|
|
374
|
+
description: 'The ID of the page to close. Call browser_list_pages first; IDs shift when tabs close.',
|
|
375
|
+
}),
|
|
376
|
+
force: Type.Optional(Type.Boolean({
|
|
377
|
+
description: 'Existing mode only: This session refuses to close tabs it did not open unless force is true and the user explicitly asked for that exact tab.',
|
|
378
|
+
})),
|
|
379
|
+
})
|
|
380
|
+
: Type.Unsafe(tool.inputSchema);
|
|
381
|
+
registerTool({
|
|
382
|
+
name: prefixedName,
|
|
383
|
+
label: prefixedName,
|
|
384
|
+
description,
|
|
385
|
+
parameters,
|
|
386
|
+
async execute(params, signal) {
|
|
387
|
+
await ensureConnected(signal);
|
|
388
|
+
const browser = client;
|
|
389
|
+
// Focus policy (headed-background / existing): Agent-created pages
|
|
390
|
+
// open in the background and selections never take foreground
|
|
391
|
+
// unless the caller explicitly asked. Explicit values always win.
|
|
392
|
+
const effectiveParams = originalName === 'new_page'
|
|
393
|
+
? applyNewPageDefaults(params)
|
|
394
|
+
: originalName === 'select_page'
|
|
395
|
+
? applySelectPageDefaults(params)
|
|
396
|
+
: params;
|
|
397
|
+
if ((originalName === 'navigate_page' || originalName === 'new_page') &&
|
|
398
|
+
typeof effectiveParams.url === 'string') {
|
|
399
|
+
// Remember the origin for the per-origin headed-background
|
|
400
|
+
// fallback; normalizeOrigin never throws (falls back to raw).
|
|
401
|
+
lastOrigin = normalizeOrigin(effectiveParams.url);
|
|
402
|
+
// In Existing mode an agent-driven navigation marks the destination
|
|
403
|
+
// as agent-touched for the close guard below.
|
|
404
|
+
if (currentMode === 'existing' && originalName === 'navigate_page')
|
|
405
|
+
trackUrl(effectiveParams.url);
|
|
406
|
+
// Claim navigated pages for this session so peer agents in a
|
|
407
|
+
// shared browser can tell ours apart (best effort, never fatal).
|
|
408
|
+
if (originalName === 'navigate_page' &&
|
|
409
|
+
(currentMode === 'persistent' || currentMode === 'existing') &&
|
|
410
|
+
typeof effectiveParams.pageId === 'number') {
|
|
411
|
+
try {
|
|
412
|
+
claimPage(registryDir(), { pageId: effectiveParams.pageId, url: effectiveParams.url }, myOwner);
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
// Ownership is coordination metadata, not the task itself.
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// Never close another session's tabs: Existing mode checks Pi URL
|
|
420
|
+
// ownership (spec 19); a shared persistent backend checks the page
|
|
421
|
+
// registry instead. Both yield to explicit force:true.
|
|
422
|
+
if (originalName === 'close_page') {
|
|
423
|
+
const { force: _force, ...closeArgs } = effectiveParams;
|
|
424
|
+
void _force;
|
|
425
|
+
const wantsForce = effectiveParams.force === true;
|
|
426
|
+
const sharedPersistent = currentMode === 'persistent' && !!ownBackend;
|
|
427
|
+
if ((currentMode === 'existing' || sharedPersistent) && !wantsForce) {
|
|
428
|
+
if (typeof effectiveParams.pageId !== 'number') {
|
|
429
|
+
return {
|
|
430
|
+
content: [{ type: 'text', text: 'close_page needs a numeric pageId.' }],
|
|
431
|
+
isError: true,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const entries = parseMcpPageList(await callUpstream(browser, 'list_pages', {}, signal));
|
|
435
|
+
const verdict = currentMode === 'existing'
|
|
436
|
+
? checkExistingCloseAllowed(entries, effectiveParams.pageId, ownedUrls)
|
|
437
|
+
: checkSharedCloseAllowed(entries, effectiveParams.pageId, registryDir(), myOwner, ownedUrls);
|
|
438
|
+
if (!verdict.ok) {
|
|
439
|
+
return {
|
|
440
|
+
content: [{ type: 'text', text: verdict.reason }],
|
|
441
|
+
isError: true,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const result = await callUpstream(browser, originalName, closeArgs, signal);
|
|
446
|
+
if (!result.isError && typeof effectiveParams.pageId === 'number') {
|
|
447
|
+
try {
|
|
448
|
+
releasePages(registryDir(), { pageId: effectiveParams.pageId });
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
// Registry hygiene never fails the close itself.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return { ...toToolContent(result, originalName) };
|
|
455
|
+
}
|
|
456
|
+
// Correlate a newly opened page by ID, never by URL: other agents
|
|
457
|
+
// may have the same URL open. Ambiguous results are left unclaimed.
|
|
458
|
+
let before;
|
|
459
|
+
if (originalName === 'new_page' &&
|
|
460
|
+
(currentMode === 'persistent' || currentMode === 'existing')) {
|
|
461
|
+
try {
|
|
462
|
+
before = parseMcpPageList(await callUpstream(browser, 'list_pages', {}, signal));
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
signal?.throwIfAborted();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
let result = await callUpstream(browser, originalName, effectiveParams, signal);
|
|
469
|
+
if (result.isError &&
|
|
470
|
+
OVERLAY_RECOVERABLE.has(originalName) &&
|
|
471
|
+
looksOverlayBlocked(extractTextContent(result.content))) {
|
|
472
|
+
// One recovery attempt: dismiss the overlay, then retry the
|
|
473
|
+
// original call. Any failure here falls through to the
|
|
474
|
+
// original error, which already carries a hint.
|
|
475
|
+
try {
|
|
476
|
+
const escapeArgs = typeof effectiveParams.pageId === 'number'
|
|
477
|
+
? { pageId: effectiveParams.pageId, key: 'Escape' }
|
|
478
|
+
: { key: 'Escape' };
|
|
479
|
+
await callUpstream(browser, 'press_key', escapeArgs, signal);
|
|
480
|
+
result = await callUpstream(browser, originalName, effectiveParams, signal);
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// Fall through to the original result below.
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (!result.isError && before) {
|
|
487
|
+
const after = parseMcpPageList(result);
|
|
488
|
+
const opened = after.find((page) => page.pageId === correlateNewPage(before, after));
|
|
489
|
+
if (opened) {
|
|
490
|
+
if (opened.url)
|
|
491
|
+
trackUrl(opened.url);
|
|
492
|
+
try {
|
|
493
|
+
claimPage(registryDir(), opened, myOwner);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// Ownership is coordination metadata, not the task itself.
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const toolContent = toToolContent(result, originalName);
|
|
501
|
+
if (!toolContent.isError &&
|
|
502
|
+
(originalName === 'navigate_page' || originalName === 'take_snapshot')) {
|
|
503
|
+
// Prefer the page's real URL from the snapshot; fall back to the
|
|
504
|
+
// requested URL only when the snapshot carries none.
|
|
505
|
+
const snapshotUrl = pageUrlFromSnapshot(extractTextContent(result.content));
|
|
506
|
+
const requestedUrl = originalName === 'navigate_page' && typeof effectiveParams.url === 'string'
|
|
507
|
+
? effectiveParams.url
|
|
508
|
+
: undefined;
|
|
509
|
+
const url = snapshotUrl ?? requestedUrl;
|
|
510
|
+
const escalation = await escalateBlockedPage(url, extractTextContent(result.content), signal, { tool: originalName, requestedUrl });
|
|
511
|
+
const first = toolContent.content[0];
|
|
512
|
+
if (escalation && first && first.text !== undefined) {
|
|
513
|
+
first.text += escalation;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return { ...toolContent };
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function registerSaveArtifactTool() {
|
|
522
|
+
const properties = {
|
|
523
|
+
kind: Type.Union([Type.Literal('screenshot'), Type.Literal('html')], {
|
|
524
|
+
description: 'Capture a viewport screenshot (PNG) or the full rendered HTML.',
|
|
525
|
+
}),
|
|
526
|
+
path: Type.Optional(Type.String({
|
|
527
|
+
description: `Absolute destination path. Defaults to ${artifactDir}/page-<timestamp>.<png|html>.`,
|
|
528
|
+
})),
|
|
529
|
+
annotate: Type.Optional(Type.Boolean({
|
|
530
|
+
description: 'Screenshots only: overlay numbered badges on interactive elements and return their coordinate map for coordinate click tools. Badges are removed after capture.',
|
|
531
|
+
})),
|
|
532
|
+
};
|
|
533
|
+
if (config?.experimentalPageIdRouting === true) {
|
|
534
|
+
properties.pageId = Type.Number({
|
|
535
|
+
description: 'Numeric page ID returned by browser_list_pages.',
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
registerTool({
|
|
539
|
+
name: `${TOOL_PREFIX}save_artifact`,
|
|
540
|
+
label: `${TOOL_PREFIX}save_artifact`,
|
|
541
|
+
description: 'Save a screenshot or the rendered HTML of the current page to disk and return its path. Prefer this over pulling image bytes into context when the capture is evidence (bug reports, visual QA, artifact sharing) rather than something you need to look at right now.',
|
|
542
|
+
parameters: Type.Object(properties),
|
|
543
|
+
async execute(params, signal) {
|
|
544
|
+
await ensureConnected(signal);
|
|
545
|
+
const browser = client;
|
|
546
|
+
const kind = params.kind === 'html' ? 'html' : 'screenshot';
|
|
547
|
+
const pageId = typeof params.pageId === 'number' ? params.pageId : undefined;
|
|
548
|
+
const target = resolveArtifactTarget(kind, params.path, artifactDir);
|
|
549
|
+
if (kind === 'html') {
|
|
550
|
+
const pageArgs = pageId === undefined ? {} : { pageId };
|
|
551
|
+
const evaluated = (await browser.callTool('evaluate_script', {
|
|
552
|
+
...pageArgs,
|
|
553
|
+
function: '() => document.documentElement.outerHTML',
|
|
554
|
+
}, signal));
|
|
555
|
+
const html = extractTextContent(evaluated.content);
|
|
556
|
+
if (!html)
|
|
557
|
+
throw new Error('Page HTML came back empty.');
|
|
558
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
559
|
+
writeFileSync(target, html, 'utf8');
|
|
560
|
+
return {
|
|
561
|
+
content: [
|
|
562
|
+
{ type: 'text', text: `Saved page HTML (${html.length} chars) to ${target}` },
|
|
563
|
+
],
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const shotArgs = pageId === undefined ? {} : { pageId };
|
|
567
|
+
const evalArgs = pageId === undefined ? {} : { pageId };
|
|
568
|
+
const wantAnnotations = params.annotate === true && kind === 'screenshot';
|
|
569
|
+
let annotatedMap = '';
|
|
570
|
+
try {
|
|
571
|
+
if (wantAnnotations) {
|
|
572
|
+
const injected = (await browser.callTool('evaluate_script', {
|
|
573
|
+
...evalArgs,
|
|
574
|
+
function: INJECT_ANNOTATIONS,
|
|
575
|
+
}, signal));
|
|
576
|
+
annotatedMap = formatAnnotatedMap(parseAnnotatedElements(extractTextContent(injected.content)));
|
|
577
|
+
}
|
|
578
|
+
const shot = (await browser.callTool('take_screenshot', shotArgs, signal));
|
|
579
|
+
const image = pickImageData(shot.content);
|
|
580
|
+
if (!image)
|
|
581
|
+
throw new Error('Screenshot came back without image data.');
|
|
582
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
583
|
+
writeFileSync(target, Buffer.from(image.data, 'base64'));
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
if (wantAnnotations) {
|
|
587
|
+
try {
|
|
588
|
+
await browser.callTool('evaluate_script', {
|
|
589
|
+
...evalArgs,
|
|
590
|
+
function: CLEANUP_ANNOTATIONS,
|
|
591
|
+
}, signal);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// Badges are pointer-events:none and harmless if cleanup fails.
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const suffix = annotatedMap ? `\nAnnotated elements:\n${annotatedMap}` : '';
|
|
599
|
+
return {
|
|
600
|
+
content: [{ type: 'text', text: `Saved screenshot to ${target}${suffix}` }],
|
|
601
|
+
};
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
function registerSwitchModeTool() {
|
|
606
|
+
registerTool({
|
|
607
|
+
name: `${TOOL_PREFIX}switch_mode`,
|
|
608
|
+
label: `${TOOL_PREFIX}switch_mode`,
|
|
609
|
+
description: 'Switch the browser backend without restarting: "persistent" is the plugin\'s own browser (saved profile with your logins, default), "fresh" is an isolated clean room for anonymous checks, "existing" attaches to your running Chrome (tabs go to the collapsed pi-browser-use group via browser_open_background_tab, consent popup each session). Fresh and persistent default to headless; pass headed true to watch. Tabs do not transfer; call browser_list_pages after switching. Prefer persistent; drop to fresh for clean-room checks.',
|
|
610
|
+
parameters: Type.Object({
|
|
611
|
+
mode: Type.Union([
|
|
612
|
+
Type.Literal('fresh'),
|
|
613
|
+
Type.Literal('persistent'),
|
|
614
|
+
Type.Literal('existing'),
|
|
615
|
+
]),
|
|
616
|
+
headed: Type.Optional(Type.Boolean({
|
|
617
|
+
description: 'Show the browser window. Default is headless — everything works with no popups.',
|
|
618
|
+
})),
|
|
619
|
+
rememberSite: Type.Optional(Type.Boolean({
|
|
620
|
+
description: "Persistent only: remember the last-visited site's visibility (headless or headed-background) for next time.",
|
|
621
|
+
})),
|
|
622
|
+
}),
|
|
623
|
+
async execute(params, signal) {
|
|
624
|
+
const mode = params.mode === 'persistent'
|
|
625
|
+
? 'persistent'
|
|
626
|
+
: params.mode === 'existing'
|
|
627
|
+
? 'existing'
|
|
628
|
+
: 'fresh';
|
|
629
|
+
const { next, effectiveHeaded, backendNote } = await switchBackend(mode, params.headed === true, signal, { rememberSite: params.rememberSite === true });
|
|
630
|
+
const visibility = mode === 'existing' ? 'headed (your Chrome)' : effectiveHeaded ? 'headed' : 'headless';
|
|
631
|
+
const what = mode === 'fresh'
|
|
632
|
+
? 'a fresh isolated browser'
|
|
633
|
+
: mode === 'persistent'
|
|
634
|
+
? 'the persistent managed profile'
|
|
635
|
+
: 'your running Chrome';
|
|
636
|
+
const extra = mode === 'existing'
|
|
637
|
+
? ' Open agent tabs with browser_open_background_tab so they land in the collapsed pi-browser-use group.'
|
|
638
|
+
: '';
|
|
639
|
+
void next;
|
|
640
|
+
return {
|
|
641
|
+
content: [
|
|
642
|
+
{
|
|
643
|
+
type: 'text',
|
|
644
|
+
text: `Switched to ${what} (${visibility})${backendNote}. Previous tabs are gone; call browser_list_pages to start.${extra}`,
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
};
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
function registerSetupTool() {
|
|
652
|
+
registerTool({
|
|
653
|
+
name: `${TOOL_PREFIX}setup`,
|
|
654
|
+
label: `${TOOL_PREFIX}setup`,
|
|
655
|
+
description: 'First-run setup for the persistent Managed browser profile: opens a plain headed Chrome window (no automation attached) for a human to sign into Google and any sites. Completes when the window is closed. Run once; afterwards the agent automates headless.',
|
|
656
|
+
parameters: Type.Object({}),
|
|
657
|
+
async execute(_params, signal) {
|
|
658
|
+
const profileDir = persistentProfileDir(config ?? {});
|
|
659
|
+
const meta = loadPersistentMetadata(profileDir);
|
|
660
|
+
if (meta.initialized) {
|
|
661
|
+
return {
|
|
662
|
+
content: [
|
|
663
|
+
{
|
|
664
|
+
type: 'text',
|
|
665
|
+
text: `Managed browser profile is already initialized (${profileDir}). If a login expired, use browser_reauth instead.`,
|
|
666
|
+
},
|
|
667
|
+
],
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
// No Chrome may hold the profile while the setup window runs.
|
|
671
|
+
await teardownBackend();
|
|
672
|
+
await withProfileLock(profileDir, () => runBootstrap({
|
|
673
|
+
profileDir,
|
|
674
|
+
executablePath: config?.executablePath,
|
|
675
|
+
chromeArgs: config?.chromeArgs,
|
|
676
|
+
signal,
|
|
677
|
+
}));
|
|
678
|
+
return {
|
|
679
|
+
content: [
|
|
680
|
+
{
|
|
681
|
+
type: 'text',
|
|
682
|
+
text: 'Managed browser profile initialized. The agent now works in the background — no Chrome window will appear during normal automation.',
|
|
683
|
+
},
|
|
684
|
+
],
|
|
685
|
+
};
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
function registerStatusTool() {
|
|
690
|
+
registerTool({
|
|
691
|
+
name: `${TOOL_PREFIX}status`,
|
|
692
|
+
label: `${TOOL_PREFIX}status`,
|
|
693
|
+
description: 'Plain-language Managed browser status: profile readiness, execution mode, and what to do next. No page is touched.',
|
|
694
|
+
parameters: Type.Object({}),
|
|
695
|
+
async execute() {
|
|
696
|
+
const mode = currentMode;
|
|
697
|
+
const profileDir = persistentProfileDir(config ?? {});
|
|
698
|
+
const meta = loadPersistentMetadata(profileDir);
|
|
699
|
+
const sitePins = loadSitePreferences(profileDir).length;
|
|
700
|
+
const lines = [
|
|
701
|
+
'Browser Use',
|
|
702
|
+
'──────────',
|
|
703
|
+
`Session: ${shortSession}`,
|
|
704
|
+
`Persistent profile: ${identityProfileDir}`,
|
|
705
|
+
`Artifacts: ${artifactDir}`,
|
|
706
|
+
];
|
|
707
|
+
if (mode === 'fresh') {
|
|
708
|
+
lines.push('Profile: Ephemeral (nothing persists)');
|
|
709
|
+
lines.push(`Execution: ${config.headless === false ? 'Headed' : 'Headless'}`);
|
|
710
|
+
}
|
|
711
|
+
else if (mode === 'persistent') {
|
|
712
|
+
lines.push(`Profile: ${meta.initialized ? 'Ready' : 'Setup required'}`);
|
|
713
|
+
if (!meta.initialized) {
|
|
714
|
+
lines.push('Next step: run browser_setup and sign in, then close the window.');
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
const headed = config?.headless === false;
|
|
718
|
+
lines.push(`Execution: ${headed ? 'Visible fallback (background)' : 'Headless'}${ownBackend?.running() ? '' : ' (backend stopped)'}`);
|
|
719
|
+
if (meta.lastSuccessfulMode)
|
|
720
|
+
lines.push(`Last working mode: ${meta.lastSuccessfulMode}`);
|
|
721
|
+
if (sitePins > 0)
|
|
722
|
+
lines.push(`Sites pinned to visible fallback: ${sitePins}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
else if (mode === 'existing') {
|
|
726
|
+
lines.push('Profile: Your browser');
|
|
727
|
+
lines.push('Execution: Background tabs in the collapsed pi-browser-use group');
|
|
728
|
+
lines.push(`Tab bridge: ${bridge ? bridge.baseUrl() : 'not running'}`);
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
lines.push('Profile: Externally attached browser');
|
|
732
|
+
lines.push('Execution: Visible (owned by its launcher)');
|
|
733
|
+
}
|
|
734
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
function registerReauthTool() {
|
|
739
|
+
registerTool({
|
|
740
|
+
name: `${TOOL_PREFIX}reauth`,
|
|
741
|
+
label: `${TOOL_PREFIX}reauth`,
|
|
742
|
+
description: 'Reauthenticate the persistent managed profile after a login/challenge wall: shuts the headless browser down cleanly, opens a headed window for the human to verify, then resumes headless. The plain variant (no automation attached) is for providers that reject instrumented browsers.',
|
|
743
|
+
parameters: Type.Object({
|
|
744
|
+
url: Type.Optional(Type.String({ description: 'Page that needs authentication. Defaults to last origin.' })),
|
|
745
|
+
variant: Type.Optional(Type.Union([Type.Literal('instrumented'), Type.Literal('plain')], {
|
|
746
|
+
description: 'Headed variant: instrumented (Pi navigates first) or plain (maximum compatibility).',
|
|
747
|
+
})),
|
|
748
|
+
}),
|
|
749
|
+
async execute(params, signal) {
|
|
750
|
+
if (currentMode !== 'persistent') {
|
|
751
|
+
return {
|
|
752
|
+
content: [
|
|
753
|
+
{
|
|
754
|
+
type: 'text',
|
|
755
|
+
text: 'Reauth applies to the persistent managed profile. Switch to it first with browser_switch_mode({"mode": "persistent"}).',
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
const url = typeof params.url === 'string' && params.url.length > 0
|
|
761
|
+
? params.url
|
|
762
|
+
: (lastOrigin ?? 'this page');
|
|
763
|
+
const variant = params.variant === 'plain' ? 'plain' : 'instrumented';
|
|
764
|
+
if (ownBackend && !ownBackend.owned) {
|
|
765
|
+
return {
|
|
766
|
+
content: [
|
|
767
|
+
{
|
|
768
|
+
type: 'text',
|
|
769
|
+
text: 'Another live agent session owns the persistent browser (shared mode). Reauth must happen there — ask that session to run browser_reauth, or wait for it to exit and retry.',
|
|
770
|
+
},
|
|
771
|
+
],
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
if (!ownBackend && !shouldSelfLaunch(config)) {
|
|
775
|
+
// Legacy MCP-launched persistent: headed switch is the reauth path.
|
|
776
|
+
await switchBackend('persistent', true, signal);
|
|
777
|
+
return {
|
|
778
|
+
content: [
|
|
779
|
+
{
|
|
780
|
+
type: 'text',
|
|
781
|
+
text: `A browser window just opened (legacy persistent backend). ${url}: please complete the login there, then tell the agent to continue.`,
|
|
782
|
+
},
|
|
783
|
+
],
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
// Spec §7: close headless Chrome cleanly before any headed reauth.
|
|
787
|
+
await teardownBackend();
|
|
788
|
+
const backend = createBackend({
|
|
789
|
+
config: config ?? {},
|
|
790
|
+
headed: variant === 'instrumented',
|
|
791
|
+
sessionId,
|
|
792
|
+
});
|
|
793
|
+
ownBackend = backend;
|
|
794
|
+
const reauth = () => runReauth({
|
|
795
|
+
backend,
|
|
796
|
+
url,
|
|
797
|
+
variant,
|
|
798
|
+
signal,
|
|
799
|
+
executablePath: config.executablePath,
|
|
800
|
+
chromeArgs: config.chromeArgs,
|
|
801
|
+
restartBackend: (headed) => backend.restart(headed, signal),
|
|
802
|
+
});
|
|
803
|
+
const message = variant === 'plain' ? await withProfileLock(backend.profileDir(), reauth) : await reauth();
|
|
804
|
+
if (variant === 'plain') {
|
|
805
|
+
// Plain window closed by the human: resume headless automation.
|
|
806
|
+
const attach = await backend.restart(false, signal);
|
|
807
|
+
client = createClient(attach);
|
|
808
|
+
await client.ensureReady(signal);
|
|
809
|
+
backendInitialized = true;
|
|
810
|
+
config.headless = true;
|
|
811
|
+
return {
|
|
812
|
+
content: [
|
|
813
|
+
{
|
|
814
|
+
type: 'text',
|
|
815
|
+
text: `${message}\n\nVerification recorded — Automation resumed headless.`,
|
|
816
|
+
},
|
|
817
|
+
],
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
client = createClient(backend.attachConfig());
|
|
821
|
+
await client.ensureReady(signal);
|
|
822
|
+
backendInitialized = true;
|
|
823
|
+
config.headless = false;
|
|
824
|
+
return {
|
|
825
|
+
content: [
|
|
826
|
+
{
|
|
827
|
+
type: 'text',
|
|
828
|
+
text: `${message}\n\nAfter verifying, tell the agent to continue; it resumes with browser_switch_mode({"mode": "persistent"}) back to headless.`,
|
|
829
|
+
},
|
|
830
|
+
],
|
|
831
|
+
};
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
function registerOpenBackgroundTabTool() {
|
|
836
|
+
registerTool({
|
|
837
|
+
name: `${TOOL_PREFIX}open_background_tab`,
|
|
838
|
+
label: `${TOOL_PREFIX}open_background_tab`,
|
|
839
|
+
description: 'Existing mode only: open a URL as an inactive tab in the collapsed pi-browser-use group via the bundled Chrome extension — never a foreground tab. Fails clearly when the extension bridge is unavailable.',
|
|
840
|
+
parameters: Type.Object({
|
|
841
|
+
url: Type.String({ description: 'URL to open in a background agent tab.' }),
|
|
842
|
+
timeoutMs: Type.Optional(Type.Number({
|
|
843
|
+
description: 'How long to wait for the extension (default 90000: a suspended worker wakes on the ~1min alarm cadence).',
|
|
844
|
+
})),
|
|
845
|
+
}),
|
|
846
|
+
async execute(params, signal) {
|
|
847
|
+
if (currentMode !== 'existing') {
|
|
848
|
+
return {
|
|
849
|
+
content: [
|
|
850
|
+
{
|
|
851
|
+
type: 'text',
|
|
852
|
+
text: 'Background agent tabs need Existing mode (your Chrome). Switch first with browser_switch_mode({"mode": "existing"}).',
|
|
853
|
+
isError: true,
|
|
854
|
+
},
|
|
855
|
+
],
|
|
856
|
+
isError: true,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
if (typeof params.url !== 'string' || params.url.length === 0) {
|
|
860
|
+
throw new Error('A URL is required.');
|
|
861
|
+
}
|
|
862
|
+
const activeBridge = await ensureBridge();
|
|
863
|
+
const timeoutMs = typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? params.timeoutMs : 90_000;
|
|
864
|
+
const result = await openExistingPage(params.url, {
|
|
865
|
+
bridge: activeBridge,
|
|
866
|
+
listPages: async () => {
|
|
867
|
+
await ensureConnected(signal);
|
|
868
|
+
const pages = await client.callTool('list_pages', {}, signal);
|
|
869
|
+
return parseMcpPageList(pages);
|
|
870
|
+
},
|
|
871
|
+
}, { timeoutMs, signal });
|
|
872
|
+
trackUrl(params.url);
|
|
873
|
+
if (result.pageId !== undefined) {
|
|
874
|
+
try {
|
|
875
|
+
claimPage(registryDir(), { pageId: result.pageId, url: params.url }, myOwner);
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
// Ownership is coordination metadata, not the task itself.
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const selectHint = result.pageId !== undefined
|
|
882
|
+
? ` Select it with browser_select_page (it stays in the background).`
|
|
883
|
+
: ' Call browser_list_pages to find it (it stays in the background).';
|
|
884
|
+
return {
|
|
885
|
+
content: [
|
|
886
|
+
{
|
|
887
|
+
type: 'text',
|
|
888
|
+
text: `Opened ${params.url} as an inactive tab in the collapsed pi-browser-use group.${selectHint}`,
|
|
889
|
+
},
|
|
890
|
+
],
|
|
891
|
+
};
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
function registerDoctorTool() {
|
|
896
|
+
registerTool({
|
|
897
|
+
name: `${TOOL_PREFIX}doctor`,
|
|
898
|
+
label: `${TOOL_PREFIX}doctor`,
|
|
899
|
+
description: 'Diagnose the browser setup: effective mode, whether this session launches its own Chrome, profile health, and upstream tool availability. Run this first when browser tools misbehave. Touches no pages.',
|
|
900
|
+
parameters: Type.Object({}),
|
|
901
|
+
async execute(_params, signal) {
|
|
902
|
+
await ensureConnected(signal);
|
|
903
|
+
let peers = 0;
|
|
904
|
+
try {
|
|
905
|
+
peers = livePeerSessions(registryDir()).length;
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
peers = 0;
|
|
909
|
+
}
|
|
910
|
+
const report = await diagnose(config ?? {}, async () => (await client.listAllTools(signal)).map((tool) => tool.name), {
|
|
911
|
+
backend: ownBackend ? (ownBackend.owned ? 'pi-owned' : 'shared') : undefined,
|
|
912
|
+
bridgeUrl: bridge?.baseUrl() ?? null,
|
|
913
|
+
peers,
|
|
914
|
+
});
|
|
915
|
+
return { content: [{ type: 'text', text: formatDoctorReport(report) }] };
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
function registerVisionTool() {
|
|
920
|
+
const properties = {
|
|
921
|
+
instruction: Type.Optional(Type.String({
|
|
922
|
+
description: 'What to identify or analyze visually (e.g. "Find the coordinates of the blue submit button").',
|
|
923
|
+
})),
|
|
924
|
+
};
|
|
925
|
+
if (config?.experimentalPageIdRouting === true) {
|
|
926
|
+
properties.pageId = Type.Number({
|
|
927
|
+
description: 'Numeric page ID returned by browser_list_pages.',
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
registerTool({
|
|
931
|
+
name: `${TOOL_PREFIX}analyze_screenshot`,
|
|
932
|
+
label: `${TOOL_PREFIX}analyze_screenshot`,
|
|
933
|
+
description: 'Analyze the current page visually using a screenshot. Use when you need to identify elements by visual attributes (color, layout, position) not available in the accessibility tree, or when you need precise pixel coordinates for coordinate click tools.',
|
|
934
|
+
parameters: Type.Object(properties),
|
|
935
|
+
async execute(params, signal, ctx) {
|
|
936
|
+
await ensureConnected(signal);
|
|
937
|
+
if (!ctx?.callVision)
|
|
938
|
+
throw new Error('Visual analysis is not provided by this host.');
|
|
939
|
+
const callVision = ctx.callVision;
|
|
940
|
+
const pageId = typeof params.pageId === 'number' ? params.pageId : undefined;
|
|
941
|
+
const result = await handleAnalyzeScreenshot({
|
|
942
|
+
callTool: (name, args, sig) => client.callTool(name, args, sig),
|
|
943
|
+
}, callVision, { instruction: typeof params.instruction === 'string' ? params.instruction : '', pageId }, signal);
|
|
944
|
+
return { ...result };
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
async function initialize() {
|
|
949
|
+
if (options.lazyBrowser) {
|
|
950
|
+
// Tool discovery is browser-free even when the chosen identity is persistent.
|
|
951
|
+
// The upstream server launches/attaches Chrome only on its first browser call.
|
|
952
|
+
client = createClient({
|
|
953
|
+
...config,
|
|
954
|
+
sessionMode: 'isolated',
|
|
955
|
+
isolated: true,
|
|
956
|
+
userDataDir: undefined,
|
|
957
|
+
browserUrl: undefined,
|
|
958
|
+
wsEndpoint: undefined,
|
|
959
|
+
wsHeaders: undefined,
|
|
960
|
+
autoConnect: false,
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
await ensureConnected(lifetime.signal);
|
|
965
|
+
}
|
|
966
|
+
await registerUpstreamTools(lifetime.signal);
|
|
967
|
+
registerSaveArtifactTool();
|
|
968
|
+
registerDoctorTool();
|
|
969
|
+
registerSwitchModeTool();
|
|
970
|
+
registerSetupTool();
|
|
971
|
+
registerStatusTool();
|
|
972
|
+
registerReauthTool();
|
|
973
|
+
registerOpenBackgroundTabTool();
|
|
974
|
+
if (options.visionEnabled)
|
|
975
|
+
registerVisionTool();
|
|
976
|
+
return [...tools.values()];
|
|
977
|
+
}
|
|
978
|
+
async function start() {
|
|
979
|
+
if (stopped)
|
|
980
|
+
throw new Error('Browser runtime is stopped.');
|
|
981
|
+
startPromise ??= initialize().catch(async (error) => {
|
|
982
|
+
await teardownBackend();
|
|
983
|
+
tools.clear();
|
|
984
|
+
startPromise = undefined;
|
|
985
|
+
throw error;
|
|
986
|
+
});
|
|
987
|
+
return startPromise;
|
|
988
|
+
}
|
|
989
|
+
async function shutdown() {
|
|
990
|
+
stopped = true;
|
|
991
|
+
lifetime.abort(new Error('Browser runtime is stopped.'));
|
|
992
|
+
await Promise.allSettled([startPromise, operations]);
|
|
993
|
+
await teardownBackend();
|
|
994
|
+
// Release only this runtime's claims; never terminate a borrowed browser.
|
|
995
|
+
for (const scope of new Set([identityProfileDir, defaultProfileDir])) {
|
|
996
|
+
try {
|
|
997
|
+
releasePages(scope, { sessionId });
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
// Best effort.
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (bridge) {
|
|
1004
|
+
try {
|
|
1005
|
+
await bridge.stop();
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
// Session teardown is best-effort.
|
|
1009
|
+
}
|
|
1010
|
+
bridge = undefined;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
function stop() {
|
|
1014
|
+
stopPromise ??= shutdown();
|
|
1015
|
+
return stopPromise;
|
|
1016
|
+
}
|
|
1017
|
+
return { start, stop };
|
|
1018
|
+
}
|
|
1019
|
+
//# sourceMappingURL=runtime.js.map
|