remote-agent-browser 1.1.0 → 1.2.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 CHANGED
@@ -102,6 +102,25 @@ await browser.exec('find', {
102
102
  })
103
103
  ```
104
104
 
105
+ ### `browser.shell(command, options?)`
106
+
107
+ Use this when forwarding a Bash-tool command or composing `agent-browser` with
108
+ normal shell utilities. The command runs verbatim, so quoting, pipes,
109
+ redirection, and control operators keep their shell semantics. Prefer `exec()`
110
+ or `run()` for browser commands that do not need a shell. Every `agent-browser`
111
+ invocation inherits the client's CLI session through `AGENT_BROWSER_SESSION`.
112
+
113
+ ```ts
114
+ const result = await browser.shell(
115
+ 'agent-browser read "https://example.com" | grep -io "example" | wc -l',
116
+ )
117
+ console.log(result.stdout)
118
+ ```
119
+
120
+ Client-wide `args` are not inserted into a shell string because doing so would
121
+ require rewriting arbitrary shell syntax. Put global CLI arguments directly in
122
+ the command when using `shell()`.
123
+
105
124
  ### Typed JSON
106
125
 
107
126
  Set `output: 'json'` for commands that support `--json`. `exec<T>()` adds the
@@ -143,6 +162,17 @@ const result = await browser.exec('network', { args: ['har', 'stop'] })
143
162
  await writeFile('capture.har', result.file.bytes)
144
163
  ```
145
164
 
165
+ When a command already contains an explicit remote output path, read it back
166
+ without changing the command:
167
+
168
+ ```ts
169
+ await browser.shell(
170
+ 'agent-browser screenshot /tmp/verification.png',
171
+ )
172
+ const file = await browser.readFile('/tmp/verification.png', 'image/png')
173
+ await writeFile('verification.png', file.bytes)
174
+ ```
175
+
146
176
  ### Convenience methods
147
177
 
148
178
  - `browser.snapshot(url?)` optionally opens a page, then returns its interactive
@@ -153,6 +183,41 @@ await writeFile('capture.har', result.file.bytes)
153
183
 
154
184
  All methods use the same disposable browser session until `close()` is called.
155
185
 
186
+ ### Stable browser ids
187
+
188
+ Use `AgentBrowser.session()` when browser identity must survive process
189
+ boundaries. It returns a lazy handle: constructing it or starting keepalive
190
+ does not create a Sandbox. The first browser command finds or creates a runtime
191
+ derived from the caller-defined id, and another process using the same id finds
192
+ that runtime again.
193
+
194
+ ```ts
195
+ const browser = AgentBrowser.session({ id: `environment:${chatId}` })
196
+ const stopKeepalive = browser.keepalive()
197
+ try {
198
+ await browser.exec('open', { args: ['https://example.com'] })
199
+ } finally {
200
+ stopKeepalive()
201
+ }
202
+ ```
203
+
204
+ Ids are project-scoped. Include the deployment environment or another namespace
205
+ when the same application uses one Vercel project for multiple environments.
206
+ The underlying Sandbox name is private and derived from a hash of the id.
207
+
208
+ If an expired Sandbox resumes, its Chromium process starts fresh. Subscribe to
209
+ `reset` before running commands when the caller needs to surface that page,
210
+ cookies, refs, tabs, and console history were lost:
211
+
212
+ ```ts
213
+ browser.on('reset', ({ reason }) => {
214
+ console.log(`Browser runtime reset: ${reason}`)
215
+ })
216
+ ```
217
+
218
+ `browser.destroy()` permanently removes the named runtime. Stopping keepalive
219
+ only allows its normal idle timeout to resume.
220
+
156
221
  ### Keepalive
157
222
 
158
223
  Keep the Sandbox alive across idle gaps during long-running work. Always stop
package/dist/index.d.ts CHANGED
@@ -36,6 +36,18 @@ type BrowserRunResult = {
36
36
  results: BrowserCommandResult[];
37
37
  ok: boolean;
38
38
  };
39
+ /** Result from a verbatim shell command running in the browser environment. */
40
+ type BrowserShellResult = {
41
+ ok: boolean;
42
+ exitCode: number | null;
43
+ stdout: string;
44
+ stderr: string;
45
+ };
46
+ type ShellOptions = {
47
+ /** Named agent-browser CLI session available to commands through the environment. */
48
+ session?: string;
49
+ timeoutMs?: number;
50
+ };
39
51
  type RunOptions = {
40
52
  /** Named session to reuse browser state across calls. Default: ephemeral. */
41
53
  session?: string;
@@ -72,6 +84,8 @@ type BrowserClientOptions = {
72
84
  args?: string[];
73
85
  /** Proxy configuration fixed for this client's lifetime. */
74
86
  proxy?: ProxyOptions;
87
+ /** Run `agent-browser close` before closing the runner. Default: true. */
88
+ closeSession?: boolean;
75
89
  /** Whether close() also tears down the command runner. Default: true. */
76
90
  ownsRunner?: boolean;
77
91
  };
@@ -83,6 +97,12 @@ type KeepaliveOptions = {
83
97
  /** Optional observer for best-effort heartbeat failures. */
84
98
  onError?: (error: unknown) => void;
85
99
  };
100
+ type BrowserRuntimeResetEvent = {
101
+ /** Stable logical browser id supplied by the caller. */
102
+ id: string;
103
+ /** Why the backing runtime no longer has its previous in-memory state. */
104
+ reason: 'resumed';
105
+ };
86
106
  /**
87
107
  * Minimal command executor the browser client needs. Implemented by the
88
108
  * Vercel Sandbox adapter; anything with the same shape can back a
@@ -113,6 +133,8 @@ interface AgentBrowser$1 {
113
133
  readonly session: string;
114
134
  /** Run a batch of agent-browser arg-arrays sequentially. */
115
135
  run(commands: string[][], opts?: RunOptions): Promise<BrowserRunResult>;
136
+ /** Run a shell command verbatim, preserving quoting and control operators. */
137
+ shell(command: string, opts?: ShellOptions): Promise<BrowserShellResult>;
116
138
  /** Run one command and unwrap its typed --json data payload. */
117
139
  exec<T>(command: string, opts: JsonExecOptions): Promise<BrowserCommandResult<T>>;
118
140
  /** Run one agent-browser command with text output. */
@@ -126,6 +148,8 @@ interface AgentBrowser$1 {
126
148
  file: BrowserFile;
127
149
  result: BrowserCommandResult;
128
150
  }>;
151
+ /** Read an explicitly named artifact from the browser environment. */
152
+ readFile(path: string, contentType?: string): Promise<BrowserFile>;
129
153
  /** Optionally open a URL, then return the interactive accessibility snapshot. */
130
154
  snapshot(urlOrOptions?: string | RunOptions, opts?: RunOptions): Promise<BrowserRunResult>;
131
155
  /** Optionally open a URL, then return a PNG screenshot. */
@@ -141,6 +165,15 @@ interface AgentBrowser$1 {
141
165
  /** Close the browser session (and the sandbox when it owns it). */
142
166
  close(): Promise<void>;
143
167
  }
168
+ /** A lazily acquired browser whose logical identity survives runtime expiry. */
169
+ interface AgentBrowserSession extends AgentBrowser$1 {
170
+ /** Stable caller-defined identity. The backing runtime may be replaced. */
171
+ readonly id: string;
172
+ /** Observe when an expired runtime resumes with fresh in-memory browser state. */
173
+ on(event: 'reset', listener: (event: BrowserRuntimeResetEvent) => void): () => void;
174
+ /** Permanently remove the runtime currently associated with this id. */
175
+ destroy(): Promise<void>;
176
+ }
144
177
 
145
178
  type CreateAgentBrowserOptions = {
146
179
  /** Named CLI session; state (page, cookies, refs) persists across calls. */
@@ -161,6 +194,10 @@ type CreateAgentBrowserOptions = {
161
194
  /** Proxy configuration fixed for this browser's lifetime. */
162
195
  proxy?: ProxyOptions;
163
196
  };
197
+ type CreateAgentBrowserSessionOptions = CreateAgentBrowserOptions & {
198
+ /** Stable logical identity used to find this browser across processes. */
199
+ id: string;
200
+ };
164
201
  /**
165
202
  * Create an agent-browser client running inside a fresh Vercel Sandbox.
166
203
  *
@@ -184,6 +221,15 @@ type CreateAgentBrowserOptions = {
184
221
  * ```
185
222
  */
186
223
  declare function createAgentBrowser(opts?: CreateAgentBrowserOptions): Promise<AgentBrowser$1>;
224
+ /**
225
+ * Create a lazy browser handle with a stable logical id.
226
+ *
227
+ * The first operation finds or creates a named Vercel Sandbox. Later handles
228
+ * created with the same id attach to that Sandbox, including from another
229
+ * process. If an expired Sandbox resumes, listeners are told that its
230
+ * in-memory page, refs, cookies, and console history were reset.
231
+ */
232
+ declare function createAgentBrowserSession(opts: CreateAgentBrowserSessionOptions): AgentBrowserSession;
187
233
 
188
234
  /**
189
235
  * Create a session-oriented browser client over any CommandRunner.
@@ -198,8 +244,9 @@ declare function createBrowserClient(runner: CommandRunner, opts?: BrowserClient
198
244
  /** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */
199
245
  declare const AgentBrowser: {
200
246
  create: typeof createAgentBrowser;
247
+ session: typeof createAgentBrowserSession;
201
248
  };
202
249
  type AgentBrowser = AgentBrowser$1;
203
250
 
204
- export { AgentBrowser, createAgentBrowser, createBrowserClient };
205
- export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, RunOptions };
251
+ export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
252
+ export type { AgentBrowserSession, BrowserClientOptions, BrowserCommandResult, BrowserFile, BrowserRunResult, BrowserRuntimeResetEvent, BrowserShellResult, CommandRunner, CreateAgentBrowserOptions, CreateAgentBrowserSessionOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, RunOptions, ShellOptions };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { randomUUID, createHash } from 'node:crypto';
2
2
  import { Sandbox } from '@vercel/sandbox';
3
3
 
4
4
  const FLAGS_WITH_VALUE = new Set([
@@ -150,11 +150,28 @@ function outputSpec(args) {
150
150
  const clientEnv = proxyEnvironment(opts.proxy);
151
151
  const redactProxy = proxyOutputRedactor(opts.proxy);
152
152
  const ownsRunner = opts.ownsRunner ?? true;
153
+ const closeSession = opts.closeSession ?? true;
153
154
  const recordings = new Map();
154
155
  let closed = false;
155
156
  function assertOpen() {
156
157
  if (closed) throw new Error('AgentBrowser is closed');
157
158
  }
159
+ async function runWithRedaction(cmd, args, options) {
160
+ try {
161
+ const run = await runner.run(cmd, args, options);
162
+ return {
163
+ ...run,
164
+ stdout: redactProxy(run.stdout),
165
+ stderr: redactProxy(run.stderr)
166
+ };
167
+ } catch (error) {
168
+ if (error instanceof Error) {
169
+ error.message = redactProxy(error.message);
170
+ if (error.stack) error.stack = redactProxy(error.stack);
171
+ }
172
+ throw error;
173
+ }
174
+ }
158
175
  async function execCommand(args, timeoutMs, useSession = session) {
159
176
  const [name, ...rest] = args;
160
177
  // File-producing commands get a temporary remote output path when the
@@ -188,25 +205,16 @@ function outputSpec(args) {
188
205
  remotePath = recording?.path;
189
206
  fileSpec = recording?.spec;
190
207
  }
191
- let run;
192
- try {
193
- run = await runner.run('agent-browser', finalArgs, {
194
- timeoutMs,
195
- env: clientEnv
196
- });
197
- } catch (error) {
198
- if (error instanceof Error) {
199
- error.message = redactProxy(error.message);
200
- if (error.stack) error.stack = redactProxy(error.stack);
201
- }
202
- throw error;
203
- }
208
+ const run = await runWithRedaction('agent-browser', finalArgs, {
209
+ timeoutMs,
210
+ env: clientEnv
211
+ });
204
212
  const result = {
205
213
  args,
206
214
  ok: run.exitCode === 0,
207
215
  exitCode: run.exitCode,
208
- stdout: redactProxy(run.stdout),
209
- stderr: redactProxy(run.stderr)
216
+ stdout: run.stdout,
217
+ stderr: run.stderr
210
218
  };
211
219
  if (result.ok && startsRecording && remotePath && fileSpec) {
212
220
  recordings.set(useSession, {
@@ -297,6 +305,30 @@ function outputSpec(args) {
297
305
  ok: results.every((r)=>r.ok)
298
306
  };
299
307
  },
308
+ async shell (command, shellOpts = {}) {
309
+ assertOpen();
310
+ if (!command.trim()) throw new Error('shell command must not be empty');
311
+ const useSession = shellOpts.session ?? session;
312
+ if (!SESSION_NAME.test(useSession)) {
313
+ throw new Error(`session must match [A-Za-z0-9_.-]{1,64}, got "${useSession}"`);
314
+ }
315
+ const run = await runWithRedaction('sh', [
316
+ '-c',
317
+ command
318
+ ], {
319
+ timeoutMs: shellOpts.timeoutMs,
320
+ env: {
321
+ ...clientEnv,
322
+ AGENT_BROWSER_SESSION: useSession
323
+ }
324
+ });
325
+ return {
326
+ ok: run.exitCode === 0,
327
+ exitCode: run.exitCode,
328
+ stdout: run.stdout,
329
+ stderr: run.stderr
330
+ };
331
+ },
300
332
  exec,
301
333
  async upload (selector, files, execOpts = {}) {
302
334
  assertOpen();
@@ -341,6 +373,15 @@ function outputSpec(args) {
341
373
  result
342
374
  };
343
375
  },
376
+ async readFile (path, contentType = 'application/octet-stream') {
377
+ assertOpen();
378
+ if (!path) throw new Error('file path must not be empty');
379
+ return {
380
+ path,
381
+ bytes: await runner.readFile(path),
382
+ contentType
383
+ };
384
+ },
344
385
  async snapshot (urlOrOptions, opts = {}) {
345
386
  const url = typeof urlOrOptions === 'string' ? urlOrOptions : undefined;
346
387
  const runOpts = typeof urlOrOptions === 'string' ? opts : urlOrOptions ?? {};
@@ -392,12 +433,14 @@ function outputSpec(args) {
392
433
  if (closed) return;
393
434
  closed = true;
394
435
  // Close the CLI session's browser first so the sandbox can stop cleanly.
395
- await runner.run('agent-browser', withSession(session, clientArgs, [
396
- 'close'
397
- ]), {
398
- timeoutMs: 15_000,
399
- env: clientEnv
400
- }).catch(()=>{});
436
+ if (closeSession) {
437
+ await runner.run('agent-browser', withSession(session, clientArgs, [
438
+ 'close'
439
+ ]), {
440
+ timeoutMs: 15_000,
441
+ env: clientEnv
442
+ }).catch(()=>{});
443
+ }
401
444
  if (ownsRunner) await runner.close();
402
445
  }
403
446
  };
@@ -416,9 +459,34 @@ const BASE_ENV = {
416
459
  this.env = env;
417
460
  this.timeoutMs = timeoutMs;
418
461
  }
462
+ async acquire() {
463
+ if (!this.sandbox) throw new Error('browser sandbox is unavailable');
464
+ return this.sandbox;
465
+ }
466
+ async observe() {
467
+ if (!this.sandbox) return null;
468
+ const sandbox = await Sandbox.get({
469
+ name: this.sandbox.name,
470
+ resume: false
471
+ });
472
+ this.sandbox = sandbox;
473
+ return sandbox;
474
+ }
475
+ async destroyRuntime() {
476
+ await this.sandbox?.stop().catch(()=>{});
477
+ }
419
478
  async run(cmd, args, opts = {}) {
420
479
  const timeoutMs = opts.timeoutMs ?? 60_000;
421
- const command = await this.sandbox.runCommand({
480
+ // A command is activity: restore the configured idle window even when no
481
+ // explicit heartbeat is running. Renewal is best-effort so an observability
482
+ // or platform hiccup does not block useful browser work.
483
+ const hadSandbox = this.sandbox !== undefined;
484
+ let sandbox = await this.acquire();
485
+ if (hadSandbox) {
486
+ sandbox = await this.observe().catch(()=>null) ?? sandbox;
487
+ }
488
+ await this.restoreLifetime(sandbox, this.timeoutMs).catch(()=>{});
489
+ const command = await sandbox.runCommand({
422
490
  cmd,
423
491
  args,
424
492
  env: {
@@ -428,8 +496,12 @@ const BASE_ENV = {
428
496
  detached: true
429
497
  });
430
498
  let timer;
499
+ let timedOut = false;
431
500
  const timeout = new Promise((_, reject)=>{
432
- timer = setTimeout(()=>reject(new Error(`command timed out after ${timeoutMs}ms`)), timeoutMs);
501
+ timer = setTimeout(()=>{
502
+ timedOut = true;
503
+ reject(new Error(`command timed out after ${timeoutMs}ms`));
504
+ }, timeoutMs);
433
505
  if (typeof timer.unref === 'function') timer.unref();
434
506
  });
435
507
  try {
@@ -449,11 +521,38 @@ const BASE_ENV = {
449
521
  };
450
522
  } catch (error) {
451
523
  await command.kill().catch(()=>{});
524
+ if (timedOut) await this.reclaimBrowser(sandbox);
452
525
  throw error;
453
526
  } finally{
454
527
  if (timer) clearTimeout(timer);
455
528
  }
456
529
  }
530
+ /** A timed-out CLI call may leave Chromium wedged; force a clean relaunch. */ async reclaimBrowser(sandbox) {
531
+ try {
532
+ const command = await sandbox.runCommand({
533
+ cmd: 'sh',
534
+ args: [
535
+ '-c',
536
+ 'pkill -9 chromium || true; pkill -9 chrome || true'
537
+ ],
538
+ detached: true
539
+ });
540
+ let timer;
541
+ const timeout = new Promise((resolve)=>{
542
+ // Cleanup must not turn the original command timeout into an unbounded wait.
543
+ timer = setTimeout(resolve, 30_000);
544
+ if (typeof timer.unref === 'function') timer.unref();
545
+ });
546
+ try {
547
+ await Promise.race([
548
+ command.wait(),
549
+ timeout
550
+ ]);
551
+ } finally{
552
+ if (timer) clearTimeout(timer);
553
+ }
554
+ } catch {}
555
+ }
457
556
  async readFile(path) {
458
557
  const { stdout, exitCode, stderr } = await this.run('base64', [
459
558
  '-w0',
@@ -467,27 +566,28 @@ const BASE_ENV = {
467
566
  return Buffer.from(stdout.trim(), 'base64');
468
567
  }
469
568
  async writeFile(path, bytes) {
470
- await this.sandbox.writeFiles([
569
+ const sandbox = await this.acquire();
570
+ await sandbox.writeFiles([
471
571
  {
472
572
  path,
473
573
  content: bytes
474
574
  }
475
575
  ]);
476
576
  }
477
- /** Restore a sliding idle window without cumulatively adding that window. */ async renewLifetime(timeoutMs) {
478
- // Poll without resuming: a late heartbeat must not resurrect an already
479
- // expired Sandbox whose in-memory browser state is gone.
480
- const sandbox = await Sandbox.get({
481
- name: this.sandbox.name,
482
- resume: false
483
- });
577
+ /** Restore a sliding idle window without cumulatively adding that window. */ async restoreLifetime(sandbox, timeoutMs) {
484
578
  if (sandbox.status !== 'running') return;
485
- this.sandbox = sandbox;
486
579
  const targetExpiresAt = Date.now() + timeoutMs;
487
580
  const currentExpiresAt = sandbox.expiresAt?.getTime();
488
581
  const extensionMs = currentExpiresAt === undefined ? timeoutMs : Math.ceil(targetExpiresAt - currentExpiresAt);
489
582
  if (extensionMs > 0) await sandbox.extendTimeout(extensionMs);
490
583
  }
584
+ async renewLifetime(timeoutMs) {
585
+ // Poll without resuming: a late heartbeat must not resurrect an already
586
+ // expired Sandbox whose in-memory browser state is gone.
587
+ const sandbox = await this.observe();
588
+ if (!sandbox) return;
589
+ await this.restoreLifetime(sandbox, timeoutMs);
590
+ }
491
591
  keepalive(opts = {}) {
492
592
  const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
493
593
  const intervalMs = opts.intervalMs ?? Math.min(5 * 60 * 1000, Math.floor(timeoutMs / 2));
@@ -526,8 +626,95 @@ const BASE_ENV = {
526
626
  for (const stop of [
527
627
  ...this.keepaliveStops
528
628
  ])stop();
529
- await this.sandbox.stop().catch(()=>{});
629
+ await this.destroyRuntime();
630
+ }
631
+ }
632
+ function stableNames(id) {
633
+ const hash = createHash('sha256').update(id).digest('hex');
634
+ return {
635
+ sandbox: `remote-browser-${hash.slice(0, 32)}`,
636
+ session: `session-${hash.slice(0, 32)}`
637
+ };
638
+ }
639
+ function isMissingSandbox(error) {
640
+ if (!(error instanceof Error)) return false;
641
+ const code = error.json?.error?.code;
642
+ return code === 'not_found' || error.message.includes('Status code 404');
643
+ }
644
+ class NamedSandboxRunner extends SandboxRunner {
645
+ constructor(opts){
646
+ const names = stableNames(opts.id);
647
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
648
+ super(undefined, {
649
+ ...BASE_ENV,
650
+ ...opts.env
651
+ }, timeoutMs), this.resetListeners = new Set(), this.onResume = async ()=>{
652
+ const event = {
653
+ id: this.id,
654
+ reason: 'resumed'
655
+ };
656
+ for (const listener of this.resetListeners){
657
+ try {
658
+ listener(event);
659
+ } catch {}
660
+ }
661
+ };
662
+ this.id = opts.id;
663
+ this.name = names.sandbox;
664
+ this.session = names.session;
665
+ this.image = opts.image ?? process.env.REMOTE_AGENT_BROWSER_IMAGE ?? DEFAULT_BROWSER_IMAGE;
666
+ this.vcpus = opts.vcpus ?? 2;
667
+ }
668
+ on(event, listener) {
669
+ if (event !== 'reset') throw new Error(`unsupported browser event: ${event}`);
670
+ this.resetListeners.add(listener);
671
+ return ()=>this.resetListeners.delete(listener);
672
+ }
673
+ async acquire() {
674
+ if (this.sandbox) return this.sandbox;
675
+ this.sandbox = await Sandbox.getOrCreate({
676
+ name: this.name,
677
+ image: this.image,
678
+ resources: {
679
+ vcpus: this.vcpus
680
+ },
681
+ timeout: this.timeoutMs,
682
+ ports: [],
683
+ env: this.env,
684
+ persistent: false,
685
+ onResume: this.onResume
686
+ });
687
+ return this.sandbox;
688
+ }
689
+ async observe() {
690
+ try {
691
+ const sandbox = await Sandbox.get({
692
+ name: this.name,
693
+ resume: false,
694
+ onResume: this.onResume
695
+ });
696
+ this.sandbox = sandbox;
697
+ return sandbox;
698
+ } catch (error) {
699
+ if (isMissingSandbox(error)) return null;
700
+ throw error;
701
+ }
530
702
  }
703
+ async destroyRuntime() {
704
+ try {
705
+ const sandbox = this.sandbox ?? await Sandbox.get({
706
+ name: this.name,
707
+ resume: false
708
+ });
709
+ await sandbox.delete();
710
+ this.sandbox = undefined;
711
+ } catch (error) {
712
+ if (!isMissingSandbox(error)) throw error;
713
+ }
714
+ }
715
+ }
716
+ /** Create a lazy runner backed by a stable, project-scoped browser id. */ function createNamedBrowserSandboxRunner(opts) {
717
+ return new NamedSandboxRunner(opts);
531
718
  }
532
719
  /**
533
720
  * Start an ephemeral Vercel Sandbox from the pre-built browser image.
@@ -590,11 +777,46 @@ const BASE_ENV = {
590
777
  ownsRunner: true
591
778
  });
592
779
  }
780
+ /**
781
+ * Create a lazy browser handle with a stable logical id.
782
+ *
783
+ * The first operation finds or creates a named Vercel Sandbox. Later handles
784
+ * created with the same id attach to that Sandbox, including from another
785
+ * process. If an expired Sandbox resumes, listeners are told that its
786
+ * in-memory page, refs, cookies, and console history were reset.
787
+ */ function createAgentBrowserSession(opts) {
788
+ if (!opts.id.trim()) throw new Error('browser id must not be empty');
789
+ const runner = createNamedBrowserSandboxRunner({
790
+ id: opts.id,
791
+ image: opts.image,
792
+ timeoutMs: opts.timeoutMs,
793
+ vcpus: opts.vcpus,
794
+ env: opts.env
795
+ });
796
+ const browser = createBrowserClient(runner, {
797
+ session: opts.session ?? runner.session,
798
+ args: opts.args,
799
+ proxy: opts.proxy,
800
+ closeSession: false,
801
+ ownsRunner: true
802
+ });
803
+ return {
804
+ ...browser,
805
+ id: opts.id,
806
+ on (event, listener) {
807
+ return runner.on(event, listener);
808
+ },
809
+ destroy () {
810
+ return browser.close();
811
+ }
812
+ };
813
+ }
593
814
 
594
815
  // Public library surface: create an agent browser in a Vercel Sandbox and
595
816
  // drive it with the agent-browser CLI.
596
817
  /** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */ const AgentBrowser = {
597
- create: createAgentBrowser
818
+ create: createAgentBrowser,
819
+ session: createAgentBrowserSession
598
820
  };
599
821
 
600
- export { AgentBrowser, createAgentBrowser, createBrowserClient };
822
+ export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-agent-browser",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Run agent-browser in a Vercel Sandbox created from a browser-ready container image",
5
5
  "license": "MIT",
6
6
  "repository": {