@reclaimprotocol/agent 0.1.24 → 0.1.26

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.
Files changed (55) hide show
  1. package/README.md +5 -2
  2. package/lib/cdp/claims.d.ts +21 -0
  3. package/lib/cdp/claims.js +129 -0
  4. package/lib/cdp/client.d.ts +0 -10
  5. package/lib/cdp/container.d.ts +85 -0
  6. package/lib/cdp/container.js +481 -0
  7. package/lib/live/tunnel.d.ts +6 -3
  8. package/lib/live/tunnel.js +57 -17
  9. package/lib/mcp/instructions.js +2 -1
  10. package/lib/mcp/start.js +58 -3
  11. package/lib/mcp/tools/agent/attach.d.ts +22 -2
  12. package/lib/mcp/tools/agent/attach.js +197 -55
  13. package/lib/mcp/tools/agent/build.d.ts +5 -0
  14. package/lib/mcp/tools/agent/build.js +9 -2
  15. package/lib/mcp/tools/agent/index.d.ts +1 -2
  16. package/lib/mcp/tools/agent/index.js +1 -1
  17. package/lib/mcp/tools/agent/live-view.d.ts +3 -7
  18. package/lib/mcp/tools/agent/live-view.js +74 -81
  19. package/lib/mcp/tools/agent/session.d.ts +14 -0
  20. package/lib/mcp/tools/agent/session.js +14 -8
  21. package/lib/old/client.d.ts +83 -0
  22. package/lib/old/client.js +46 -0
  23. package/lib/old/tools/build.js +2 -0
  24. package/lib/old/tools/session-logs.d.ts +3 -0
  25. package/lib/old/tools/session-logs.js +310 -0
  26. package/package.json +2 -2
  27. package/skill/SKILL.common.md +115 -38
  28. package/skill/SKILL.old.md +28 -2
  29. package/skill/builder-mode.md +9 -7
  30. package/skill/interception.md +3 -2
  31. package/skill/old-mode.md +14 -10
  32. package/src/cdp/claims.ts +153 -0
  33. package/src/cdp/client.ts +0 -8
  34. package/src/cdp/container.ts +593 -0
  35. package/src/live/tunnel.ts +71 -20
  36. package/src/mcp/instructions.ts +2 -1
  37. package/src/mcp/start.ts +65 -2
  38. package/src/mcp/tools/agent/attach.ts +229 -60
  39. package/src/mcp/tools/agent/build.ts +10 -2
  40. package/src/mcp/tools/agent/index.ts +6 -2
  41. package/src/mcp/tools/agent/live-view.ts +77 -93
  42. package/src/mcp/tools/agent/session.ts +14 -8
  43. package/src/old/client.ts +128 -0
  44. package/src/old/tools/build.ts +2 -0
  45. package/src/old/tools/session-logs.ts +395 -0
  46. package/lib/cdp/browser-proxy.d.ts +0 -78
  47. package/lib/cdp/browser-proxy.js +0 -186
  48. package/lib/live/page.d.ts +0 -20
  49. package/lib/live/page.js +0 -134
  50. package/lib/live/viewer.d.ts +0 -28
  51. package/lib/live/viewer.js +0 -232
  52. package/lib/mcp/tools/agent/proxy.d.ts +0 -3
  53. package/lib/mcp/tools/agent/proxy.js +0 -199
  54. package/src/live/page.ts +0 -145
  55. package/src/live/viewer.ts +0 -293
@@ -6,6 +6,11 @@ import {
6
6
  connectCdp,
7
7
  connectCdpUrl,
8
8
  } from '../../../cdp/client.ts'
9
+ import {
10
+ dockerAvailable,
11
+ dockerMissingMessage,
12
+ startContainer,
13
+ } from '../../../cdp/container.ts'
9
14
  import { suggestCountryFromIp } from '../../../cdp/geo.ts'
10
15
  import {
11
16
  defaultProfileDir,
@@ -16,7 +21,22 @@ import type { ConfigStore } from '../../../config/store.ts'
16
21
  import { defaultConfigPath } from '../../../config/store.ts'
17
22
  import { defineTool, type RegisteredTool } from '../../server.ts'
18
23
 
19
- export type BrowserMode = 'dedicated' | 'attach' | 'builder' | 'custom'
24
+ export type BrowserMode =
25
+ | 'dedicated'
26
+ | 'attach'
27
+ | 'builder'
28
+ | 'custom'
29
+ | 'container'
30
+
31
+ /**
32
+ * Modes with a live view that `share_browser_view` can put behind a public URL.
33
+ *
34
+ * Only `container`: it runs the Reclaim browser runtime, whose viewer already
35
+ * serves on loopback, so sharing is a tunnel in front of it. `builder` gives a
36
+ * hosted `liveViewUrl` instead, and a plain local browser has nothing to
37
+ * show — one on this machine is watched by looking at it.
38
+ */
39
+ export const SHAREABLE_MODES = new Set<BrowserMode>(['container'])
20
40
 
21
41
  export interface AttachState {
22
42
  handles: CdpHandles
@@ -32,6 +52,13 @@ export interface AttachState {
32
52
  apiUrl?: string
33
53
  /** The exit country this remote browser was allocated with, if any. */
34
54
  countryCode?: string
55
+ /** Docker's name for a `container`-mode browser, for diagnostics. */
56
+ containerName?: string
57
+ /** Loopback port the container's own live viewer serves on, which is what
58
+ * `share_browser_view` tunnels. Never the CDP port. */
59
+ viewerPort?: number
60
+ /** Whether the runtime image had to be downloaded on this attach. */
61
+ imagePulled?: boolean
35
62
  disposeRemote?: () => Promise<void>
36
63
  }
37
64
 
@@ -53,6 +80,8 @@ interface AttachArgs {
53
80
  /** Builder mode: allocate without geo-routing, skipping the country
54
81
  * prompt. Geolocation is optional — this is how the caller says so. */
55
82
  skipCountry?: boolean
83
+ /** Container mode: loopback port for the live viewer. */
84
+ viewerPort?: number
56
85
  }
57
86
 
58
87
  /**
@@ -65,115 +94,174 @@ interface AttachArgs {
65
94
  * reads in the description but that disagrees with what the tool returns is
66
95
  * worse than either alone.
67
96
  *
68
- * `liveView` is here for the same reason. How a developer watches the browser
69
- * differs per mode — Popcorn hands back a hosted URL, a local Chrome needs
70
- * `share_browser_view` to tunnel one — and nobody discovers that by guessing.
71
- * Advertising it alongside the mode is the only place it lands BEFORE the
72
- * choice is made.
97
+ * Ordered and labelled BY USE CASE, because that is the question a developer
98
+ * can actually answer. "Which CDP transport?" is not; "does anyone else need to
99
+ * get into this browser?" is, and it decides two of the four. `useCase` is the
100
+ * grouping key, so the rendered list reads as two pairs rather than four
101
+ * unrelated transports.
73
102
  */
74
103
  const BROWSER_OPTIONS = [
75
104
  {
76
105
  mode: 'dedicated',
106
+ useCase: 'Working on your own machine',
77
107
  label: 'Local Chrome, launched for you',
78
108
  detail:
79
109
  'Launches Chrome with a dedicated profile under '
80
- + '~/.reclaim/chrome-profile. Easiest local option; nothing to set up.',
110
+ + '~/.reclaim/chrome-profile. Nothing to set up, and the profile '
111
+ + 'persists, so a site you sign in to stays signed in. The easiest '
112
+ + 'choice when you are doing the work yourself.',
81
113
  requires: [],
82
- liveView:
83
- 'On this machine only, until you share it: share_browser_view puts the '
84
- + 'tab behind a public URL (needs cloudflared) so someone elsewhere can '
85
- + 'watch, and by default click and type.',
114
+ liveView: 'None, and none needed — the browser is on your screen.',
86
115
  },
87
116
  {
88
117
  mode: 'attach',
89
- label: 'Local Chrome the developer is already running',
118
+ useCase: 'Working on your own machine',
119
+ label: 'The Chrome you already have open',
90
120
  detail:
91
- 'Connects to an existing Chrome debugging port. That Chrome must have '
92
- + 'been started with BOTH --remote-debugging-port AND a non-default '
93
- + '--user-data-dir: since Chrome 136 the debug flag is silently ignored '
94
- + 'on the default profile.',
121
+ 'Connects to a Chrome you started yourself, keeping the tabs, '
122
+ + 'extensions and logins already in it. That Chrome must have been '
123
+ + 'started with BOTH --remote-debugging-port AND a non-default '
124
+ + '--user-data-dir: since Chrome 136 the debug flag is silently '
125
+ + 'ignored on the default profile.',
95
126
  requires: ['port (default 9222)'],
127
+ liveView: 'None, and none needed — the browser is on your screen.',
128
+ },
129
+ {
130
+ mode: 'container',
131
+ useCase: 'Someone else has to sign in, or you need a phone',
132
+ label: 'Reclaim runtime in Docker — free, runs on your machine',
133
+ detail:
134
+ 'Runs the Reclaim browser runtime (the same image the remote browser '
135
+ + 'uses) as a container on this machine, and gives you a link you can '
136
+ + 'send to whoever holds the account. They open it in any browser, '
137
+ + 'including a phone, and can see and click in it. Free — it is your '
138
+ + 'Docker and your bandwidth. Costs a 432 MB first-run download and '
139
+ + 'about 2 GB of RAM. The image is amd64, so Apple Silicon runs it '
140
+ + 'translated. OrbStack handles that itself; Docker Desktop needs '
141
+ + 'Rosetta enabled.',
142
+ requires: ['Docker running'],
96
143
  liveView:
97
- 'On this machine only, until you share it: share_browser_view puts the '
98
- + 'tab behind a public URL (needs cloudflared) so someone elsewhere can '
99
- + 'watch, and by default click and type.',
144
+ 'Built in. This call returns a local `viewerUrl`, and '
145
+ + 'share_browser_view turns that into a public link for the person '
146
+ + 'helping you.',
100
147
  },
101
148
  {
102
149
  mode: 'builder',
103
- label: 'Remote Builder browser (Popcorn), with a shareable live view',
150
+ useCase: 'Someone else has to sign in, or you need a phone',
151
+ label: 'Remote browser, hosted by Reclaim — CHARGEABLE',
104
152
  detail:
105
- 'Quota-accounted remote browser; returns a liveViewUrl the developer '
106
- + 'can open or share. The only way to browse a site from a SPECIFIC '
107
- + 'COUNTRY — its whole egress is routed there. Geolocation is '
108
- + 'optional: '
109
- + 'omit countryCode to be offered a suggestion, or pass skipCountry '
110
- + 'to allocate without geo-routing.',
153
+ 'A browser allocated in Reclaim\'s cloud, billed against the '
154
+ + 'organization\'s quota. Nothing to install, and the shareable link '
155
+ + 'comes back immediately. It is also the only way to browse a site '
156
+ + 'from a SPECIFIC COUNTRY — its whole egress is routed there. '
157
+ + 'Geolocation is optional: omit countryCode to be offered a '
158
+ + 'suggestion, or pass skipCountry to allocate without geo-routing.',
111
159
  requires: [
112
160
  'Builder authentication',
113
161
  'orgId (auto-resolved when you have only one)',
162
+ 'quota — this one costs money',
114
163
  ],
115
164
  liveView:
116
- 'Included — this call returns a hosted liveViewUrl straight from '
117
- + 'Popcorn. Nothing to start, and share_browser_view does not apply.',
165
+ 'Included. This call returns a hosted liveViewUrl to pass on; '
166
+ + 'share_browser_view does not apply.',
118
167
  },
119
168
  {
120
169
  mode: 'custom',
121
- label: 'A CDP endpoint the developer supplies',
170
+ useCase: 'Advanced',
171
+ label: 'A CDP endpoint you supply',
122
172
  detail:
123
- 'Connects to any ws:// or wss:// CDP websocket. The URL is treated as '
124
- + 'a credential — it is never logged, persisted, or returned.',
173
+ 'Connects to any ws:// or wss:// CDP websocket — a browser you run '
174
+ + 'elsewhere, or another tool\'s. The URL is treated as a credential: '
175
+ + 'never logged, persisted, or returned. Only pick this if you were '
176
+ + 'given an endpoint and know why.',
125
177
  requires: ['cdpUrl'],
126
178
  liveView:
127
- 'Shareable on request: share_browser_view puts the tab behind a public '
128
- + 'URL (needs cloudflared). The developer\'s own cdpUrl is never '
129
- + 'exposed by it.',
179
+ 'None, unless you pass your own liveViewUrl alongside the cdpUrl.',
130
180
  },
131
181
  ] as const
132
182
 
133
- /** Flatten {@link BROWSER_OPTIONS} into the tool description. */
183
+ /**
184
+ * The options as prose, grouped by use case.
185
+ *
186
+ * The grouping is the useful part: it turns "pick one of five transports" into
187
+ * "answer one question, then pick one of two".
188
+ */
134
189
  function renderBrowserOptions(): string {
135
- return BROWSER_OPTIONS
136
- .map((option) => {
137
- const needs = option.requires.length
138
- ? ` Needs: ${option.requires.join('; ')}.`
139
- : ''
140
- return `(${option.mode}) ${option.label}. ${option.detail}${needs}`
141
- + ` Live view: ${option.liveView}`
142
- })
143
- .join(' ')
190
+ const seen = new Set<string>()
191
+ const lines: string[] = []
192
+ for(const option of BROWSER_OPTIONS) {
193
+ if(!seen.has(option.useCase)) {
194
+ seen.add(option.useCase)
195
+ lines.push(`— ${option.useCase.toUpperCase()} —`)
196
+ }
197
+
198
+ const needs = option.requires.length
199
+ ? ` Needs: ${option.requires.join('; ')}.`
200
+ : ''
201
+ lines.push(
202
+ `(${option.mode}) ${option.label}. ${option.detail}${needs}`
203
+ + ` Live view: ${option.liveView}`,
204
+ )
205
+ }
206
+
207
+ return lines.join(' ')
144
208
  }
145
209
 
146
210
  interface AttachDependencies {
147
211
  connectLocal: typeof connectCdp
148
212
  connectRemote: typeof connectCdpUrl
213
+ /** Injected so container mode is testable without Docker installed. */
214
+ dockerAvailable: typeof dockerAvailable
215
+ startContainer: typeof startContainer
149
216
  }
150
217
 
151
218
  const DEFAULT_DEPENDENCIES: AttachDependencies = {
152
219
  connectLocal: connectCdp,
153
220
  connectRemote: connectCdpUrl,
221
+ dockerAvailable,
222
+ startContainer,
154
223
  }
155
224
 
156
225
  export function attachTool(
157
226
  state: AttachRef,
158
227
  builderClient?: ReclaimClient,
159
228
  beforeReplace?: () => Promise<void>,
160
- dependencies: AttachDependencies = DEFAULT_DEPENDENCIES,
229
+ // Partial so a caller can stub one thing without restating the rest — the
230
+ // tests only ever care about a couple of these at a time.
231
+ dependencies: Partial<AttachDependencies> = {},
161
232
  ): RegisteredTool {
233
+ const deps: AttachDependencies = {
234
+ ...DEFAULT_DEPENDENCIES, ...dependencies,
235
+ }
162
236
  return defineTool<AttachArgs>(
163
237
  {
164
238
  name: 'attach_browser',
165
239
  description:
166
- 'Connect the provider-authoring tools to a browser. ASK THE '
167
- + 'DEVELOPER which of the four options below they want, then call '
168
- + 'this ONCE with their chosen `mode` — never choose for them, and '
169
- + 'never assume a previous session\'s choice still applies. You do '
170
- + 'not need a discovery call: the options are listed here. '
171
- + '(Calling with no `mode` returns the same list as structured '
172
- + '`options`, if you\'d rather relay it verbatim.) '
173
- + 'When you put the choice to them, INCLUDE each option\'s live-view '
174
- + 'line: how they watch the browser differs per mode, and nobody '
175
- + 'discovers share_browser_view on their own. '
240
+ 'Connect the provider-authoring tools to a browser. Never choose '
241
+ + 'for them, never assume a previous session\'s choice still '
242
+ + 'applies, and call this ONCE with the `mode` they pick. '
243
+ + 'ASK THIS FIRST, before listing anything: "Will you be doing this '
244
+ + 'yourself, or does someone else need to sign in — for example the '
245
+ + 'person whose account it is?" That answer decides half the list, '
246
+ + 'and a developer can answer it without knowing what CDP is. '
247
+ + 'THEMSELVES → offer `dedicated` or `attach`. SOMEONE ELSE, or the '
248
+ + 'site only works on a phone → offer `container` or `builder`, '
249
+ + 'which are the only two that produce a link another person can '
250
+ + 'open. Say which of those two costs money: `container` is free '
251
+ + 'but needs Docker, `builder` needs no install but is billed '
252
+ + 'against the org\'s quota. '
253
+ + 'Then relay the matching options with their live-view lines. You '
254
+ + 'do not need a discovery call — the full list is here, and calling '
255
+ + 'with no `mode` returns it as structured `options` if you would '
256
+ + 'rather read it back verbatim. '
176
257
  + 'OPTIONS — ' + renderBrowserOptions() + ' '
258
+ + 'A `container` browser returns a local `viewerUrl`; relay it. When '
259
+ + 'someone remote needs in, call share_browser_view and relay the '
260
+ + 'FULL public url verbatim, on its own line. `builder` returns a '
261
+ + 'hosted `liveViewUrl` — relay that the same way. `dedicated`, '
262
+ + '`attach` and `custom` cannot be shared at all: if they ask for a '
263
+ + 'link after picking one, say so and offer to re-attach with '
264
+ + '`container`. '
177
265
  + 'Builder and custom CDP credentials are held in memory and are '
178
266
  + 'never returned. '
179
267
  + 'More: how_it_works({ topic: "browser" }).',
@@ -182,7 +270,9 @@ export function attachTool(
182
270
  properties: {
183
271
  mode: {
184
272
  type: 'string',
185
- enum: ['dedicated', 'attach', 'builder', 'custom'],
273
+ enum: [
274
+ 'dedicated', 'attach', 'container', 'builder', 'custom',
275
+ ],
186
276
  },
187
277
  attachMode: {
188
278
  type: 'string',
@@ -193,6 +283,15 @@ export function attachTool(
193
283
  orgId: { type: 'string', format: 'uuid' },
194
284
  cdpUrl: { type: 'string', maxLength: 4000 },
195
285
  liveViewUrl: { type: 'string', format: 'uri', maxLength: 4000 },
286
+ viewerPort: {
287
+ type: 'number',
288
+ minimum: 1024,
289
+ maximum: 60000,
290
+ description:
291
+ '`container` mode only. Loopback port for the built-in live '
292
+ + 'viewer. Change it only to avoid a clash; the CDP port and '
293
+ + 'the container name are derived from it.',
294
+ },
196
295
  countryCode: {
197
296
  type: 'string',
198
297
  pattern: '^[A-Za-z]{2}$',
@@ -225,9 +324,14 @@ export function attachTool(
225
324
  return {
226
325
  needsChoice: true,
227
326
  message:
228
- 'Ask the developer which browser to use, then call '
229
- + 'attach_browser again with their chosen `mode`. Present all '
230
- + 'the options below — do not pick one for them.',
327
+ 'Ask FIRST whether they will be doing this themselves or '
328
+ + 'someone else has to sign in — that answer decides half the '
329
+ + 'list. Themselves: `dedicated` or `attach`. Someone else, or '
330
+ + 'a phone-only site: `container` (free, needs Docker) or '
331
+ + '`builder` (no install, chargeable) — the only two that give '
332
+ + 'you a link to send. Then present the matching options and '
333
+ + 'call attach_browser again with the `mode` they pick. Do not '
334
+ + 'pick one for them.',
231
335
  options: BROWSER_OPTIONS,
232
336
  ...(persisted?.attachMode
233
337
  ? { previouslyUsed: persisted.attachMode }
@@ -314,7 +418,7 @@ export function attachTool(
314
418
  }
315
419
 
316
420
  const next = await connect(
317
- mode, { ...parsed, orgId }, persisted, builderClient, dependencies,
421
+ mode, { ...parsed, orgId }, persisted, builderClient, deps,
318
422
  )
319
423
  try {
320
424
  await beforeReplace?.()
@@ -331,6 +435,11 @@ export function attachTool(
331
435
  })
332
436
  }
333
437
 
438
+ // How the developer watches this browser differs per mode, and none of
439
+ // it is guessable, so every mode says its own piece here.
440
+ const localView = mode === 'container'
441
+ const hostedView = Boolean(next.liveViewUrl) && !localView
442
+ const canShare = SHAREABLE_MODES.has(mode)
334
443
  return {
335
444
  attached: true,
336
445
  mode,
@@ -340,6 +449,37 @@ export function attachTool(
340
449
  : {}),
341
450
  ...(next.liveViewUrl ? { liveViewUrl: next.liveViewUrl } : {}),
342
451
  ...(next.countryCode ? { countryCode: next.countryCode } : {}),
452
+ ...(next.containerName ? { containerName: next.containerName } : {}),
453
+ ...(canShare ? { liveViewAvailable: true } : {}),
454
+ _notes: [
455
+ ...(next.imagePulled
456
+ ? ['The browser runtime image was downloaded just now (about '
457
+ + '432 MB, about 1.3 GB on disk). Mention it — later attaches '
458
+ + 'reuse it. RECLAIM_BROWSER_RUNTIME_IMAGE points at a '
459
+ + 'different or locally built image.']
460
+ : []),
461
+ ...(localView
462
+ ? ['This browser has its own live view, already running on this '
463
+ + 'machine: relay `liveViewUrl` to the developer verbatim, on '
464
+ + 'its own line, so they can watch and drive it. It is '
465
+ + 'loopback-only, so only they can reach it. If someone ELSE '
466
+ + 'needs to — a login only they can complete — call '
467
+ + 'share_browser_view to put that same viewer behind a public '
468
+ + 'URL.']
469
+ : []),
470
+ ...(hostedView
471
+ ? ['This browser has a hosted live view already. Relay '
472
+ + '`liveViewUrl` to the developer verbatim, on its own line, '
473
+ + 'so they can watch the run — share_browser_view does not '
474
+ + 'apply here.']
475
+ : []),
476
+ ...(!localView && !hostedView
477
+ ? ['This browser has no live view: it runs on the developer\'s '
478
+ + 'own machine, so they watch it by looking at it. If someone '
479
+ + 'ELSE needs to see or drive it, say that `container` mode '
480
+ + 'runs a browser with a shareable live view built in.']
481
+ : []),
482
+ ],
343
483
  }
344
484
  },
345
485
  )
@@ -416,6 +556,35 @@ async function connect(
416
556
  }
417
557
  }
418
558
 
559
+ if(mode === 'container') {
560
+ assert(await dependencies.dockerAvailable(),
561
+ badRequest(dockerMissingMessage()))
562
+ // Ports are allocated per session unless the caller names one. A fixed
563
+ // default would give two concurrent sessions the SAME container — the
564
+ // name derives from the viewer port — so session two would silently
565
+ // drive session one's browser.
566
+ const container = await dependencies.startContainer({
567
+ ...(parsed.viewerPort ? { viewerPort: parsed.viewerPort } : {}),
568
+ image: process.env.RECLAIM_BROWSER_RUNTIME_IMAGE?.trim() || undefined,
569
+ })
570
+ try {
571
+ return {
572
+ handles: await dependencies.connectRemote(container.cdpUrl),
573
+ mode,
574
+ liveViewUrl: container.viewerUrl,
575
+ containerName: container.name,
576
+ viewerPort: container.viewerPort,
577
+ imagePulled: container.pulled,
578
+ disposeRemote: container.stop,
579
+ }
580
+ } catch(err) {
581
+ // Never leave a 1.3 GB container running for a browser we could not
582
+ // talk to.
583
+ await container.stop().catch(() => {})
584
+ throw err
585
+ }
586
+ }
587
+
419
588
  const port = parsed.port ?? persisted?.port ?? 9222
420
589
  if(mode === 'dedicated') {
421
590
  try {
@@ -22,7 +22,7 @@ import { howItWorksTool } from './how-it-works.ts'
22
22
  import { type LiveShareRef, liveViewTools } from './live-view.ts'
23
23
  import { podProofTools } from './pod-proof.ts'
24
24
  import { createVersionFromCaptureTool } from './publish.ts'
25
- import { sessionTools } from './session.ts'
25
+ import { resetAll, sessionTools } from './session.ts'
26
26
  import { sharedAuthoringTools } from './shared.ts'
27
27
 
28
28
  export interface AgentState {
@@ -76,7 +76,7 @@ export function buildAgentTools(
76
76
  }
77
77
 
78
78
  // Local-CDP only: a builder-mode browser already has a hosted live view.
79
- for(const t of liveViewTools(state.attach, state.captures, state.liveShare)) {
79
+ for(const t of liveViewTools(state.attach, state.liveShare)) {
80
80
  tools.push(t)
81
81
  }
82
82
 
@@ -98,3 +98,11 @@ export function buildAgentTools(
98
98
 
99
99
  return tools
100
100
  }
101
+
102
+ /**
103
+ * Tear down an agent state on the way out. Safe to call more than once, and
104
+ * safe when nothing was ever attached.
105
+ */
106
+ export async function disposeAgentState(state: AgentState): Promise<void> {
107
+ await resetAll(state.attach, state.captures, state.liveShare)
108
+ }
@@ -1,4 +1,8 @@
1
1
  // Barrel for the agent tool group (Chrome CDP capture, provider synthesis,
2
2
  // replay, zkTLS proof). Import from here, not the individual files.
3
- export { buildAgentState, buildAgentTools } from './build.ts'
4
- export type { AgentState } from './build.ts'
3
+ export {
4
+ type AgentState,
5
+ buildAgentState,
6
+ buildAgentTools,
7
+ disposeAgentState,
8
+ } from './build.ts'