@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
package/src/live/page.ts DELETED
@@ -1,145 +0,0 @@
1
- /**
2
- * The live-view page served to whoever opens the tunnel URL.
3
- *
4
- * Deliberately dependency-free and inline: it is delivered through a throwaway
5
- * tunnel, so it cannot rely on a CDN, a bundler, or anything cached.
6
- *
7
- * Frames arrive as base64 JPEG over Server-Sent Events rather than a
8
- * WebSocket. SSE is one plain GET, so it survives tunnels and proxies that
9
- * mishandle upgrades, and it reconnects on its own. Input goes the other way
10
- * as small POSTs.
11
- */
12
-
13
- /** Escape a value for embedding in a single-quoted JS string literal. */
14
- function js(value: string): string {
15
- return value
16
- .replace(/\\/g, '\\\\')
17
- .replace(/'/g, "\\'")
18
- .replace(/</g, '\\x3c')
19
- }
20
-
21
- export interface PageOptions {
22
- /** Path prefix that carries the access token, for example `/v/<token>`. */
23
- base: string
24
- /** Whether to wire up mouse and keyboard forwarding. */
25
- interactive: boolean
26
- /** Shown in the header so a viewer knows which tab they are watching. */
27
- title: string
28
- }
29
-
30
- export function renderViewerPage(opts: PageOptions): string {
31
- return `<!doctype html>
32
- <html lang="en">
33
- <head>
34
- <meta charset="utf-8">
35
- <meta name="viewport" content="width=device-width, initial-scale=1">
36
- <meta name="robots" content="noindex, nofollow">
37
- <title>Reclaim live view</title>
38
- <style>
39
- :root { color-scheme: dark; }
40
- * { box-sizing: border-box; }
41
- body { margin: 0; background: #0b0b0c; color: #e7e7ea; display: flex;
42
- flex-direction: column; height: 100vh;
43
- font: 14px/1.4 ui-sans-serif, system-ui, sans-serif; }
44
- header { display: flex; align-items: center; gap: .75rem;
45
- padding: .5rem .75rem; border-bottom: 1px solid #26262b; }
46
- .dot { width: .5rem; height: .5rem; border-radius: 50%; background: #f5a524; }
47
- .dot.live { background: #17c964; }
48
- .title { color: #9a9aa4; overflow: hidden; text-overflow: ellipsis;
49
- white-space: nowrap; }
50
- .badge { margin-left: auto; font-size: 12px; padding: .15rem .4rem;
51
- border: 1px solid #26262b; border-radius: .25rem; color: #9a9aa4; }
52
- main { flex: 1; min-height: 0; display: grid; place-items: center;
53
- padding: .75rem; }
54
- img { max-width: 100%; max-height: 100%; display: block;
55
- background: #fff; border-radius: .25rem; }
56
- img.interactive { cursor: crosshair; }
57
- .msg { color: #9a9aa4; }
58
- </style>
59
- </head>
60
- <body>
61
- <header>
62
- <span class="dot" id="dot"></span>
63
- <span class="title" id="title">${js(opts.title)}</span>
64
- <span class="badge">${opts.interactive ? 'interactive' : 'view only'}</span>
65
- </header>
66
- <main><img id="screen" alt="Live view of the browser tab"><p class="msg"
67
- id="msg">Connecting…</p></main>
68
- <script>
69
- (function () {
70
- var BASE = '${js(opts.base)}';
71
- var INTERACTIVE = ${opts.interactive ? 'true' : 'false'};
72
- var screen = document.getElementById('screen');
73
- var msg = document.getElementById('msg');
74
- var dot = document.getElementById('dot');
75
- var title = document.getElementById('title');
76
-
77
- var es = new EventSource(BASE + '/stream');
78
- es.addEventListener('frame', function (e) {
79
- var payload = JSON.parse(e.data);
80
- screen.src = 'data:image/jpeg;base64,' + payload.data;
81
- if (payload.title) { title.textContent = payload.title; }
82
- msg.style.display = 'none';
83
- dot.className = 'dot live';
84
- });
85
- es.onerror = function () {
86
- dot.className = 'dot';
87
- msg.style.display = '';
88
- msg.textContent = 'Reconnecting…';
89
- };
90
-
91
- if (!INTERACTIVE) { return; }
92
- screen.className = 'interactive';
93
-
94
- // The <img> is letterboxed by object-fit, so a click at some point in the
95
- // element maps to page coordinates by the ratio of natural to rendered size.
96
- function toPage(ev) {
97
- var r = screen.getBoundingClientRect();
98
- if (!screen.naturalWidth || !r.width) { return null; }
99
- return {
100
- x: Math.round((ev.clientX - r.left) * (screen.naturalWidth / r.width)),
101
- y: Math.round((ev.clientY - r.top) * (screen.naturalHeight / r.height))
102
- };
103
- }
104
-
105
- function post(body) {
106
- return fetch(BASE + '/input', {
107
- method: 'POST',
108
- headers: { 'content-type': 'application/json' },
109
- body: JSON.stringify(body)
110
- }).catch(function () {});
111
- }
112
-
113
- screen.addEventListener('mousedown', function (ev) {
114
- var p = toPage(ev); if (!p) { return; }
115
- ev.preventDefault();
116
- post({ kind: 'mouse', type: 'mousePressed', x: p.x, y: p.y,
117
- button: 'left', clickCount: 1 });
118
- });
119
- screen.addEventListener('mouseup', function (ev) {
120
- var p = toPage(ev); if (!p) { return; }
121
- post({ kind: 'mouse', type: 'mouseReleased', x: p.x, y: p.y,
122
- button: 'left', clickCount: 1 });
123
- });
124
- screen.addEventListener('wheel', function (ev) {
125
- var p = toPage(ev); if (!p) { return; }
126
- ev.preventDefault();
127
- post({ kind: 'mouse', type: 'mouseWheel', x: p.x, y: p.y,
128
- deltaX: ev.deltaX, deltaY: ev.deltaY });
129
- }, { passive: false });
130
-
131
- // Typing goes to the page, so the viewer never needs to focus anything.
132
- window.addEventListener('keydown', function (ev) {
133
- if (ev.metaKey || ev.ctrlKey) { return; }
134
- ev.preventDefault();
135
- post({ kind: 'key', type: 'keyDown', key: ev.key, code: ev.code });
136
- });
137
- window.addEventListener('keyup', function (ev) {
138
- if (ev.metaKey || ev.ctrlKey) { return; }
139
- post({ kind: 'key', type: 'keyUp', key: ev.key, code: ev.code });
140
- });
141
- })();
142
- </script>
143
- </body>
144
- </html>`
145
- }
@@ -1,293 +0,0 @@
1
- /**
2
- * Interactive live view of one attached tab, over a public URL.
3
- *
4
- * Two halves:
5
- * - OUT: CDP `Page.startScreencast` pushes JPEG frames; each is forwarded to
6
- * every connected browser as a Server-Sent Event and acked back to CDP.
7
- * - IN: the viewer POSTs pointer and key events, which are replayed into the
8
- * page with `Input.dispatch*`.
9
- *
10
- * Only ever bound to 127.0.0.1. Reaching it from outside is the tunnel's job
11
- * (see `tunnel.ts`), and because a quick tunnel is unauthenticated, EVERY
12
- * route sits behind an unguessable token in the path. A request without it
13
- * gets a flat 404 — not a 401, which would confirm the server exists.
14
- *
15
- * Interactivity is the caller's choice. `interactive: false` still streams,
16
- * but the input route refuses, which is the right setting for showing someone
17
- * a run rather than handing them the wheel.
18
- */
19
- import { randomBytes } from 'node:crypto'
20
- import {
21
- createServer,
22
- type IncomingMessage,
23
- type Server,
24
- type ServerResponse,
25
- } from 'node:http'
26
- import type { AddressInfo } from 'node:net'
27
- import type { CdpTabClient } from '../cdp/client.ts'
28
- import { LOGGER } from '../logger.ts'
29
- import { renderViewerPage } from './page.ts'
30
-
31
- /** JPEG quality for streamed frames. 60 keeps a text-heavy page readable
32
- * while staying small enough for a tunnel's uplink. */
33
- const FRAME_QUALITY = 60
34
- /** Cap the streamed frame size. Screencast scales to fit, so this bounds
35
- * bandwidth without changing the page's own layout viewport. */
36
- const MAX_FRAME_WIDTH = 1280
37
- const MAX_FRAME_HEIGHT = 800
38
- /** Bytes accepted on an input POST. Events are tiny; anything larger is
39
- * either a bug or someone probing. */
40
- const MAX_INPUT_BYTES = 4096
41
-
42
- export interface LiveViewOptions {
43
- /** The tab to stream. */
44
- tab: CdpTabClient
45
- /** Forward viewer input into the page. */
46
- interactive: boolean
47
- /** Shown in the viewer's header. */
48
- title?: string
49
- }
50
-
51
- export interface LiveView {
52
- /** Loopback origin the tunnel should point at. */
53
- localUrl: string
54
- /** Port the server bound to. */
55
- port: number
56
- /** Unguessable path segment guarding every route. */
57
- token: string
58
- /** Path prefix, `/v/<token>`. Append this to the public origin. */
59
- path: string
60
- /** Number of viewers currently streaming. */
61
- viewers: () => number
62
- stop: () => Promise<void>
63
- }
64
-
65
- /**
66
- * Start the loopback live-view server for `tab`. Resolves once it is
67
- * listening; the screencast starts with the first viewer and stops with the
68
- * last, so an idle share costs the page nothing.
69
- */
70
- export async function startLiveView(
71
- opts: LiveViewOptions,
72
- ): Promise<LiveView> {
73
- const token = randomBytes(24).toString('base64url')
74
- const path = `/v/${token}`
75
- const clients = new Set<ServerResponse>()
76
- let streaming = false
77
- let unsubscribe: (() => void) | undefined
78
-
79
- const startScreencast = async() => {
80
- if(streaming) {
81
- return
82
- }
83
-
84
- streaming = true
85
- unsubscribe = opts.tab.subscribe('Page.screencastFrame', (frame) => {
86
- // Ack first: CDP sends at most one un-acked frame, so a slow or failed
87
- // write must not stall the stream.
88
- void opts.tab
89
- .send('Page.screencastFrameAck', { sessionId: frame.sessionId })
90
- .catch(() => {})
91
- const payload = JSON.stringify({ data: frame.data })
92
- for(const res of clients) {
93
- res.write(`event: frame\ndata: ${payload}\n\n`)
94
- }
95
- })
96
- await opts.tab.send('Page.enable')
97
- await opts.tab.send('Page.startScreencast', {
98
- format: 'jpeg',
99
- quality: FRAME_QUALITY,
100
- maxWidth: MAX_FRAME_WIDTH,
101
- maxHeight: MAX_FRAME_HEIGHT,
102
- everyNthFrame: 1,
103
- })
104
- }
105
-
106
- const stopScreencast = async() => {
107
- if(!streaming) {
108
- return
109
- }
110
-
111
- streaming = false
112
- unsubscribe?.()
113
- unsubscribe = undefined
114
- await opts.tab.send('Page.stopScreencast').catch(() => {})
115
- }
116
-
117
- const server = createServer((req, res) => {
118
- const url = req.url ?? '/'
119
- // Constant prefix check: everything is scoped under the token, so an
120
- // unmatched path is indistinguishable from a server that isn't there.
121
- if(!url.startsWith(path)) {
122
- res.writeHead(404).end('not found')
123
- return
124
- }
125
-
126
- const route = url.slice(path.length) || '/'
127
- if(req.method === 'GET' && (route === '/' || route.startsWith('/?'))) {
128
- const body = renderViewerPage({
129
- base: path,
130
- interactive: opts.interactive,
131
- title: opts.title ?? 'Attached tab',
132
- })
133
- res.writeHead(200, {
134
- 'content-type': 'text/html; charset=utf-8',
135
- // A share URL must never be cached or indexed by anything between
136
- // the viewer and here.
137
- 'cache-control': 'no-store',
138
- 'referrer-policy': 'no-referrer',
139
- }).end(body)
140
- return
141
- }
142
-
143
- if(req.method === 'GET' && route === '/stream') {
144
- res.writeHead(200, {
145
- 'content-type': 'text/event-stream',
146
- 'cache-control': 'no-store',
147
- connection: 'keep-alive',
148
- // Defeat proxy buffering, which would otherwise hold frames back.
149
- 'x-accel-buffering': 'no',
150
- })
151
- res.write(': connected\n\n')
152
- clients.add(res)
153
- void startScreencast().catch((err) => {
154
- LOGGER.warn({ err }, 'live view: screencast failed to start')
155
- })
156
- req.on('close', () => {
157
- clients.delete(res)
158
- if(!clients.size) {
159
- void stopScreencast()
160
- }
161
- })
162
- return
163
- }
164
-
165
- if(req.method === 'POST' && route === '/input') {
166
- if(!opts.interactive) {
167
- res.writeHead(403).end('view only')
168
- return
169
- }
170
-
171
- readJson(req, MAX_INPUT_BYTES)
172
- .then((body) => dispatchInput(opts.tab, body))
173
- .then(() => res.writeHead(204).end())
174
- .catch((err) => {
175
- LOGGER.debug({ err }, 'live view: input rejected')
176
- res.writeHead(400).end('bad input')
177
- })
178
- return
179
- }
180
-
181
- res.writeHead(404).end('not found')
182
- })
183
-
184
- await new Promise<void>((resolve) => {
185
- server.listen(0, '127.0.0.1', resolve)
186
- })
187
- const port = (server.address() as AddressInfo).port
188
-
189
- return {
190
- localUrl: `http://127.0.0.1:${port}`,
191
- port,
192
- token,
193
- path,
194
- viewers: () => clients.size,
195
- stop: async() => {
196
- await stopScreencast()
197
- for(const res of clients) {
198
- res.end()
199
- }
200
-
201
- clients.clear()
202
- await closeServer(server)
203
- },
204
- }
205
- }
206
-
207
- function closeServer(server: Server): Promise<void> {
208
- return new Promise((resolve) => {
209
- server.close(() => resolve())
210
- // A held-open SSE response would otherwise keep the server alive.
211
- server.closeAllConnections?.()
212
- })
213
- }
214
-
215
- function readJson(
216
- req: IncomingMessage,
217
- limit: number,
218
- ): Promise<Record<string, unknown>> {
219
- return new Promise((resolve, reject) => {
220
- let size = 0
221
- const chunks: Buffer[] = []
222
- req.on('data', (chunk: Buffer) => {
223
- size += chunk.length
224
- if(size > limit) {
225
- reject(new Error('input too large'))
226
- req.destroy()
227
- return
228
- }
229
-
230
- chunks.push(chunk)
231
- })
232
- req.on('end', () => {
233
- try {
234
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
235
- } catch(err) {
236
- reject(err instanceof Error ? err : new Error('bad json'))
237
- }
238
- })
239
- req.on('error', reject)
240
- })
241
- }
242
-
243
- /** Replay one viewer event into the page. Unknown shapes throw, so the route
244
- * answers 400 rather than forwarding something arbitrary to CDP. */
245
- async function dispatchInput(
246
- tab: CdpTabClient,
247
- body: Record<string, unknown>,
248
- ): Promise<void> {
249
- const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v)
250
- ? v
251
- : 0)
252
-
253
- if(body.kind === 'mouse') {
254
- const type = String(body.type)
255
- if(!['mousePressed', 'mouseReleased', 'mouseMoved', 'mouseWheel']
256
- .includes(type)) {
257
- throw new Error(`unsupported mouse type ${type}`)
258
- }
259
-
260
- await tab.send('Input.dispatchMouseEvent', {
261
- type,
262
- x: num(body.x),
263
- y: num(body.y),
264
- button: type === 'mouseWheel' ? 'none' : 'left',
265
- clickCount: type === 'mouseWheel' ? 0 : 1,
266
- ...(type === 'mouseWheel'
267
- ? { deltaX: num(body.deltaX), deltaY: num(body.deltaY) }
268
- : {}),
269
- })
270
- return
271
- }
272
-
273
- if(body.kind === 'key') {
274
- const type = String(body.type)
275
- if(type !== 'keyDown' && type !== 'keyUp') {
276
- throw new Error(`unsupported key type ${type}`)
277
- }
278
-
279
- const key = String(body.key ?? '')
280
- // A single printable character needs `text` as well, or the page sees the
281
- // keypress but no character is inserted.
282
- const text = key.length === 1 ? key : undefined
283
- await tab.send('Input.dispatchKeyEvent', {
284
- type,
285
- key,
286
- code: String(body.code ?? ''),
287
- ...(text && type === 'keyDown' ? { text } : {}),
288
- })
289
- return
290
- }
291
-
292
- throw new Error('unknown event kind')
293
- }