@ricardochen1996/browser-use-mcp 0.0.0-stage → 0.1.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/lib/server.js ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The MCP server: one `McpServer` per connection, one browser session behind it.
3
+ *
4
+ * This is the assembly point. `createSession` gives a connection its browser (its own profile, its own
5
+ * DevTools connection, its own tabs), `createServer` registers the tools on it, and `serveStdio`
6
+ * puts the pair on the protocol. The HTTP transport in `./http.js` creates one such pair per MCP
7
+ * session, so two agents using one HTTP endpoint still never share a page by accident.
8
+ *
9
+ * @module browser-use-mcp/server
10
+ */
11
+
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
14
+ import { join } from 'node:path'
15
+
16
+ import { packageInfo } from './cli.js'
17
+ import { ensureEngine, reportText } from './engine.js'
18
+ import { GUIDANCE, ONE_CONNECTION, registerTools, toolNames } from './tools.js'
19
+ import { BrowserSession } from './session.js'
20
+
21
+ /** The identity this server reports to MCP clients. */
22
+ export const SERVER_NAME = 'browser-use-mcp'
23
+ /** This package's own version, read from its manifest once. */
24
+ export const VERSION = packageInfo().version
25
+
26
+ /**
27
+ * The instructions an MCP client adds to its model's context before the tools are used.
28
+ *
29
+ * It is the same rules the tool descriptions carry, said once for the whole surface: without it a
30
+ * model reads "browser_open returns a table" as documentation, not as the action grammar it is.
31
+ * @param {object} config - the resolved configuration.
32
+ */
33
+ export function instructions(config) {
34
+ const jev = config.jev.enabled
35
+ ? 'The Jev policy is on: when a step is easier to describe than to pick, call browser_act with an intent and no operation, and the policy chooses the operation and target on that observation. A TYPE_TEXT with an intent and no text gets its value from the text model. Either spends model quota, and a policy choice is a claim like any other: check its effect in the observation it returns.'
36
+ : 'The policy is off (--no-jev): browser_goal is not offered and browser_act has no intent parameter, so name the operation, target and text yourself.'
37
+ return `${GUIDANCE}
38
+
39
+ ${ONE_CONNECTION}
40
+
41
+ ${jev}
42
+
43
+ One MCP session owns one browser. If two agents need their own pages at the same time, run one server per agent (stdio does this naturally) or use the HTTP transport, which gives every MCP session its own browser. The browser stays open across calls; browser_close closes the tab, and the server closes the browser when the connection ends.`
44
+ }
45
+
46
+ /** A client-chosen profile name from `?profile=` or `X-Browser-Use-Profile`. */
47
+ export const PROFILE_PARAMETER = /^[A-Za-z0-9_-]{1,32}$/u
48
+
49
+ /** Whether a client-supplied profile name is one directory-safe segment. */
50
+ export function validProfileName(name) {
51
+ return typeof name === 'string' && PROFILE_PARAMETER.test(name)
52
+ }
53
+
54
+ /** The label one session's browser uses: stable, filesystem-safe, and unique per MCP session. */
55
+ export function sessionLabel(config, suffix) {
56
+ const base = config.profile.replace(/[^A-Za-z0-9_-]/gu, '') || 'profile'
57
+ if (!suffix) return base
58
+ const tail = String(suffix).replace(/[^A-Za-z0-9]/gu, '').slice(0, 12) || 'session'
59
+ return `${base}-${tail}`
60
+ }
61
+
62
+ /**
63
+ * One browser session for one MCP connection.
64
+ *
65
+ * A suffix makes the session's profile unique, which is what lets an HTTP endpoint serve several
66
+ * agents at once without two of them launching on one Chrome profile. A client-chosen profile name
67
+ * (`profileName`) is the opposite: the directory is stable across reconnects, so an agent that names
68
+ * itself keeps its logins — and two agents that name themselves the same way are refused by the
69
+ * profile lock rather than silently sharing a browser.
70
+ * @param {object} config - the resolved configuration.
71
+ * @param {object} [options]
72
+ * @param {string} [options.suffix] - a unique per-session suffix for the profile directory.
73
+ * @param {string} [options.profileName] - a client-chosen, stable profile name (HTTP only).
74
+ * @param {object} [options.logger] - the stderr logger.
75
+ */
76
+ export function createSession(config, { suffix, profileName, logger } = {}) {
77
+ const named = profileName !== undefined && profileName !== null && profileName !== ''
78
+ const label = named ? `${sessionLabel(config)}-${profileName}` : sessionLabel(config, suffix)
79
+ const directory = join(config.browserUseMcpHome, 'profiles', label)
80
+ const session = new BrowserSession({
81
+ config,
82
+ label,
83
+ logger,
84
+ connection: named || suffix !== undefined ? { userDataDir: directory } : {},
85
+ })
86
+ // The HTTP transport decides what to do with the profile directory when the session ends: a
87
+ // client-chosen one is kept (that is the point of naming it), an anonymous one is removed.
88
+ session.profile = { label, directory, persistent: named }
89
+ return session
90
+ }
91
+
92
+ /**
93
+ * One MCP server with the browser tools registered.
94
+ * @param {object} options
95
+ * @param {object} options.config - the resolved configuration.
96
+ * @param {import('./session.js').BrowserSession} options.session - the session the tools drive.
97
+ * @param {object} [options.logger] - the stderr logger.
98
+ */
99
+ export function createServer({ config, session, logger }) {
100
+ const server = new McpServer(
101
+ { name: SERVER_NAME, version: VERSION, title: 'browser-use-mcp' },
102
+ { instructions: instructions(config) },
103
+ )
104
+ registerTools(server, { config, session, logger, version: VERSION })
105
+ return server
106
+ }
107
+
108
+ /**
109
+ * Build the engine environment in the background so the first browser call does not have to wait for
110
+ * it — and say so on stderr, never on the protocol's stdout.
111
+ *
112
+ * A failure here is not fatal: the browser tools report the same problems, with their fixes, when
113
+ * they are called. The point is that the install a machine needs once happens while the agent is
114
+ * still reading its instructions rather than inside its first click.
115
+ */
116
+ export function prewarm(config, logger) {
117
+ const started = Date.now()
118
+ logger?.info(`checking the browser engine (state root ${config.browserUseMcpHome})`)
119
+ ensureEngine(config, { onOutput: line => logger?.debug?.(`install: ${line}`) })
120
+ .then(report => {
121
+ if (report.ok) {
122
+ logger?.info(`browser engine ready: jev_ultrafast ${report.engine.engine}, browser-harness ${report.engine.browserHarness} (Python ${report.engine.python})`)
123
+ return
124
+ }
125
+ logger?.error(`the browser engine is not ready yet (${Math.round((Date.now() - started) / 1000)}s):\n${reportText(report)}`)
126
+ })
127
+ .catch(error => logger?.error(`checking the browser engine failed: ${error?.stack ?? error}`))
128
+ }
129
+
130
+ /**
131
+ * Serve one MCP connection over stdio until the client closes stdin or the process is signalled.
132
+ *
133
+ * @param {object} config - the resolved configuration.
134
+ * @param {object} logger - the stderr logger.
135
+ * @returns {Promise<{server: McpServer, session: BrowserSession, shutdown: (reason: string) => Promise<void>}>}
136
+ */
137
+ export async function serveStdio(config, logger) {
138
+ const session = createSession(config, { logger })
139
+ const server = createServer({ config, session, logger })
140
+ let stopping = false
141
+ let resolveClosed
142
+ const closed = new Promise(resolve => { resolveClosed = resolve })
143
+
144
+ const shutdown = async reason => {
145
+ if (stopping) return
146
+ stopping = true
147
+ const began = Date.now()
148
+ logger?.info(`shutting down (${reason})`)
149
+ // Synchronously, before anything can go wrong: the profile claim is released even if this
150
+ // process is killed while the browser is still stopping.
151
+ session.releaseLocks()
152
+ // A stdio MCP client closes the pipe, waits two seconds, sends SIGTERM, waits two more, and then
153
+ // kills. The engine's daemon cleanup can outlast that, so the launch is bounded and the process
154
+ // exits on its own terms instead of being killed mid-cleanup.
155
+ const forced = setTimeout(() => {
156
+ logger?.error('shutdown took too long; exiting with the browser already asked to stop')
157
+ session.releaseLocks()
158
+ process.exit(0)
159
+ }, 3_000)
160
+ forced.unref?.()
161
+ try {
162
+ await server.close()
163
+ logger?.debug?.(`MCP server closed in ${Date.now() - began}ms`)
164
+ } catch (error) {
165
+ logger?.debug?.(`closing the MCP server failed: ${error?.message ?? error}`)
166
+ }
167
+ try {
168
+ const problem = await session.shutdown({ graceMs: 1_200 })
169
+ logger?.debug?.(`browser session shut down in ${Date.now() - began}ms`)
170
+ if (problem !== '') logger?.error(`the browser was not shut down cleanly: ${problem}`)
171
+ } catch (error) {
172
+ logger?.error(`stopping the browser failed: ${error?.stack ?? error}`)
173
+ }
174
+ clearTimeout(forced)
175
+ resolveClosed()
176
+ // Nothing else holds this process open on purpose; the sidecar child is gone and stdin is closed.
177
+ process.exit(0)
178
+ }
179
+
180
+ process.once('exit', () => session.releaseLocks())
181
+ const transport = new StdioServerTransport()
182
+ // The transport reads stdin but does not treat its end as a close: an MCP client that exits would
183
+ // otherwise leave this process — and the Chrome it launched — running with no one to answer.
184
+ process.stdin.once('end', () => { shutdown('stdin closed') })
185
+ process.once('SIGINT', () => { shutdown('SIGINT') })
186
+ process.once('SIGTERM', () => { shutdown('SIGTERM') })
187
+ process.once('SIGHUP', () => { shutdown('SIGHUP') })
188
+ await server.connect(transport)
189
+ prewarm(config, logger)
190
+ logger?.info(`serving MCP on stdio: ${toolNames(config).length} tools, ${config.mode} mode, transport stdio`)
191
+ await closed
192
+ return { server, session, shutdown }
193
+ }
package/lib/session.js ADDED
@@ -0,0 +1,484 @@
1
+ /**
2
+ * One browser session: a sidecar generation, a serialized operation queue, and the observations a
3
+ * caller's next action may refer to.
4
+ *
5
+ * The session is the unit an MCP connection owns. One MCP client gets one browser: its own profile,
6
+ * its own DevTools connection, its own tabs. Two agents therefore never fight over a page by accident
7
+ * — running two servers gives two browsers, and using the HTTP transport gives each MCP session its
8
+ * own browser unless the server is told to share.
9
+ *
10
+ * @module browser-use-mcp/session
11
+ */
12
+
13
+ import { createHash } from 'node:crypto'
14
+ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+
17
+ import { connectionFor } from './config.js'
18
+ import { Sidecar } from './sidecar.js'
19
+
20
+ /** The sidecar's own ceiling for one awaited expression (120 s), plus the answer's margin. */
21
+ export const EVAL_DEADLINE_MS = 125_000
22
+ /** How many past observations stay addressable by `browser_act`; a page is re-read after that. */
23
+ const MAX_OBSERVATIONS = 50
24
+
25
+ /** A refusal the caller can act on: nothing executed, and usually the fresh table to choose from. */
26
+ export class SessionRefusal extends Error {
27
+ /**
28
+ * @param {string} message - why nothing executed, or what to do instead.
29
+ * @param {object} [options]
30
+ * @param {object} [options.page] - the fresh page, when the refusal carries one.
31
+ * @param {number} [options.observation] - the fresh observation number for `page`.
32
+ * @param {string} [options.kind] - the sidecar's machine-readable refusal kind.
33
+ */
34
+ constructor(message, { page, observation, kind = 'refused' } = {}) {
35
+ super(message)
36
+ this.name = 'SessionRefusal'
37
+ this.kind = kind
38
+ this.page = page
39
+ this.observation = observation
40
+ }
41
+ }
42
+
43
+ /** Another live session holds the profile (or attached browser) this session was told to use. */
44
+ export class SessionBusy extends Error {
45
+ constructor(message, { fix }) {
46
+ super(message)
47
+ this.name = 'SessionBusy'
48
+ this.kind = 'busy'
49
+ this.fix = fix
50
+ }
51
+ }
52
+
53
+ let lockCounter = 0
54
+
55
+ /** One lock file: created exclusively, reclaimed when its owning process is gone, removed on release. */
56
+ export class FileLock {
57
+ #path = null
58
+ #token = null
59
+
60
+ constructor(path) {
61
+ this.#path = path
62
+ }
63
+
64
+ /**
65
+ * Take the lock, or throw the busy error the caller supplies.
66
+ * @param {(holder: object) => Error} busy - the refusal to throw when a live process holds it.
67
+ */
68
+ acquire(busy) {
69
+ if (this.#path === null) return
70
+ if (this.#token !== null) return // this session already holds it
71
+ mkdirSync(join(this.#path, '..'), { recursive: true })
72
+ const token = `${process.pid}-${Date.now()}-${++lockCounter}`
73
+ for (let attempt = 0; attempt < 2; attempt += 1) {
74
+ try {
75
+ const descriptor = openSync(this.#path, 'wx')
76
+ try {
77
+ writeSync(descriptor, JSON.stringify({ pid: process.pid, token, startedAt: new Date().toISOString() }))
78
+ } finally {
79
+ closeSync(descriptor)
80
+ }
81
+ this.#token = token
82
+ return
83
+ } catch (error) {
84
+ if (error.code !== 'EEXIST') throw error
85
+ const holder = this.#read()
86
+ if (holder !== undefined && isAlive(holder.pid)) throw busy(holder)
87
+ // The lock's owner is gone — a crashed server or one killed with its browser — so the
88
+ // profile is free. Removing it is safe: the writer that could still be racing uses 'wx' too.
89
+ try {
90
+ unlinkSync(this.#path)
91
+ } catch (unlinkError) {
92
+ if (unlinkError.code !== 'ENOENT') throw unlinkError
93
+ }
94
+ }
95
+ }
96
+ throw busy(this.#read() ?? {})
97
+ }
98
+
99
+ #read() {
100
+ try {
101
+ return JSON.parse(readFileSync(this.#path, 'utf8'))
102
+ } catch {
103
+ return undefined
104
+ }
105
+ }
106
+
107
+ release() {
108
+ if (this.#path === null || this.#token === null) return
109
+ const holder = this.#read()
110
+ if (holder?.token === this.#token) {
111
+ try {
112
+ unlinkSync(this.#path)
113
+ } catch {
114
+ // An already-removed lock needs nothing here.
115
+ }
116
+ }
117
+ this.#token = null
118
+ }
119
+ }
120
+
121
+ /** Whether a pid is alive on this machine; a permission answer counts as alive. */
122
+ function isAlive(pid) {
123
+ const number = Number(pid)
124
+ if (!Number.isInteger(number) || number <= 0) return false
125
+ try {
126
+ process.kill(number, 0)
127
+ return true
128
+ } catch (error) {
129
+ return error.code === 'EPERM'
130
+ }
131
+ }
132
+
133
+ /**
134
+ * One browser session.
135
+ *
136
+ * Every public operation is queued behind the session's earlier operations: a page is a shared piece
137
+ * of state, and two tool calls racing on it is how a click lands on the wrong element. Cancellation
138
+ * settles the wait, not the browser work — the page may already have changed, so the next call must
139
+ * read it again rather than assume nothing happened.
140
+ */
141
+ export class BrowserSession {
142
+ /**
143
+ * @param {object} options
144
+ * @param {object} options.config - the resolved configuration.
145
+ * @param {string} [options.label] - a filesystem-safe label for this session's browser.
146
+ * @param {object} [options.logger] - the stderr logger.
147
+ * @param {object} [options.connection] - per-session connection overrides (`userDataDir`, `headless`).
148
+ * @param {(options: object) => object} [options.sidecarFactory] - how a sidecar generation is built;
149
+ * tests replace the real child process with a scripted one.
150
+ */
151
+ constructor({ config, label = 'session', logger, connection = {}, sidecarFactory }) {
152
+ this.config = config
153
+ this.label = label
154
+ this.logger = logger
155
+ this.connection = connectionFor(config, connection)
156
+ this.generation = 1
157
+ this.sidecar = undefined
158
+ this.problem = ''
159
+ this.tail = Promise.resolve()
160
+ this.observation = 0
161
+ this.fingerprints = new Map()
162
+ this.last = null
163
+ this.lastAction = ''
164
+ this.openedAt = null
165
+ this.#sidecarFactory = sidecarFactory ?? (sidecarOptions => new Sidecar(sidecarOptions))
166
+ this.#profileLock = new FileLock(this.connection.mode === 'launch'
167
+ ? join(this.connection.userDataDir, '.browser-use-mcp.lock')
168
+ : null)
169
+ // In attach mode, `shared` means several sessions (and several server processes) may drive the
170
+ // browser you already use, each in its own tab and over its own daemon; `exclusive` claims the
171
+ // DevTools endpoint for one live holder and refuses the rest.
172
+ this.#attachLock = this.connection.mode === 'attach' && config.attachSharing === 'exclusive'
173
+ ? new FileLock(join(config.browserUseMcpHome, 'attachments', `${createHash('sha256').update(this.connection.cdpEndpoint || 'discovered').digest('hex').slice(0, 16)}.lock`))
174
+ : null
175
+ }
176
+
177
+ #profileLock
178
+ #attachLock
179
+ #sidecarFactory
180
+
181
+ /** The sidecar this session may use now: the same generation while healthy, else a new one. */
182
+ #sidecar() {
183
+ if (this.sidecar && !this.sidecar.broken) return this.sidecar
184
+ if (this.sidecar?.broken) {
185
+ // The previous generation died or could not be cleaned up. It is never reused: the next browser
186
+ // is a new process, a new daemon name, and the caller is told what happened. Its page table is
187
+ // forgotten too — a table from a process that is gone is not an observation of anything now.
188
+ this.problem = this.sidecar.problem || 'the browser sidecar was lost'
189
+ this.generation += 1
190
+ this.#forgetPage()
191
+ }
192
+ this.sidecar = this.#sidecarFactory({
193
+ config: this.config,
194
+ label: this.label,
195
+ generation: this.generation,
196
+ logger: this.logger,
197
+ })
198
+ return this.sidecar
199
+ }
200
+
201
+ /**
202
+ * Run one browser operation after this session's earlier operations settle.
203
+ * @param {AbortSignal} [signal] - cancellation for this one call.
204
+ * @param {(sidecar: Sidecar, signal: AbortSignal) => Promise<any>} operation - the work to run.
205
+ */
206
+ run(signal, operation) {
207
+ signal?.throwIfAborted?.()
208
+ const task = this.tail.then(async () => {
209
+ signal?.throwIfAborted?.()
210
+ const sidecar = this.#sidecar()
211
+ await sidecar.ready(signal)
212
+ return operation(sidecar, signal)
213
+ })
214
+ this.tail = task.then(() => {}, () => {})
215
+ return task
216
+ }
217
+
218
+ /**
219
+ * Record one observation and return its number: the handle `browser_act` names.
220
+ *
221
+ * Only the last {@link MAX_OBSERVATIONS} stay addressable; an older observation is refused the same
222
+ * way one this session never produced is, which is what keeps the fingerprint map bounded.
223
+ */
224
+ observed(page) {
225
+ this.observation += 1
226
+ this.fingerprints.set(this.observation, page.fingerprint)
227
+ while (this.fingerprints.size > MAX_OBSERVATIONS) {
228
+ this.fingerprints.delete(this.fingerprints.keys().next().value)
229
+ }
230
+ this.last = { page, observation: this.observation }
231
+ return this.observation
232
+ }
233
+
234
+ /** The fingerprint of an observation this session produced, or undefined for a foreign one. */
235
+ fingerprintFor(observation) {
236
+ if (observation === undefined || observation === null) return undefined
237
+ return this.fingerprints.get(Number(observation))
238
+ }
239
+
240
+ /** Forget page state, keeping the sidecar: the profile and the browser outlive one tab. */
241
+ #forgetPage() {
242
+ this.fingerprints.clear()
243
+ this.last = null
244
+ this.lastAction = ''
245
+ }
246
+
247
+ /** Take this session's claim on its browser; called before the first request that can launch one. */
248
+ #claimConnection() {
249
+ this.#profileLock?.acquire(holder => new SessionBusy(
250
+ `browser profile "${this.config.profile}" at ${this.connection.userDataDir} is already driven by another `
251
+ + `browser-use-mcp process (pid ${holder.pid}), so two servers would drive one browser. `
252
+ + 'Stop that server, or give this one its own profile with --profile <name>.',
253
+ { fix: 'start the other server with --profile <name>, or close it first' },
254
+ ))
255
+ this.#attachLock?.acquire(holder => new SessionBusy(
256
+ `${this.connection.cdpEndpoint || 'the Chrome you browse with'} is already attached by another live `
257
+ + `session or browser-use-mcp process (pid ${holder.pid}). With --attach-sharing exclusive only one `
258
+ + 'holder is allowed; drop that flag (the default is shared) so both sessions can drive the browser '
259
+ + 'in their own tabs, or launch a browser of our own with --mode launch.',
260
+ { fix: 'stop the other server, or use --mode launch' },
261
+ ))
262
+ }
263
+
264
+ /** Release the profile/attachment claim; the browser itself is stopped by `shutdown`. */
265
+ #releaseConnection() {
266
+ this.#profileLock?.release()
267
+ this.#attachLock?.release()
268
+ }
269
+
270
+ /**
271
+ * Open `url` in this session's browser and read it.
272
+ * @returns {Promise<{page: object, observation: number, note: string}>}
273
+ */
274
+ async open(url, { signal, reuse = false } = {}) {
275
+ if (typeof url !== 'string' || url.trim() === '') throw new SessionRefusal('browser_open needs a url.', { kind: 'bad_request' })
276
+ // Claimed before the sidecar starts: a profile another process drives must not become a second
277
+ // Chrome launch that only fails later, with the browser already up.
278
+ this.#claimConnection()
279
+ return this.run(signal, async sidecar => {
280
+ // Read here, after the generation was chosen: a broken generation is replaced during the run,
281
+ // and this call is the one that has to say the previous browser was not shut down cleanly.
282
+ const note = this.problem
283
+ ? `\n\nThe previous browser was not shut down cleanly (${this.problem}); this is a new one.\n`
284
+ : ''
285
+ const { page } = await sidecar.request('open', {
286
+ url: url.trim(),
287
+ ...this.connection,
288
+ ...(reuse ? { reuse: true } : {}),
289
+ }, signal)
290
+ this.problem = ''
291
+ this.openedAt = Date.now()
292
+ const observation = this.observed(page)
293
+ return { page, observation, note }
294
+ })
295
+ }
296
+
297
+ /** Read the current page again: a new observation for the same page. */
298
+ async observe({ signal } = {}) {
299
+ return this.run(signal, async sidecar => {
300
+ const { page } = await sidecar.request('observe', {}, signal)
301
+ const observation = this.observed(page)
302
+ return { page, observation }
303
+ })
304
+ }
305
+
306
+ /**
307
+ * Run one guarded operation on one target of one observation.
308
+ *
309
+ * A stale page is reported as a refusal carrying the fresh table, never retried: the click the
310
+ * caller chose was about to land somewhere it had not seen.
311
+ * @param {object} input
312
+ * @param {number} input.observation - the observation whose table the target came from.
313
+ * @param {string} [input.operation] - `CLICK`, `TYPE_TEXT`, `SELECT`, `SCROLL_*`, `WAIT`.
314
+ * @param {string} [input.target] - the target index from that observation.
315
+ * @param {string} [input.text] - the value to enter when the operation is `TYPE_TEXT`.
316
+ * @param {string} [input.intent] - what the step should accomplish, for the policy to choose.
317
+ * @param {object} [input.engineEnv] - the Jev variables for this one model-spending call.
318
+ */
319
+ async act({ observation, operation = '', target = '', text, intent = '', engineEnv } = {}, { signal } = {}) {
320
+ const fingerprint = this.fingerprintFor(observation)
321
+ if (fingerprint === undefined) {
322
+ throw new SessionRefusal(
323
+ `observation ${observation ?? '(missing)'} is not one this session produced. Read the page again with browser_page.`,
324
+ { kind: 'unsupported_target' },
325
+ )
326
+ }
327
+ return this.run(signal, async sidecar => {
328
+ try {
329
+ const result = await sidecar.request('act', {
330
+ operation,
331
+ target,
332
+ text,
333
+ fingerprint,
334
+ ...(intent === '' ? {} : { intent }),
335
+ ...(engineEnv === undefined ? {} : { engine_env: engineEnv }),
336
+ }, signal)
337
+ const { page, ...rest } = result
338
+ const fresh = this.observed(page)
339
+ if (rest.executed !== undefined) {
340
+ const label = rest.executed.label ? ` on ${rest.executed.label}` : ''
341
+ this.lastAction = `${rest.executed.operation ?? operation}${label}`
342
+ } else if (rest.decision !== undefined) {
343
+ this.lastAction = rest.decision.operation ?? ''
344
+ }
345
+ return { ...rest, page, observation: fresh }
346
+ } catch (error) {
347
+ if (error?.kind === 'stale' && error.page) {
348
+ const fresh = this.observed(error.page)
349
+ throw new SessionRefusal(String(error.message), { page: error.page, observation: fresh, kind: 'stale' })
350
+ }
351
+ if (error?.kind === 'lost_session') {
352
+ this.#forgetPage()
353
+ throw new SessionRefusal(
354
+ `the browser lost the page (${error.message}). Call browser_open again; the next action must start from a fresh observation.`,
355
+ { kind: 'lost_session' },
356
+ )
357
+ }
358
+ throw error
359
+ }
360
+ })
361
+ }
362
+
363
+ /**
364
+ * Evaluate one JavaScript expression in the owned page over the session's own CDP connection.
365
+ *
366
+ * The expression gets no user gesture; a throw is reported as a refusal, and no observation is
367
+ * produced because the page state is still the one the caller last read.
368
+ */
369
+ async evaluate({ expression, timeout_ms } = {}, { signal } = {}) {
370
+ if (typeof expression !== 'string' || expression.trim() === '') {
371
+ throw new SessionRefusal('browser_eval needs a JavaScript expression.', { kind: 'bad_request' })
372
+ }
373
+ return this.run(signal, async sidecar => {
374
+ try {
375
+ return await sidecar.request('evaluate', {
376
+ expression,
377
+ ...(timeout_ms === undefined ? {} : { timeout_ms }),
378
+ }, signal, Math.max(this.config.requestTimeoutMs, EVAL_DEADLINE_MS))
379
+ } catch (error) {
380
+ if (error?.kind === 'js_error') {
381
+ const message = String(error.message ?? '')
382
+ throw new SessionRefusal(
383
+ message.startsWith('the value cannot be returned by value')
384
+ ? `the expression produced a value that cannot cross the wire by value:\n${message}`
385
+ : `the expression threw, so nothing was returned:\n${message}`,
386
+ { kind: 'js_error' },
387
+ )
388
+ }
389
+ if (error?.kind === 'lost_session') {
390
+ this.#forgetPage()
391
+ throw new SessionRefusal(`the browser lost the page (${error.message}). Call browser_open again.`, { kind: 'lost_session' })
392
+ }
393
+ throw error
394
+ }
395
+ })
396
+ }
397
+
398
+ /** One JPEG of the visible viewport, as base64 plus its pixel size. */
399
+ async screenshot({ quality, signal } = {}) {
400
+ return this.run(signal, async sidecar => {
401
+ const shot = await sidecar.request('screenshot', quality === undefined ? {} : { quality }, signal)
402
+ return { jpegBase64: shot.jpeg_base64, width: shot.width, height: shot.height }
403
+ })
404
+ }
405
+
406
+ /** Console entries, exceptions, failed and 4xx/5xx requests, drained per call. */
407
+ async diagnostics({ signal } = {}) {
408
+ return this.run(signal, async sidecar => sidecar.request('diagnostics', {}, signal))
409
+ }
410
+
411
+ /**
412
+ * Hand a whole goal to the policy in its own tab. Spends model quota; never retried.
413
+ * @param {object} input
414
+ * @param {string} input.url - where the run starts.
415
+ * @param {string} input.goal - what must be true when it finishes.
416
+ * @param {object} [input.engineEnv] - the Jev variables for this one model-spending call.
417
+ */
418
+ async goal({ url, goal, engineEnv } = {}, { signal } = {}) {
419
+ if (typeof url !== 'string' || url.trim() === '') throw new SessionRefusal('browser_goal needs a url.', { kind: 'bad_request' })
420
+ if (typeof goal !== 'string' || goal.trim() === '') throw new SessionRefusal('browser_goal needs a goal.', { kind: 'bad_request' })
421
+ this.#claimConnection()
422
+ return this.run(signal, async sidecar => {
423
+ // A goal run decides its own length: the policy loops until DONE/BLOCKED, which can exceed any
424
+ // per-request deadline. The caller's client timeout is the real bound here.
425
+ const result = await sidecar.request('goal', {
426
+ url: url.trim(),
427
+ goal: goal.trim(),
428
+ ...this.connection,
429
+ ...(engineEnv === undefined ? {} : { engine_env: engineEnv }),
430
+ }, signal, Number.POSITIVE_INFINITY)
431
+ return result
432
+ })
433
+ }
434
+
435
+ /** Close the owned tab; a launched browser stays up for the next page. */
436
+ async close({ signal } = {}) {
437
+ // Nothing has been opened, so there is nothing to close and no reason to start a sidecar.
438
+ if (this.sidecar === undefined) return { closed: false, idle: true }
439
+ return this.run(signal, async sidecar => {
440
+ const result = await sidecar.request('close', {}, signal)
441
+ this.#forgetPage()
442
+ return result
443
+ })
444
+ }
445
+
446
+ /**
447
+ * Release the profile/attachment claims *now*, without waiting for a browser.
448
+ *
449
+ * Locks are synchronous filesystem work and must be released even when the process dies a moment
450
+ * later: a lock left by a killed server would refuse the next one until its owner pid is seen dead,
451
+ * and that is a delay a user should never have to explain.
452
+ */
453
+ releaseLocks() {
454
+ this.#releaseConnection()
455
+ }
456
+
457
+ /** Stop the sidecar (tab, browser, daemon) and release this session's claims. */
458
+ async shutdown(options = {}) {
459
+ // First, synchronously: whatever happens to this process next, the profile is not left claimed.
460
+ this.#releaseConnection()
461
+ const started = Date.now()
462
+ const problem = await this.sidecar?.stop(options) ?? ''
463
+ this.logger?.debug?.(`sidecar stopped in ${Date.now() - started}ms`)
464
+ this.sidecar = undefined
465
+ this.problem = problem
466
+ this.#forgetPage()
467
+ return problem
468
+ }
469
+
470
+ /** What the doctor shows about this session, without touching the browser. */
471
+ status() {
472
+ return {
473
+ label: this.label,
474
+ mode: this.connection.mode,
475
+ generation: this.generation,
476
+ sidecarRunning: this.sidecar !== undefined && !this.sidecar.broken,
477
+ problem: this.problem,
478
+ observation: this.observation,
479
+ page: this.last ? { url: this.last.page.url, title: this.last.page.title ?? '', observation: this.last.observation } : null,
480
+ lastAction: this.lastAction,
481
+ openedAt: this.openedAt,
482
+ }
483
+ }
484
+ }