@reclaimprotocol/agent 0.1.24 → 0.1.25

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 (46) hide show
  1. package/README.md +3 -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/package.json +2 -2
  22. package/skill/SKILL.common.md +115 -38
  23. package/skill/builder-mode.md +9 -7
  24. package/skill/interception.md +3 -2
  25. package/skill/old-mode.md +10 -8
  26. package/src/cdp/claims.ts +153 -0
  27. package/src/cdp/client.ts +0 -8
  28. package/src/cdp/container.ts +593 -0
  29. package/src/live/tunnel.ts +71 -20
  30. package/src/mcp/instructions.ts +2 -1
  31. package/src/mcp/start.ts +65 -2
  32. package/src/mcp/tools/agent/attach.ts +229 -60
  33. package/src/mcp/tools/agent/build.ts +10 -2
  34. package/src/mcp/tools/agent/index.ts +6 -2
  35. package/src/mcp/tools/agent/live-view.ts +77 -93
  36. package/src/mcp/tools/agent/session.ts +14 -8
  37. package/lib/cdp/browser-proxy.d.ts +0 -78
  38. package/lib/cdp/browser-proxy.js +0 -186
  39. package/lib/live/page.d.ts +0 -20
  40. package/lib/live/page.js +0 -134
  41. package/lib/live/viewer.d.ts +0 -28
  42. package/lib/live/viewer.js +0 -232
  43. package/lib/mcp/tools/agent/proxy.d.ts +0 -3
  44. package/lib/mcp/tools/agent/proxy.js +0 -199
  45. package/src/live/page.ts +0 -145
  46. package/src/live/viewer.ts +0 -293
package/lib/mcp/start.js CHANGED
@@ -3,6 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { ReclaimClient } from '@reclaimprotocol/client';
4
4
  import { ConfigStore } from "../config/store.js";
5
5
  import { agentDisabled, apiToken, apiUrl, oldApiUrl, oldLogsUrl, resolveOldMode, sdkApiUrl, } from "../consts.js";
6
+ import { LOGGER } from "../logger.js";
6
7
  import { ReclaimOldClient } from "../old/client.js";
7
8
  import { resolveInitialIdentity } from "../old/identity.js";
8
9
  import { OLD_MODE_INSTRUCTIONS } from "../old/instructions.js";
@@ -10,7 +11,7 @@ import { ReclaimSdkClient } from "../old/sdk-client.js";
10
11
  import { buildOldBackendTools } from "../old/tools/build.js";
11
12
  import { NAME, VERSION } from "../pkg.js";
12
13
  import { registerTools } from "./api/tools.js";
13
- import { buildAgentState, buildAgentTools, buildBuilderAuthenticationTools, buildBuilderOrganizationTools, buildCredentialTools, buildLocalCredentialTools, buildModeTools, buildVerificationTools, readSession, } from "./tools/index.js";
14
+ import { buildAgentState, buildAgentTools, buildBuilderAuthenticationTools, buildBuilderOrganizationTools, buildCredentialTools, buildLocalCredentialTools, buildModeTools, buildVerificationTools, disposeAgentState, readSession, } from "./tools/index.js";
14
15
  import { BUILDER_MODE_INSTRUCTIONS } from "./instructions.js";
15
16
  import { registerToolList } from "./server.js";
16
17
  export async function startMcpServer() {
@@ -28,6 +29,8 @@ export async function startMcpServer() {
28
29
  // deployments — set RECLAIM_AGENT_DISABLED=1 to register only the API
29
30
  // surface.
30
31
  const agentToolsEnabled = !agentDisabled();
32
+ /** The agent state this server registered, for shutdown to release. */
33
+ let agentState;
31
34
  const builderClient = new ReclaimClient({
32
35
  baseUrl: apiUrl(),
33
36
  // Builder access is orthogonal to publishing mode. A legacy-devtools
@@ -52,7 +55,7 @@ export async function startMcpServer() {
52
55
  // Shared with buildAgentTools below so the old-mode publish tool can see
53
56
  // the live attach state (for example, to flag a still-open browser
54
57
  // session).
55
- const agentState = buildAgentState();
58
+ agentState = buildAgentState();
56
59
  registerToolList(server, buildBuilderAuthenticationTools(builderClient));
57
60
  registerToolList(server, buildBuilderOrganizationTools(builderClient));
58
61
  registerToolList(server, buildLocalCredentialTools());
@@ -79,9 +82,61 @@ export async function startMcpServer() {
79
82
  // for every MCP-tagged operation.
80
83
  registerTools(server, client);
81
84
  if (agentToolsEnabled) {
82
- registerToolList(server, buildAgentTools(client));
85
+ agentState = buildAgentState();
86
+ registerToolList(server, buildAgentTools(client, client, agentState));
83
87
  }
84
88
  }
89
+ // Anything the session holds has to be released when the session ends, and
90
+ // the session can end without a single tool call: the developer closes their
91
+ // agent, the client kills the server, stdin drops. Nothing ran here before,
92
+ // which left a container up and — worse — a public tunnel still pointing at a
93
+ // live browser after the session that made it was gone.
94
+ if (agentToolsEnabled) {
95
+ installShutdownHooks(() => agentState);
96
+ }
85
97
  const transport = new StdioServerTransport();
86
98
  await server.connect(transport);
87
99
  }
100
+ /** How long teardown gets before the process leaves anyway. */
101
+ const SHUTDOWN_GRACE_MS = 10000;
102
+ /**
103
+ * Release the browser on every way out we can observe.
104
+ *
105
+ * `SIGTERM`/`SIGINT` is how a client stops us; stdin ending is how a stdio
106
+ * transport says the other side is gone. A `SIGKILL` cannot be caught at all,
107
+ * which is why `reapOrphanedContainers` exists as the backstop.
108
+ */
109
+ function installShutdownHooks(state) {
110
+ let shuttingDown = false;
111
+ const shutdown = (reason) => {
112
+ if (shuttingDown) {
113
+ return;
114
+ }
115
+ shuttingDown = true;
116
+ const current = state();
117
+ if (!current) {
118
+ process.exit(0);
119
+ }
120
+ LOGGER.info({ reason }, 'releasing the browser before exit');
121
+ // Bounded: a wedged `docker stop` must not keep the process alive, but
122
+ // nor should we skip cleanup that would have finished in a second.
123
+ const timer = setTimeout(() => {
124
+ LOGGER.warn({ reason }, 'teardown timed out; exiting anyway');
125
+ process.exit(0);
126
+ }, SHUTDOWN_GRACE_MS);
127
+ timer.unref();
128
+ void disposeAgentState(current)
129
+ .catch((err) => {
130
+ LOGGER.warn({ err }, 'teardown failed');
131
+ })
132
+ .finally(() => {
133
+ clearTimeout(timer);
134
+ process.exit(0);
135
+ });
136
+ };
137
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
138
+ process.once('SIGINT', () => shutdown('SIGINT'));
139
+ // The transport's own end of the conversation.
140
+ process.stdin.once('end', () => shutdown('stdin ended'));
141
+ process.stdin.once('close', () => shutdown('stdin closed'));
142
+ }
@@ -1,8 +1,18 @@
1
1
  import type { ReclaimClient } from '@reclaimprotocol/client';
2
2
  import { type CdpHandles, connectCdp, connectCdpUrl } from '../../../cdp/client.ts';
3
+ import { dockerAvailable, startContainer } from '../../../cdp/container.ts';
3
4
  import type { ConfigStore } from '../../../config/store.ts';
4
5
  import { type RegisteredTool } from '../../server.ts';
5
- export type BrowserMode = 'dedicated' | 'attach' | 'builder' | 'custom';
6
+ export type BrowserMode = 'dedicated' | 'attach' | 'builder' | 'custom' | 'container';
7
+ /**
8
+ * Modes with a live view that `share_browser_view` can put behind a public URL.
9
+ *
10
+ * Only `container`: it runs the Reclaim browser runtime, whose viewer already
11
+ * serves on loopback, so sharing is a tunnel in front of it. `builder` gives a
12
+ * hosted `liveViewUrl` instead, and a plain local browser has nothing to
13
+ * show — one on this machine is watched by looking at it.
14
+ */
15
+ export declare const SHAREABLE_MODES: Set<BrowserMode>;
6
16
  export interface AttachState {
7
17
  handles: CdpHandles;
8
18
  mode: BrowserMode;
@@ -17,6 +27,13 @@ export interface AttachState {
17
27
  apiUrl?: string;
18
28
  /** The exit country this remote browser was allocated with, if any. */
19
29
  countryCode?: string;
30
+ /** Docker's name for a `container`-mode browser, for diagnostics. */
31
+ containerName?: string;
32
+ /** Loopback port the container's own live viewer serves on, which is what
33
+ * `share_browser_view` tunnels. Never the CDP port. */
34
+ viewerPort?: number;
35
+ /** Whether the runtime image had to be downloaded on this attach. */
36
+ imagePulled?: boolean;
20
37
  disposeRemote?: () => Promise<void>;
21
38
  }
22
39
  export interface AttachRef {
@@ -26,7 +43,10 @@ export interface AttachRef {
26
43
  interface AttachDependencies {
27
44
  connectLocal: typeof connectCdp;
28
45
  connectRemote: typeof connectCdpUrl;
46
+ /** Injected so container mode is testable without Docker installed. */
47
+ dockerAvailable: typeof dockerAvailable;
48
+ startContainer: typeof startContainer;
29
49
  }
30
- export declare function attachTool(state: AttachRef, builderClient?: ReclaimClient, beforeReplace?: () => Promise<void>, dependencies?: AttachDependencies): RegisteredTool;
50
+ export declare function attachTool(state: AttachRef, builderClient?: ReclaimClient, beforeReplace?: () => Promise<void>, dependencies?: Partial<AttachDependencies>): RegisteredTool;
31
51
  export declare function disposeAttached(state: AttachRef): Promise<void>;
32
52
  export {};
@@ -1,10 +1,20 @@
1
1
  import { badRequest } from '@hapi/boom';
2
2
  import assert from 'node:assert';
3
3
  import { connectCdp, connectCdpUrl, } from "../../../cdp/client.js";
4
+ import { dockerAvailable, dockerMissingMessage, startContainer, } from "../../../cdp/container.js";
4
5
  import { suggestCountryFromIp } from "../../../cdp/geo.js";
5
6
  import { defaultProfileDir, launchChrome, waitForCdpReady, } from "../../../cdp/launcher.js";
6
7
  import { defaultConfigPath } from "../../../config/store.js";
7
8
  import { defineTool } from "../../server.js";
9
+ /**
10
+ * Modes with a live view that `share_browser_view` can put behind a public URL.
11
+ *
12
+ * Only `container`: it runs the Reclaim browser runtime, whose viewer already
13
+ * serves on loopback, so sharing is a tunnel in front of it. `builder` gives a
14
+ * hosted `liveViewUrl` instead, and a plain local browser has nothing to
15
+ * show — one on this machine is watched by looking at it.
16
+ */
17
+ export const SHAREABLE_MODES = new Set(['container']);
8
18
  /**
9
19
  * The browser sources, written once and used twice: rendered into this tool's
10
20
  * DESCRIPTION (so an agent can put the choice to the developer straight from
@@ -15,92 +25,145 @@ import { defineTool } from "../../server.js";
15
25
  * reads in the description but that disagrees with what the tool returns is
16
26
  * worse than either alone.
17
27
  *
18
- * `liveView` is here for the same reason. How a developer watches the browser
19
- * differs per mode — Popcorn hands back a hosted URL, a local Chrome needs
20
- * `share_browser_view` to tunnel one — and nobody discovers that by guessing.
21
- * Advertising it alongside the mode is the only place it lands BEFORE the
22
- * choice is made.
28
+ * Ordered and labelled BY USE CASE, because that is the question a developer
29
+ * can actually answer. "Which CDP transport?" is not; "does anyone else need to
30
+ * get into this browser?" is, and it decides two of the four. `useCase` is the
31
+ * grouping key, so the rendered list reads as two pairs rather than four
32
+ * unrelated transports.
23
33
  */
24
34
  const BROWSER_OPTIONS = [
25
35
  {
26
36
  mode: 'dedicated',
37
+ useCase: 'Working on your own machine',
27
38
  label: 'Local Chrome, launched for you',
28
39
  detail: 'Launches Chrome with a dedicated profile under '
29
- + '~/.reclaim/chrome-profile. Easiest local option; nothing to set up.',
40
+ + '~/.reclaim/chrome-profile. Nothing to set up, and the profile '
41
+ + 'persists, so a site you sign in to stays signed in. The easiest '
42
+ + 'choice when you are doing the work yourself.',
30
43
  requires: [],
31
- liveView: 'On this machine only, until you share it: share_browser_view puts the '
32
- + 'tab behind a public URL (needs cloudflared) so someone elsewhere can '
33
- + 'watch, and by default click and type.',
44
+ liveView: 'None, and none needed — the browser is on your screen.',
34
45
  },
35
46
  {
36
47
  mode: 'attach',
37
- label: 'Local Chrome the developer is already running',
38
- detail: 'Connects to an existing Chrome debugging port. That Chrome must have '
39
- + 'been started with BOTH --remote-debugging-port AND a non-default '
40
- + '--user-data-dir: since Chrome 136 the debug flag is silently ignored '
41
- + 'on the default profile.',
48
+ useCase: 'Working on your own machine',
49
+ label: 'The Chrome you already have open',
50
+ detail: 'Connects to a Chrome you started yourself, keeping the tabs, '
51
+ + 'extensions and logins already in it. That Chrome must have been '
52
+ + 'started with BOTH --remote-debugging-port AND a non-default '
53
+ + '--user-data-dir: since Chrome 136 the debug flag is silently '
54
+ + 'ignored on the default profile.',
42
55
  requires: ['port (default 9222)'],
43
- liveView: 'On this machine only, until you share it: share_browser_view puts the '
44
- + 'tab behind a public URL (needs cloudflared) so someone elsewhere can '
45
- + 'watch, and by default click and type.',
56
+ liveView: 'None, and none needed — the browser is on your screen.',
57
+ },
58
+ {
59
+ mode: 'container',
60
+ useCase: 'Someone else has to sign in, or you need a phone',
61
+ label: 'Reclaim runtime in Docker — free, runs on your machine',
62
+ detail: 'Runs the Reclaim browser runtime (the same image the remote browser '
63
+ + 'uses) as a container on this machine, and gives you a link you can '
64
+ + 'send to whoever holds the account. They open it in any browser, '
65
+ + 'including a phone, and can see and click in it. Free — it is your '
66
+ + 'Docker and your bandwidth. Costs a 432 MB first-run download and '
67
+ + 'about 2 GB of RAM. The image is amd64, so Apple Silicon runs it '
68
+ + 'translated. OrbStack handles that itself; Docker Desktop needs '
69
+ + 'Rosetta enabled.',
70
+ requires: ['Docker running'],
71
+ liveView: 'Built in. This call returns a local `viewerUrl`, and '
72
+ + 'share_browser_view turns that into a public link for the person '
73
+ + 'helping you.',
46
74
  },
47
75
  {
48
76
  mode: 'builder',
49
- label: 'Remote Builder browser (Popcorn), with a shareable live view',
50
- detail: 'Quota-accounted remote browser; returns a liveViewUrl the developer '
51
- + 'can open or share. The only way to browse a site from a SPECIFIC '
52
- + 'COUNTRY — its whole egress is routed there. Geolocation is '
53
- + 'optional: '
54
- + 'omit countryCode to be offered a suggestion, or pass skipCountry '
55
- + 'to allocate without geo-routing.',
77
+ useCase: 'Someone else has to sign in, or you need a phone',
78
+ label: 'Remote browser, hosted by Reclaim — CHARGEABLE',
79
+ detail: 'A browser allocated in Reclaim\'s cloud, billed against the '
80
+ + 'organization\'s quota. Nothing to install, and the shareable link '
81
+ + 'comes back immediately. It is also the only way to browse a site '
82
+ + 'from a SPECIFIC COUNTRY — its whole egress is routed there. '
83
+ + 'Geolocation is optional: omit countryCode to be offered a '
84
+ + 'suggestion, or pass skipCountry to allocate without geo-routing.',
56
85
  requires: [
57
86
  'Builder authentication',
58
87
  'orgId (auto-resolved when you have only one)',
88
+ 'quota — this one costs money',
59
89
  ],
60
- liveView: 'Included — this call returns a hosted liveViewUrl straight from '
61
- + 'Popcorn. Nothing to start, and share_browser_view does not apply.',
90
+ liveView: 'Included. This call returns a hosted liveViewUrl to pass on; '
91
+ + 'share_browser_view does not apply.',
62
92
  },
63
93
  {
64
94
  mode: 'custom',
65
- label: 'A CDP endpoint the developer supplies',
66
- detail: 'Connects to any ws:// or wss:// CDP websocket. The URL is treated as '
67
- + 'a credential — it is never logged, persisted, or returned.',
95
+ useCase: 'Advanced',
96
+ label: 'A CDP endpoint you supply',
97
+ detail: 'Connects to any ws:// or wss:// CDP websocket — a browser you run '
98
+ + 'elsewhere, or another tool\'s. The URL is treated as a credential: '
99
+ + 'never logged, persisted, or returned. Only pick this if you were '
100
+ + 'given an endpoint and know why.',
68
101
  requires: ['cdpUrl'],
69
- liveView: 'Shareable on request: share_browser_view puts the tab behind a public '
70
- + 'URL (needs cloudflared). The developer\'s own cdpUrl is never '
71
- + 'exposed by it.',
102
+ liveView: 'None, unless you pass your own liveViewUrl alongside the cdpUrl.',
72
103
  },
73
104
  ];
74
- /** Flatten {@link BROWSER_OPTIONS} into the tool description. */
105
+ /**
106
+ * The options as prose, grouped by use case.
107
+ *
108
+ * The grouping is the useful part: it turns "pick one of five transports" into
109
+ * "answer one question, then pick one of two".
110
+ */
75
111
  function renderBrowserOptions() {
76
- return BROWSER_OPTIONS
77
- .map((option) => {
112
+ const seen = new Set();
113
+ const lines = [];
114
+ for (const option of BROWSER_OPTIONS) {
115
+ if (!seen.has(option.useCase)) {
116
+ seen.add(option.useCase);
117
+ lines.push(`— ${option.useCase.toUpperCase()} —`);
118
+ }
78
119
  const needs = option.requires.length
79
120
  ? ` Needs: ${option.requires.join('; ')}.`
80
121
  : '';
81
- return `(${option.mode}) ${option.label}. ${option.detail}${needs}`
82
- + ` Live view: ${option.liveView}`;
83
- })
84
- .join(' ');
122
+ lines.push(`(${option.mode}) ${option.label}. ${option.detail}${needs}`
123
+ + ` Live view: ${option.liveView}`);
124
+ }
125
+ return lines.join(' ');
85
126
  }
86
127
  const DEFAULT_DEPENDENCIES = {
87
128
  connectLocal: connectCdp,
88
129
  connectRemote: connectCdpUrl,
130
+ dockerAvailable,
131
+ startContainer,
89
132
  };
90
- export function attachTool(state, builderClient, beforeReplace, dependencies = DEFAULT_DEPENDENCIES) {
133
+ export function attachTool(state, builderClient, beforeReplace,
134
+ // Partial so a caller can stub one thing without restating the rest — the
135
+ // tests only ever care about a couple of these at a time.
136
+ dependencies = {}) {
137
+ const deps = {
138
+ ...DEFAULT_DEPENDENCIES, ...dependencies,
139
+ };
91
140
  return defineTool({
92
141
  name: 'attach_browser',
93
- description: 'Connect the provider-authoring tools to a browser. ASK THE '
94
- + 'DEVELOPER which of the four options below they want, then call '
95
- + 'this ONCE with their chosen `mode` — never choose for them, and '
96
- + 'never assume a previous session\'s choice still applies. You do '
97
- + 'not need a discovery call: the options are listed here. '
98
- + '(Calling with no `mode` returns the same list as structured '
99
- + '`options`, if you\'d rather relay it verbatim.) '
100
- + 'When you put the choice to them, INCLUDE each option\'s live-view '
101
- + 'line: how they watch the browser differs per mode, and nobody '
102
- + 'discovers share_browser_view on their own. '
142
+ description: 'Connect the provider-authoring tools to a browser. Never choose '
143
+ + 'for them, never assume a previous session\'s choice still '
144
+ + 'applies, and call this ONCE with the `mode` they pick. '
145
+ + 'ASK THIS FIRST, before listing anything: "Will you be doing this '
146
+ + 'yourself, or does someone else need to sign in — for example the '
147
+ + 'person whose account it is?" That answer decides half the list, '
148
+ + 'and a developer can answer it without knowing what CDP is. '
149
+ + 'THEMSELVES → offer `dedicated` or `attach`. SOMEONE ELSE, or the '
150
+ + 'site only works on a phone → offer `container` or `builder`, '
151
+ + 'which are the only two that produce a link another person can '
152
+ + 'open. Say which of those two costs money: `container` is free '
153
+ + 'but needs Docker, `builder` needs no install but is billed '
154
+ + 'against the org\'s quota. '
155
+ + 'Then relay the matching options with their live-view lines. You '
156
+ + 'do not need a discovery call — the full list is here, and calling '
157
+ + 'with no `mode` returns it as structured `options` if you would '
158
+ + 'rather read it back verbatim. '
103
159
  + 'OPTIONS — ' + renderBrowserOptions() + ' '
160
+ + 'A `container` browser returns a local `viewerUrl`; relay it. When '
161
+ + 'someone remote needs in, call share_browser_view and relay the '
162
+ + 'FULL public url verbatim, on its own line. `builder` returns a '
163
+ + 'hosted `liveViewUrl` — relay that the same way. `dedicated`, '
164
+ + '`attach` and `custom` cannot be shared at all: if they ask for a '
165
+ + 'link after picking one, say so and offer to re-attach with '
166
+ + '`container`. '
104
167
  + 'Builder and custom CDP credentials are held in memory and are '
105
168
  + 'never returned. '
106
169
  + 'More: how_it_works({ topic: "browser" }).',
@@ -109,7 +172,9 @@ export function attachTool(state, builderClient, beforeReplace, dependencies = D
109
172
  properties: {
110
173
  mode: {
111
174
  type: 'string',
112
- enum: ['dedicated', 'attach', 'builder', 'custom'],
175
+ enum: [
176
+ 'dedicated', 'attach', 'container', 'builder', 'custom',
177
+ ],
113
178
  },
114
179
  attachMode: {
115
180
  type: 'string',
@@ -120,6 +185,14 @@ export function attachTool(state, builderClient, beforeReplace, dependencies = D
120
185
  orgId: { type: 'string', format: 'uuid' },
121
186
  cdpUrl: { type: 'string', maxLength: 4000 },
122
187
  liveViewUrl: { type: 'string', format: 'uri', maxLength: 4000 },
188
+ viewerPort: {
189
+ type: 'number',
190
+ minimum: 1024,
191
+ maximum: 60000,
192
+ description: '`container` mode only. Loopback port for the built-in live '
193
+ + 'viewer. Change it only to avoid a clash; the CDP port and '
194
+ + 'the container name are derived from it.',
195
+ },
123
196
  countryCode: {
124
197
  type: 'string',
125
198
  pattern: '^[A-Za-z]{2}$',
@@ -148,9 +221,14 @@ export function attachTool(state, builderClient, beforeReplace, dependencies = D
148
221
  if (!mode) {
149
222
  return {
150
223
  needsChoice: true,
151
- message: 'Ask the developer which browser to use, then call '
152
- + 'attach_browser again with their chosen `mode`. Present all '
153
- + 'the options below — do not pick one for them.',
224
+ message: 'Ask FIRST whether they will be doing this themselves or '
225
+ + 'someone else has to sign in — that answer decides half the '
226
+ + 'list. Themselves: `dedicated` or `attach`. Someone else, or '
227
+ + 'a phone-only site: `container` (free, needs Docker) or '
228
+ + '`builder` (no install, chargeable) — the only two that give '
229
+ + 'you a link to send. Then present the matching options and '
230
+ + 'call attach_browser again with the `mode` they pick. Do not '
231
+ + 'pick one for them.',
154
232
  options: BROWSER_OPTIONS,
155
233
  ...(persisted?.attachMode
156
234
  ? { previouslyUsed: persisted.attachMode }
@@ -219,7 +297,7 @@ export function attachTool(state, builderClient, beforeReplace, dependencies = D
219
297
  ...(suggestion.city ? { city: suggestion.city } : {}),
220
298
  };
221
299
  }
222
- const next = await connect(mode, { ...parsed, orgId }, persisted, builderClient, dependencies);
300
+ const next = await connect(mode, { ...parsed, orgId }, persisted, builderClient, deps);
223
301
  try {
224
302
  await beforeReplace?.();
225
303
  await disposeAttached(state);
@@ -234,6 +312,11 @@ export function attachTool(state, builderClient, beforeReplace, dependencies = D
234
312
  browser_agent: { attachMode: mode, port: next.port },
235
313
  });
236
314
  }
315
+ // How the developer watches this browser differs per mode, and none of
316
+ // it is guessable, so every mode says its own piece here.
317
+ const localView = mode === 'container';
318
+ const hostedView = Boolean(next.liveViewUrl) && !localView;
319
+ const canShare = SHAREABLE_MODES.has(mode);
237
320
  return {
238
321
  attached: true,
239
322
  mode,
@@ -243,6 +326,37 @@ export function attachTool(state, builderClient, beforeReplace, dependencies = D
243
326
  : {}),
244
327
  ...(next.liveViewUrl ? { liveViewUrl: next.liveViewUrl } : {}),
245
328
  ...(next.countryCode ? { countryCode: next.countryCode } : {}),
329
+ ...(next.containerName ? { containerName: next.containerName } : {}),
330
+ ...(canShare ? { liveViewAvailable: true } : {}),
331
+ _notes: [
332
+ ...(next.imagePulled
333
+ ? ['The browser runtime image was downloaded just now (about '
334
+ + '432 MB, about 1.3 GB on disk). Mention it — later attaches '
335
+ + 'reuse it. RECLAIM_BROWSER_RUNTIME_IMAGE points at a '
336
+ + 'different or locally built image.']
337
+ : []),
338
+ ...(localView
339
+ ? ['This browser has its own live view, already running on this '
340
+ + 'machine: relay `liveViewUrl` to the developer verbatim, on '
341
+ + 'its own line, so they can watch and drive it. It is '
342
+ + 'loopback-only, so only they can reach it. If someone ELSE '
343
+ + 'needs to — a login only they can complete — call '
344
+ + 'share_browser_view to put that same viewer behind a public '
345
+ + 'URL.']
346
+ : []),
347
+ ...(hostedView
348
+ ? ['This browser has a hosted live view already. Relay '
349
+ + '`liveViewUrl` to the developer verbatim, on its own line, '
350
+ + 'so they can watch the run — share_browser_view does not '
351
+ + 'apply here.']
352
+ : []),
353
+ ...(!localView && !hostedView
354
+ ? ['This browser has no live view: it runs on the developer\'s '
355
+ + 'own machine, so they watch it by looking at it. If someone '
356
+ + 'ELSE needs to see or drive it, say that `container` mode '
357
+ + 'runs a browser with a shareable live view built in.']
358
+ : []),
359
+ ],
246
360
  };
247
361
  });
248
362
  }
@@ -304,6 +418,34 @@ async function connect(mode, parsed, persisted, builderClient, dependencies) {
304
418
  ...(parsed.liveViewUrl ? { liveViewUrl: parsed.liveViewUrl } : {}),
305
419
  };
306
420
  }
421
+ if (mode === 'container') {
422
+ assert(await dependencies.dockerAvailable(), badRequest(dockerMissingMessage()));
423
+ // Ports are allocated per session unless the caller names one. A fixed
424
+ // default would give two concurrent sessions the SAME container — the
425
+ // name derives from the viewer port — so session two would silently
426
+ // drive session one's browser.
427
+ const container = await dependencies.startContainer({
428
+ ...(parsed.viewerPort ? { viewerPort: parsed.viewerPort } : {}),
429
+ image: process.env.RECLAIM_BROWSER_RUNTIME_IMAGE?.trim() || undefined,
430
+ });
431
+ try {
432
+ return {
433
+ handles: await dependencies.connectRemote(container.cdpUrl),
434
+ mode,
435
+ liveViewUrl: container.viewerUrl,
436
+ containerName: container.name,
437
+ viewerPort: container.viewerPort,
438
+ imagePulled: container.pulled,
439
+ disposeRemote: container.stop,
440
+ };
441
+ }
442
+ catch (err) {
443
+ // Never leave a 1.3 GB container running for a browser we could not
444
+ // talk to.
445
+ await container.stop().catch(() => { });
446
+ throw err;
447
+ }
448
+ }
307
449
  const port = parsed.port ?? persisted?.port ?? 9222;
308
450
  if (mode === 'dedicated') {
309
451
  try {
@@ -39,3 +39,8 @@ export declare function buildAgentState(): AgentState;
39
39
  * registered elsewhere (for example, old mode's publish tool) instead of
40
40
  * building a fresh one. */
41
41
  export declare function buildAgentTools(client?: ReclaimClient, builderClient?: ReclaimClient | undefined, externalState?: AgentState): RegisteredTool[];
42
+ /**
43
+ * Tear down an agent state on the way out. Safe to call more than once, and
44
+ * safe when nothing was ever attached.
45
+ */
46
+ export declare function disposeAgentState(state: AgentState): Promise<void>;
@@ -19,7 +19,7 @@ import { howItWorksTool } from "./how-it-works.js";
19
19
  import { liveViewTools } from "./live-view.js";
20
20
  import { podProofTools } from "./pod-proof.js";
21
21
  import { createVersionFromCaptureTool } from "./publish.js";
22
- import { sessionTools } from "./session.js";
22
+ import { resetAll, sessionTools } from "./session.js";
23
23
  import { sharedAuthoringTools } from "./shared.js";
24
24
  /** Exported so a mode (for example, old-devtools) that supplies its own
25
25
  * publish tool can build the attach/capture state up front and share the SAME
@@ -58,7 +58,7 @@ export function buildAgentTools(client, builderClient = client, externalState) {
58
58
  tools.push(t);
59
59
  }
60
60
  // Local-CDP only: a builder-mode browser already has a hosted live view.
61
- for (const t of liveViewTools(state.attach, state.captures, state.liveShare)) {
61
+ for (const t of liveViewTools(state.attach, state.liveShare)) {
62
62
  tools.push(t);
63
63
  }
64
64
  for (const t of podProofTools(state.attach, state.captures)) {
@@ -76,3 +76,10 @@ export function buildAgentTools(client, builderClient = client, externalState) {
76
76
  }
77
77
  return tools;
78
78
  }
79
+ /**
80
+ * Tear down an agent state on the way out. Safe to call more than once, and
81
+ * safe when nothing was ever attached.
82
+ */
83
+ export async function disposeAgentState(state) {
84
+ await resetAll(state.attach, state.captures, state.liveShare);
85
+ }
@@ -1,2 +1 @@
1
- export { buildAgentState, buildAgentTools } from './build.ts';
2
- export type { AgentState } from './build.ts';
1
+ export { type AgentState, buildAgentState, buildAgentTools, disposeAgentState, } from './build.ts';
@@ -1,3 +1,3 @@
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.js";
3
+ export { buildAgentState, buildAgentTools, disposeAgentState, } from "./build.js";
@@ -1,14 +1,10 @@
1
1
  import { type Tunnel } from '../../../live/tunnel.ts';
2
- import { type LiveView } from '../../../live/viewer.ts';
3
2
  import { type RegisteredTool } from '../../server.ts';
4
- import type { AttachState } from './attach.ts';
5
- import type { CaptureStore } from './capture.ts';
3
+ import { type AttachState } from './attach.ts';
6
4
  export interface LiveShare {
7
- view: LiveView;
8
5
  tunnel: Tunnel;
6
+ /** The public URL handed to whoever is watching. */
9
7
  url: string;
10
- interactive: boolean;
11
- startedAt: number;
12
8
  }
13
9
  export interface LiveShareRef {
14
10
  current?: LiveShare;
@@ -18,4 +14,4 @@ export interface LiveShareRef {
18
14
  export declare function stopLiveShare(ref: LiveShareRef): Promise<boolean>;
19
15
  export declare function liveViewTools(attachRef: {
20
16
  current?: AttachState;
21
- }, captures: CaptureStore, shareRef: LiveShareRef): RegisteredTool[];
17
+ }, shareRef: LiveShareRef): RegisteredTool[];