@browsercash/chase 1.0.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/dist/server.js ADDED
@@ -0,0 +1,1934 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import Fastify from 'fastify';
4
+ import { spawn } from 'child_process';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as crypto from 'crypto';
8
+ import { Storage } from '@google-cloud/storage';
9
+ import { loadConfig } from './config.js';
10
+ import { runClaudeForScriptGeneration } from './claude-runner.js';
11
+ import { runIterativeTest } from './iterative-tester.js';
12
+ import { writeScript } from './codegen/bash-generator.js';
13
+ import { getSystemPrompt } from './prompts/system-prompt.js';
14
+ import { getAgenticPrompt } from './prompts/agentic-prompt.js';
15
+ import { createAndWaitForSession, stopBrowserSession, } from './browser-cash.js';
16
+ // Google Cloud Storage setup
17
+ const storage = new Storage();
18
+ const BUCKET_NAME = process.env.GCS_BUCKET || 'claude-gen-scripts';
19
+ /**
20
+ * Hash an API key to create a user namespace (ownerId).
21
+ * We use a truncated SHA-256 hash to avoid storing the raw key.
22
+ */
23
+ function hashApiKey(apiKey) {
24
+ return crypto.createHash('sha256').update(apiKey).digest('hex').substring(0, 16);
25
+ }
26
+ /**
27
+ * Upload a script to GCS and return its metadata
28
+ */
29
+ async function uploadScript(scriptContent, task, iterations, success, ownerId) {
30
+ const id = `script-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
31
+ const bucket = storage.bucket(BUCKET_NAME);
32
+ // Upload the script
33
+ const scriptFile = bucket.file(`scripts/${id}.sh`);
34
+ await scriptFile.save(scriptContent, {
35
+ contentType: 'application/x-sh',
36
+ metadata: {
37
+ task,
38
+ ownerId,
39
+ createdAt: new Date().toISOString(),
40
+ iterations: iterations.toString(),
41
+ success: success.toString(),
42
+ },
43
+ });
44
+ // Save metadata separately for easy listing
45
+ const metadata = {
46
+ id,
47
+ ownerId,
48
+ task,
49
+ createdAt: new Date().toISOString(),
50
+ iterations,
51
+ success,
52
+ scriptSize: scriptContent.length,
53
+ };
54
+ const metaFile = bucket.file(`metadata/${id}.json`);
55
+ await metaFile.save(JSON.stringify(metadata, null, 2), {
56
+ contentType: 'application/json',
57
+ });
58
+ return metadata;
59
+ }
60
+ /**
61
+ * Get a script from GCS, verifying ownership
62
+ */
63
+ async function getScript(id, ownerId) {
64
+ try {
65
+ const bucket = storage.bucket(BUCKET_NAME);
66
+ const [metaContent] = await bucket.file(`metadata/${id}.json`).download();
67
+ const metadata = JSON.parse(metaContent.toString());
68
+ // Verify ownership
69
+ if (metadata.ownerId !== ownerId) {
70
+ return null;
71
+ }
72
+ const [scriptContent] = await bucket.file(`scripts/${id}.sh`).download();
73
+ return {
74
+ content: scriptContent.toString(),
75
+ metadata,
76
+ };
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /**
83
+ * List scripts from GCS filtered by ownerId
84
+ */
85
+ async function listScripts(ownerId, limit = 50) {
86
+ try {
87
+ const bucket = storage.bucket(BUCKET_NAME);
88
+ // Get more files than limit to account for filtering
89
+ const [files] = await bucket.getFiles({ prefix: 'metadata/', maxResults: limit * 3 });
90
+ const scripts = [];
91
+ for (const file of files) {
92
+ try {
93
+ const [content] = await file.download();
94
+ const metadata = JSON.parse(content.toString());
95
+ // Only include scripts owned by this user
96
+ if (metadata.ownerId === ownerId) {
97
+ scripts.push(metadata);
98
+ }
99
+ }
100
+ catch {
101
+ // Skip invalid files
102
+ }
103
+ // Stop if we have enough
104
+ if (scripts.length >= limit)
105
+ break;
106
+ }
107
+ // Sort by createdAt descending
108
+ scripts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
109
+ return scripts.slice(0, limit);
110
+ }
111
+ catch {
112
+ return [];
113
+ }
114
+ }
115
+ /**
116
+ * Generate a unique task ID
117
+ */
118
+ function generateTaskId() {
119
+ const timestamp = Date.now().toString(36);
120
+ const random = Math.random().toString(36).substring(2, 8);
121
+ return `task-${timestamp}-${random}`;
122
+ }
123
+ /**
124
+ * Save a task record to GCS
125
+ */
126
+ async function saveTask(task) {
127
+ const bucket = storage.bucket(BUCKET_NAME);
128
+ const file = bucket.file(`tasks/${task.taskId}.json`);
129
+ await file.save(JSON.stringify(task, null, 2), {
130
+ contentType: 'application/json',
131
+ });
132
+ }
133
+ /**
134
+ * Get a task record from GCS, verifying ownership
135
+ */
136
+ async function getTask(taskId, ownerId) {
137
+ try {
138
+ const bucket = storage.bucket(BUCKET_NAME);
139
+ const [content] = await bucket.file(`tasks/${taskId}.json`).download();
140
+ const task = JSON.parse(content.toString());
141
+ // Verify ownership
142
+ if (task.ownerId !== ownerId) {
143
+ return null;
144
+ }
145
+ return task;
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
151
+ /**
152
+ * List recent tasks from GCS filtered by ownerId
153
+ */
154
+ async function listTasks(ownerId, limit = 50) {
155
+ try {
156
+ const bucket = storage.bucket(BUCKET_NAME);
157
+ // Get more files than limit to account for filtering
158
+ const [files] = await bucket.getFiles({ prefix: 'tasks/', maxResults: limit * 3 });
159
+ const tasks = [];
160
+ for (const file of files) {
161
+ try {
162
+ const [content] = await file.download();
163
+ const task = JSON.parse(content.toString());
164
+ // Only include tasks owned by this user
165
+ if (task.ownerId === ownerId) {
166
+ tasks.push(task);
167
+ }
168
+ }
169
+ catch {
170
+ // Skip invalid files
171
+ }
172
+ // Stop if we have enough
173
+ if (tasks.length >= limit)
174
+ break;
175
+ }
176
+ // Sort by createdAt descending
177
+ tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
178
+ return tasks.slice(0, limit);
179
+ }
180
+ catch {
181
+ return [];
182
+ }
183
+ }
184
+ // Create Fastify instance
185
+ const server = Fastify({
186
+ logger: true,
187
+ });
188
+ // Health check endpoint
189
+ server.get('/health', async (_request, _reply) => {
190
+ return {
191
+ status: 'ok',
192
+ timestamp: new Date().toISOString(),
193
+ version: '1.0.0',
194
+ };
195
+ });
196
+ // ============================================
197
+ // Task Status Endpoints
198
+ // ============================================
199
+ // Get a specific task's status and result
200
+ server.get('/tasks/:taskId', async (request, reply) => {
201
+ const { taskId } = request.params;
202
+ const apiKey = request.headers['x-api-key'] || request.query.apiKey;
203
+ if (!apiKey) {
204
+ reply.code(401);
205
+ return { error: 'API key required (x-api-key header or apiKey query param)' };
206
+ }
207
+ const ownerId = hashApiKey(apiKey);
208
+ try {
209
+ const task = await getTask(taskId, ownerId);
210
+ if (!task) {
211
+ reply.code(404);
212
+ return { error: 'Task not found' };
213
+ }
214
+ return task;
215
+ }
216
+ catch (error) {
217
+ const message = error instanceof Error ? error.message : 'Unknown error';
218
+ reply.code(500);
219
+ return { error: message };
220
+ }
221
+ });
222
+ // List recent tasks
223
+ server.get('/tasks', async (request, reply) => {
224
+ const apiKey = request.headers['x-api-key'] || request.query.apiKey;
225
+ if (!apiKey) {
226
+ reply.code(401);
227
+ return { error: 'API key required (x-api-key header or apiKey query param)' };
228
+ }
229
+ const ownerId = hashApiKey(apiKey);
230
+ try {
231
+ const tasks = await listTasks(ownerId);
232
+ return { tasks };
233
+ }
234
+ catch (error) {
235
+ const message = error instanceof Error ? error.message : 'Unknown error';
236
+ reply.code(500);
237
+ return { error: message };
238
+ }
239
+ });
240
+ /**
241
+ * Run a single agent-browser command and return stdout/stderr
242
+ */
243
+ function runAgentBrowserCommand(cdpUrl, command, args, timeoutMs = 30000) {
244
+ return new Promise((resolve) => {
245
+ const cmd = `agent-browser --cdp "${cdpUrl}" ${command} ${args}`;
246
+ const proc = spawn('bash', ['-c', cmd], {
247
+ stdio: ['ignore', 'pipe', 'pipe'],
248
+ });
249
+ let stdout = '';
250
+ let stderr = '';
251
+ proc.stdout?.on('data', (data) => {
252
+ stdout += data.toString();
253
+ });
254
+ proc.stderr?.on('data', (data) => {
255
+ stderr += data.toString();
256
+ });
257
+ const timeout = setTimeout(() => {
258
+ proc.kill();
259
+ resolve({ success: false, stdout, stderr: 'Timeout', code: null });
260
+ }, timeoutMs);
261
+ proc.on('close', (code) => {
262
+ clearTimeout(timeout);
263
+ resolve({ success: code === 0, stdout, stderr, code });
264
+ });
265
+ proc.on('error', (err) => {
266
+ clearTimeout(timeout);
267
+ resolve({ success: false, stdout, stderr: err.message, code: null });
268
+ });
269
+ });
270
+ }
271
+ server.post('/test-cdp', {
272
+ schema: {
273
+ body: {
274
+ type: 'object',
275
+ required: ['cdpUrl'],
276
+ properties: {
277
+ cdpUrl: { type: 'string', minLength: 1 },
278
+ testNavigation: { type: 'boolean', default: true },
279
+ testUrl: { type: 'string', default: 'https://example.com' },
280
+ },
281
+ },
282
+ },
283
+ }, async (request, reply) => {
284
+ const { cdpUrl, testNavigation = true, testUrl = 'https://example.com' } = request.body;
285
+ const startTime = Date.now();
286
+ request.log.info({ cdpUrl: cdpUrl.substring(0, 50) + '...' }, 'Testing CDP connectivity');
287
+ const diagnostics = {
288
+ cdpConnected: false,
289
+ browserResponsive: false,
290
+ canNavigate: false,
291
+ dnsWorking: false,
292
+ };
293
+ const timing = {
294
+ connectionMs: 0,
295
+ commandMs: 0,
296
+ totalMs: 0,
297
+ };
298
+ // Test 1: Basic CDP connectivity - get current page info
299
+ const connectStart = Date.now();
300
+ const infoResult = await runAgentBrowserCommand(cdpUrl, 'eval', '"JSON.stringify({title: document.title, url: location.href, userAgent: navigator.userAgent})"', 15000);
301
+ timing.connectionMs = Date.now() - connectStart;
302
+ if (infoResult.success && infoResult.stdout) {
303
+ diagnostics.cdpConnected = true;
304
+ diagnostics.browserResponsive = true;
305
+ try {
306
+ let parsed = infoResult.stdout.trim();
307
+ if (parsed.startsWith('"') && parsed.endsWith('"')) {
308
+ parsed = JSON.parse(parsed);
309
+ }
310
+ const browserInfo = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
311
+ diagnostics.browserInfo = {
312
+ userAgent: browserInfo.userAgent,
313
+ currentUrl: browserInfo.url,
314
+ currentTitle: browserInfo.title,
315
+ };
316
+ // Check if current URL indicates an error page
317
+ if (browserInfo.url?.includes('chrome-error://')) {
318
+ diagnostics.dnsWorking = false;
319
+ diagnostics.canNavigate = false;
320
+ }
321
+ }
322
+ catch {
323
+ diagnostics.browserInfo = { currentTitle: infoResult.stdout.trim() };
324
+ }
325
+ }
326
+ else {
327
+ return {
328
+ success: false,
329
+ connected: false,
330
+ error: infoResult.stderr || `Failed to connect: exit code ${infoResult.code}`,
331
+ diagnostics,
332
+ timing: { ...timing, totalMs: Date.now() - startTime },
333
+ };
334
+ }
335
+ // Test 2: Navigation test (if requested)
336
+ if (testNavigation) {
337
+ const navStart = Date.now();
338
+ // First, navigate to the test URL
339
+ const navResult = await runAgentBrowserCommand(cdpUrl, 'open', `"${testUrl}"`, 20000);
340
+ if (navResult.success) {
341
+ // Wait a moment for the page to load
342
+ await new Promise(r => setTimeout(r, 2000));
343
+ // Check what URL we ended up at
344
+ const checkResult = await runAgentBrowserCommand(cdpUrl, 'eval', `"JSON.stringify({url: location.href, title: document.title, body: document.body?.innerText?.substring(0, 200) || ''})"`, 10000);
345
+ timing.navigationMs = Date.now() - navStart;
346
+ if (checkResult.success && checkResult.stdout) {
347
+ try {
348
+ let parsed = checkResult.stdout.trim();
349
+ if (parsed.startsWith('"') && parsed.endsWith('"')) {
350
+ parsed = JSON.parse(parsed);
351
+ }
352
+ const navInfo = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
353
+ diagnostics.navigationUrl = navInfo.url;
354
+ // Check for common error patterns
355
+ const url = navInfo.url || '';
356
+ const body = navInfo.body || '';
357
+ if (url.includes('chrome-error://') || body.includes('ERR_NAME_NOT_RESOLVED')) {
358
+ diagnostics.canNavigate = false;
359
+ diagnostics.dnsWorking = false;
360
+ diagnostics.navigationError = 'DNS resolution failed (ERR_NAME_NOT_RESOLVED) - browser cannot resolve domain names';
361
+ }
362
+ else if (body.includes('ERR_CONNECTION_REFUSED')) {
363
+ diagnostics.canNavigate = false;
364
+ diagnostics.dnsWorking = true;
365
+ diagnostics.navigationError = 'Connection refused - target server not reachable';
366
+ }
367
+ else if (body.includes('ERR_CONNECTION_TIMED_OUT')) {
368
+ diagnostics.canNavigate = false;
369
+ diagnostics.dnsWorking = true;
370
+ diagnostics.navigationError = 'Connection timed out - network issues';
371
+ }
372
+ else if (body.includes('ERR_')) {
373
+ diagnostics.canNavigate = false;
374
+ const errMatch = body.match(/ERR_[A-Z_]+/);
375
+ diagnostics.navigationError = errMatch ? errMatch[0] : 'Unknown navigation error';
376
+ }
377
+ else if (url.startsWith('http') && !url.includes('chrome-error')) {
378
+ diagnostics.canNavigate = true;
379
+ diagnostics.dnsWorking = true;
380
+ }
381
+ }
382
+ catch {
383
+ diagnostics.navigationError = 'Failed to parse navigation result';
384
+ }
385
+ }
386
+ else {
387
+ diagnostics.navigationError = checkResult.stderr || 'Navigation check failed';
388
+ }
389
+ }
390
+ else {
391
+ timing.navigationMs = Date.now() - navStart;
392
+ diagnostics.navigationError = navResult.stderr || 'Navigation command failed';
393
+ }
394
+ }
395
+ timing.commandMs = Date.now() - startTime - timing.connectionMs;
396
+ timing.totalMs = Date.now() - startTime;
397
+ // Overall success check
398
+ const overallSuccess = diagnostics.cdpConnected &&
399
+ (!testNavigation || diagnostics.canNavigate);
400
+ return {
401
+ success: overallSuccess,
402
+ connected: diagnostics.cdpConnected,
403
+ pageTitle: diagnostics.browserInfo?.currentTitle,
404
+ currentUrl: diagnostics.browserInfo?.currentUrl,
405
+ error: overallSuccess ? undefined : (diagnostics.navigationError || 'CDP test failed'),
406
+ diagnostics,
407
+ timing,
408
+ };
409
+ });
410
+ // Generate script endpoint (non-streaming)
411
+ server.post('/generate', {
412
+ schema: {
413
+ body: {
414
+ type: 'object',
415
+ required: ['task'],
416
+ properties: {
417
+ task: { type: 'string', minLength: 1 },
418
+ browserCashApiKey: { type: 'string', minLength: 1 },
419
+ cdpUrl: { type: 'string', minLength: 1 },
420
+ browserOptions: {
421
+ type: 'object',
422
+ properties: {
423
+ country: { type: 'string', minLength: 2, maxLength: 2 },
424
+ type: { type: 'string', enum: ['consumer_distributed', 'hosted', 'testing'] },
425
+ proxyUrl: { type: 'string' },
426
+ windowSize: { type: 'string' },
427
+ adblock: { type: 'boolean' },
428
+ captchaSolver: { type: 'boolean' },
429
+ },
430
+ },
431
+ skipTest: { type: 'boolean', default: false },
432
+ },
433
+ },
434
+ },
435
+ }, async (request, reply) => {
436
+ const { task, browserCashApiKey, cdpUrl, browserOptions, skipTest } = request.body;
437
+ let browserSession = null;
438
+ let effectiveCdpUrl;
439
+ // Validate that either browserCashApiKey or cdpUrl is provided
440
+ if (!browserCashApiKey && !cdpUrl) {
441
+ reply.code(400);
442
+ return {
443
+ success: false,
444
+ error: 'Either browserCashApiKey or cdpUrl is required',
445
+ };
446
+ }
447
+ try {
448
+ if (cdpUrl) {
449
+ // Use direct CDP URL
450
+ effectiveCdpUrl = cdpUrl;
451
+ }
452
+ else if (browserCashApiKey) {
453
+ // Create browser session via Browser.cash
454
+ const sessionResult = await createAndWaitForSession(browserCashApiKey, browserOptions || {});
455
+ browserSession = { sessionId: sessionResult.session.sessionId, cdpUrl: sessionResult.cdpUrl };
456
+ effectiveCdpUrl = sessionResult.cdpUrl;
457
+ }
458
+ // Load configuration with effective CDP URL
459
+ const config = loadConfig({
460
+ cdpUrl: effectiveCdpUrl,
461
+ taskDescription: task,
462
+ });
463
+ request.log.info({ task, skipTest }, 'Starting script generation');
464
+ // Generate script using Claude
465
+ const result = await runClaudeForScriptGeneration(task, config);
466
+ if (!result.success || !result.script) {
467
+ request.log.error({ error: result.error }, 'Failed to generate script');
468
+ // Clean up browser session on error (only if we created one)
469
+ if (browserSession && browserCashApiKey) {
470
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
471
+ }
472
+ reply.code(400);
473
+ return {
474
+ success: false,
475
+ error: result.error || 'Failed to generate script - no valid script in Claude output',
476
+ };
477
+ }
478
+ request.log.info('Script generated successfully');
479
+ // Optionally run iterative testing
480
+ if (!skipTest) {
481
+ request.log.info('Starting iterative testing');
482
+ const testResult = await runIterativeTest(result.script, task, config);
483
+ if (testResult.skippedDueToStaleCdp) {
484
+ request.log.warn('Testing skipped due to stale CDP connection');
485
+ // Clean up browser session (only if we created one)
486
+ if (browserSession && browserCashApiKey) {
487
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
488
+ }
489
+ return {
490
+ success: false,
491
+ script: testResult.scriptContent,
492
+ iterations: testResult.iterations,
493
+ scriptPath: testResult.finalScriptPath,
494
+ skippedDueToStaleCdp: true,
495
+ error: 'CDP connection unavailable - script generated but not tested',
496
+ browserSessionId: browserSession?.sessionId,
497
+ };
498
+ }
499
+ // Clean up browser session (only if we created one)
500
+ if (browserSession && browserCashApiKey) {
501
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
502
+ }
503
+ return {
504
+ success: testResult.success,
505
+ script: testResult.scriptContent,
506
+ iterations: testResult.iterations,
507
+ scriptPath: testResult.finalScriptPath,
508
+ error: testResult.success ? undefined : testResult.lastError,
509
+ browserSessionId: browserSession?.sessionId,
510
+ };
511
+ }
512
+ // Skip testing - just write and return the script
513
+ const scriptPath = writeScript(result.script, {
514
+ cdpUrl: config.cdpUrl,
515
+ outputDir: config.outputDir,
516
+ });
517
+ // Clean up browser session (only if we created one)
518
+ if (browserSession && browserCashApiKey) {
519
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
520
+ }
521
+ return {
522
+ success: true,
523
+ script: result.script,
524
+ scriptPath,
525
+ browserSessionId: browserSession?.sessionId,
526
+ };
527
+ }
528
+ catch (error) {
529
+ // Clean up browser session on error (only if we created one)
530
+ if (browserSession && browserCashApiKey) {
531
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
532
+ }
533
+ const message = error instanceof Error ? error.message : 'Unknown error';
534
+ request.log.error({ error: message }, 'Request failed');
535
+ reply.code(500);
536
+ return {
537
+ success: false,
538
+ error: message,
539
+ };
540
+ }
541
+ });
542
+ // SSE Streaming endpoint for real-time logs
543
+ server.post('/generate/stream', {
544
+ schema: {
545
+ body: {
546
+ type: 'object',
547
+ required: ['task'],
548
+ properties: {
549
+ task: { type: 'string', minLength: 1 },
550
+ browserCashApiKey: { type: 'string', minLength: 1 },
551
+ cdpUrl: { type: 'string', minLength: 1 },
552
+ browserOptions: {
553
+ type: 'object',
554
+ properties: {
555
+ country: { type: 'string', minLength: 2, maxLength: 2 },
556
+ type: { type: 'string', enum: ['consumer_distributed', 'hosted', 'testing'] },
557
+ proxyUrl: { type: 'string' },
558
+ windowSize: { type: 'string' },
559
+ adblock: { type: 'boolean' },
560
+ captchaSolver: { type: 'boolean' },
561
+ },
562
+ },
563
+ skipTest: { type: 'boolean', default: false },
564
+ },
565
+ },
566
+ },
567
+ }, async (request, reply) => {
568
+ const { task, browserCashApiKey, cdpUrl, browserOptions, skipTest } = request.body;
569
+ // Validate that either browserCashApiKey or cdpUrl is provided
570
+ if (!browserCashApiKey && !cdpUrl) {
571
+ reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
572
+ reply.raw.end(JSON.stringify({ error: 'Either browserCashApiKey or cdpUrl is required' }));
573
+ return;
574
+ }
575
+ // Hash API key to create owner ID (use a placeholder for direct CDP URL users)
576
+ const ownerId = browserCashApiKey ? hashApiKey(browserCashApiKey) : hashApiKey(cdpUrl);
577
+ // Create task record for persistent storage
578
+ const taskId = generateTaskId();
579
+ const taskRecord = {
580
+ taskId,
581
+ ownerId,
582
+ type: 'generate',
583
+ status: 'pending',
584
+ task,
585
+ createdAt: new Date().toISOString(),
586
+ updatedAt: new Date().toISOString(),
587
+ };
588
+ // Set SSE headers
589
+ reply.raw.writeHead(200, {
590
+ 'Content-Type': 'text/event-stream',
591
+ 'Cache-Control': 'no-cache',
592
+ 'Connection': 'keep-alive',
593
+ 'Access-Control-Allow-Origin': '*',
594
+ });
595
+ const sendEvent = (type, data) => {
596
+ const event = {
597
+ type,
598
+ data,
599
+ timestamp: new Date().toISOString(),
600
+ };
601
+ try {
602
+ reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
603
+ }
604
+ catch {
605
+ // Client may have disconnected, ignore write errors
606
+ }
607
+ };
608
+ // Browser session manager for cleanup
609
+ let browserSession = null;
610
+ let effectiveCdpUrl;
611
+ try {
612
+ // Save initial task record
613
+ await saveTask(taskRecord);
614
+ // Set up browser - either direct CDP URL or create Browser.cash session
615
+ if (cdpUrl) {
616
+ // Use direct CDP URL
617
+ effectiveCdpUrl = cdpUrl;
618
+ sendEvent('log', { message: 'Using direct CDP URL', level: 'info' });
619
+ }
620
+ else if (browserCashApiKey) {
621
+ // Create browser session via Browser.cash
622
+ sendEvent('log', { message: 'Creating Browser.cash session...', level: 'info' });
623
+ try {
624
+ const result = await createAndWaitForSession(browserCashApiKey, browserOptions || {});
625
+ effectiveCdpUrl = result.cdpUrl;
626
+ browserSession = { sessionId: result.session.sessionId, cdpUrl: result.cdpUrl };
627
+ taskRecord.browserSessionId = result.session.sessionId;
628
+ sendEvent('log', {
629
+ message: `Browser session created: ${result.session.sessionId}`,
630
+ level: 'info',
631
+ browserSessionId: result.session.sessionId,
632
+ });
633
+ }
634
+ catch (err) {
635
+ const errorMsg = `Failed to create browser session: ${err instanceof Error ? err.message : 'Unknown error'}`;
636
+ taskRecord.status = 'error';
637
+ taskRecord.error = errorMsg;
638
+ taskRecord.updatedAt = new Date().toISOString();
639
+ await saveTask(taskRecord);
640
+ sendEvent('error', { message: errorMsg, phase: 'browser_setup', taskId });
641
+ reply.raw.end();
642
+ return;
643
+ }
644
+ }
645
+ // Update task to running
646
+ taskRecord.status = 'running';
647
+ taskRecord.updatedAt = new Date().toISOString();
648
+ await saveTask(taskRecord);
649
+ // Load configuration
650
+ const config = loadConfig({
651
+ cdpUrl: effectiveCdpUrl,
652
+ taskDescription: task,
653
+ });
654
+ sendEvent('start', {
655
+ taskId,
656
+ task,
657
+ model: config.model,
658
+ skipTest,
659
+ browserSessionId: browserSession?.sessionId,
660
+ });
661
+ // Run streaming script generation
662
+ const result = await runClaudeStreamingGeneration(task, config, sendEvent);
663
+ if (!result.success || !result.script) {
664
+ const errorMsg = result.error || 'Failed to generate script';
665
+ taskRecord.status = 'error';
666
+ taskRecord.error = errorMsg;
667
+ taskRecord.updatedAt = new Date().toISOString();
668
+ await saveTask(taskRecord);
669
+ sendEvent('error', { message: errorMsg, phase: 'generation', taskId });
670
+ reply.raw.end();
671
+ return;
672
+ }
673
+ sendEvent('script_extracted', {
674
+ scriptPreview: result.script.substring(0, 500) + '...',
675
+ scriptLength: result.script.length
676
+ });
677
+ // Run iterative testing if not skipped
678
+ if (!skipTest) {
679
+ const testResult = await runStreamingIterativeTest(result.script, task, config, sendEvent);
680
+ // Save to GCS if successful
681
+ let savedScript = null;
682
+ if (testResult.success) {
683
+ try {
684
+ savedScript = await uploadScript(testResult.scriptContent, task, testResult.iterations, testResult.success, ownerId);
685
+ sendEvent('script_saved', { scriptId: savedScript.id, metadata: savedScript });
686
+ }
687
+ catch (err) {
688
+ sendEvent('log', {
689
+ message: `Failed to save script to storage: ${err instanceof Error ? err.message : 'Unknown error'}`,
690
+ level: 'warn'
691
+ });
692
+ }
693
+ }
694
+ // Update task record with final result
695
+ taskRecord.status = testResult.success ? 'completed' : 'error';
696
+ taskRecord.script = testResult.scriptContent;
697
+ taskRecord.iterations = testResult.iterations;
698
+ taskRecord.scriptId = savedScript?.id;
699
+ taskRecord.updatedAt = new Date().toISOString();
700
+ if (!testResult.success) {
701
+ taskRecord.error = 'Script testing failed';
702
+ }
703
+ await saveTask(taskRecord);
704
+ sendEvent('complete', {
705
+ taskId,
706
+ success: testResult.success,
707
+ script: testResult.scriptContent,
708
+ iterations: testResult.iterations,
709
+ skippedDueToStaleCdp: testResult.skippedDueToStaleCdp,
710
+ scriptId: savedScript?.id,
711
+ browserSessionId: browserSession?.sessionId,
712
+ });
713
+ }
714
+ else {
715
+ // Skip testing - just return the script
716
+ const scriptPath = writeScript(result.script, {
717
+ cdpUrl: config.cdpUrl,
718
+ outputDir: config.outputDir,
719
+ });
720
+ // Save to GCS
721
+ let savedScript = null;
722
+ try {
723
+ savedScript = await uploadScript(result.script, task, 0, true, ownerId);
724
+ sendEvent('script_saved', { scriptId: savedScript.id, metadata: savedScript });
725
+ }
726
+ catch (err) {
727
+ sendEvent('log', {
728
+ message: `Failed to save script to storage: ${err instanceof Error ? err.message : 'Unknown error'}`,
729
+ level: 'warn'
730
+ });
731
+ }
732
+ // Update task record with final result
733
+ taskRecord.status = 'completed';
734
+ taskRecord.script = result.script;
735
+ taskRecord.iterations = 0;
736
+ taskRecord.scriptId = savedScript?.id;
737
+ taskRecord.updatedAt = new Date().toISOString();
738
+ await saveTask(taskRecord);
739
+ sendEvent('complete', {
740
+ taskId,
741
+ success: true,
742
+ script: result.script,
743
+ scriptPath,
744
+ scriptId: savedScript?.id,
745
+ browserSessionId: browserSession?.sessionId,
746
+ });
747
+ }
748
+ }
749
+ catch (error) {
750
+ const message = error instanceof Error ? error.message : 'Unknown error';
751
+ taskRecord.status = 'error';
752
+ taskRecord.error = message;
753
+ taskRecord.updatedAt = new Date().toISOString();
754
+ await saveTask(taskRecord);
755
+ sendEvent('error', { message, phase: 'unknown', taskId });
756
+ }
757
+ finally {
758
+ // Clean up browser session (only if we created one)
759
+ if (browserSession && browserCashApiKey) {
760
+ try {
761
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
762
+ sendEvent('log', { message: 'Browser session stopped', level: 'info' });
763
+ }
764
+ catch {
765
+ // Ignore cleanup errors
766
+ }
767
+ }
768
+ }
769
+ reply.raw.end();
770
+ });
771
+ // SSE Streaming endpoint for agentic automation (direct execution, no script generation)
772
+ server.post('/automate/stream', {
773
+ schema: {
774
+ body: {
775
+ type: 'object',
776
+ required: ['task'],
777
+ properties: {
778
+ task: { type: 'string', minLength: 1 },
779
+ browserCashApiKey: { type: 'string', minLength: 1 },
780
+ cdpUrl: { type: 'string', minLength: 1 },
781
+ browserOptions: {
782
+ type: 'object',
783
+ properties: {
784
+ country: { type: 'string', minLength: 2, maxLength: 2 },
785
+ type: { type: 'string', enum: ['consumer_distributed', 'hosted', 'testing'] },
786
+ proxyUrl: { type: 'string' },
787
+ windowSize: { type: 'string' },
788
+ adblock: { type: 'boolean' },
789
+ captchaSolver: { type: 'boolean' },
790
+ },
791
+ },
792
+ },
793
+ },
794
+ },
795
+ }, async (request, reply) => {
796
+ const { task, browserCashApiKey, cdpUrl, browserOptions } = request.body;
797
+ // Validate that either browserCashApiKey or cdpUrl is provided
798
+ if (!browserCashApiKey && !cdpUrl) {
799
+ reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
800
+ reply.raw.end(JSON.stringify({ error: 'Either browserCashApiKey or cdpUrl is required' }));
801
+ return;
802
+ }
803
+ // Hash API key to create owner ID (use a placeholder for direct CDP URL users)
804
+ const ownerId = browserCashApiKey ? hashApiKey(browserCashApiKey) : hashApiKey(cdpUrl);
805
+ // Create task record for persistent storage
806
+ const taskId = generateTaskId();
807
+ const taskRecord = {
808
+ taskId,
809
+ ownerId,
810
+ type: 'automate',
811
+ status: 'pending',
812
+ task,
813
+ createdAt: new Date().toISOString(),
814
+ updatedAt: new Date().toISOString(),
815
+ };
816
+ // Set SSE headers
817
+ reply.raw.writeHead(200, {
818
+ 'Content-Type': 'text/event-stream',
819
+ 'Cache-Control': 'no-cache',
820
+ 'Connection': 'keep-alive',
821
+ 'Access-Control-Allow-Origin': '*',
822
+ });
823
+ const sendEvent = (type, data) => {
824
+ const event = {
825
+ type,
826
+ data,
827
+ timestamp: new Date().toISOString(),
828
+ };
829
+ try {
830
+ reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
831
+ }
832
+ catch {
833
+ // Client may have disconnected, ignore write errors
834
+ }
835
+ };
836
+ // Browser session manager for cleanup
837
+ let browserSession = null;
838
+ let effectiveCdpUrl;
839
+ try {
840
+ // Save initial task record
841
+ await saveTask(taskRecord);
842
+ // Set up browser - either direct CDP URL or create Browser.cash session
843
+ if (cdpUrl) {
844
+ // Use direct CDP URL
845
+ effectiveCdpUrl = cdpUrl;
846
+ sendEvent('log', { message: 'Using direct CDP URL', level: 'info' });
847
+ }
848
+ else if (browserCashApiKey) {
849
+ // Create browser session via Browser.cash
850
+ sendEvent('log', { message: 'Creating Browser.cash session...', level: 'info' });
851
+ try {
852
+ const result = await createAndWaitForSession(browserCashApiKey, browserOptions || {});
853
+ effectiveCdpUrl = result.cdpUrl;
854
+ browserSession = { sessionId: result.session.sessionId, cdpUrl: result.cdpUrl };
855
+ taskRecord.browserSessionId = result.session.sessionId;
856
+ sendEvent('log', {
857
+ message: `Browser session created: ${result.session.sessionId}`,
858
+ level: 'info',
859
+ browserSessionId: result.session.sessionId,
860
+ });
861
+ }
862
+ catch (err) {
863
+ const errorMsg = `Failed to create browser session: ${err instanceof Error ? err.message : 'Unknown error'}`;
864
+ taskRecord.status = 'error';
865
+ taskRecord.error = errorMsg;
866
+ taskRecord.updatedAt = new Date().toISOString();
867
+ await saveTask(taskRecord);
868
+ sendEvent('error', { message: errorMsg, phase: 'browser_setup', taskId });
869
+ reply.raw.end();
870
+ return;
871
+ }
872
+ }
873
+ // Update task to running
874
+ taskRecord.status = 'running';
875
+ taskRecord.updatedAt = new Date().toISOString();
876
+ await saveTask(taskRecord);
877
+ // Load configuration
878
+ const config = loadConfig({
879
+ cdpUrl: effectiveCdpUrl,
880
+ taskDescription: task,
881
+ });
882
+ sendEvent('start', {
883
+ taskId,
884
+ task,
885
+ mode: 'agentic',
886
+ model: config.model,
887
+ browserSessionId: browserSession?.sessionId,
888
+ });
889
+ // Run agentic automation
890
+ const result = await runAgenticAutomation(task, config, sendEvent);
891
+ // Update task record with final result
892
+ taskRecord.status = result.success ? 'completed' : 'error';
893
+ taskRecord.result = result.result;
894
+ taskRecord.summary = result.summary;
895
+ taskRecord.error = result.error;
896
+ taskRecord.updatedAt = new Date().toISOString();
897
+ await saveTask(taskRecord);
898
+ sendEvent('complete', {
899
+ taskId,
900
+ success: result.success,
901
+ result: result.result,
902
+ summary: result.summary,
903
+ error: result.error,
904
+ browserSessionId: browserSession?.sessionId,
905
+ });
906
+ }
907
+ catch (error) {
908
+ const message = error instanceof Error ? error.message : 'Unknown error';
909
+ taskRecord.status = 'error';
910
+ taskRecord.error = message;
911
+ taskRecord.updatedAt = new Date().toISOString();
912
+ await saveTask(taskRecord);
913
+ sendEvent('error', { message, phase: 'unknown', taskId });
914
+ }
915
+ finally {
916
+ // Clean up browser session (only if we created one)
917
+ if (browserSession && browserCashApiKey) {
918
+ try {
919
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
920
+ sendEvent('log', { message: 'Browser session stopped', level: 'info' });
921
+ }
922
+ catch {
923
+ // Ignore cleanup errors
924
+ }
925
+ }
926
+ }
927
+ reply.raw.end();
928
+ });
929
+ // ============================================
930
+ // Script Storage & Execution Endpoints
931
+ // ============================================
932
+ // List all stored scripts
933
+ server.get('/scripts', async (request, reply) => {
934
+ const apiKey = request.headers['x-api-key'] || request.query.apiKey;
935
+ if (!apiKey) {
936
+ reply.code(401);
937
+ return { error: 'API key required (x-api-key header or apiKey query param)' };
938
+ }
939
+ const ownerId = hashApiKey(apiKey);
940
+ try {
941
+ const scripts = await listScripts(ownerId);
942
+ return { scripts };
943
+ }
944
+ catch (error) {
945
+ const message = error instanceof Error ? error.message : 'Unknown error';
946
+ reply.code(500);
947
+ return { error: message };
948
+ }
949
+ });
950
+ // Get a specific script
951
+ server.get('/scripts/:id', async (request, reply) => {
952
+ const { id } = request.params;
953
+ const apiKey = request.headers['x-api-key'] || request.query.apiKey;
954
+ if (!apiKey) {
955
+ reply.code(401);
956
+ return { error: 'API key required (x-api-key header or apiKey query param)' };
957
+ }
958
+ const ownerId = hashApiKey(apiKey);
959
+ try {
960
+ const result = await getScript(id, ownerId);
961
+ if (!result) {
962
+ reply.code(404);
963
+ return { error: 'Script not found' };
964
+ }
965
+ return result;
966
+ }
967
+ catch (error) {
968
+ const message = error instanceof Error ? error.message : 'Unknown error';
969
+ reply.code(500);
970
+ return { error: message };
971
+ }
972
+ });
973
+ server.post('/scripts/:id/run', {
974
+ schema: {
975
+ body: {
976
+ type: 'object',
977
+ properties: {
978
+ browserCashApiKey: { type: 'string', minLength: 1 },
979
+ cdpUrl: { type: 'string', minLength: 1 },
980
+ browserOptions: {
981
+ type: 'object',
982
+ properties: {
983
+ country: { type: 'string', minLength: 2, maxLength: 2 },
984
+ type: { type: 'string', enum: ['consumer_distributed', 'hosted', 'testing'] },
985
+ proxyUrl: { type: 'string' },
986
+ windowSize: { type: 'string' },
987
+ adblock: { type: 'boolean' },
988
+ captchaSolver: { type: 'boolean' },
989
+ },
990
+ },
991
+ },
992
+ },
993
+ },
994
+ }, async (request, reply) => {
995
+ const { id } = request.params;
996
+ const { browserCashApiKey, cdpUrl, browserOptions } = request.body;
997
+ // Validate that either browserCashApiKey or cdpUrl is provided
998
+ if (!browserCashApiKey && !cdpUrl) {
999
+ reply.code(400);
1000
+ return { error: 'Either browserCashApiKey or cdpUrl is required' };
1001
+ }
1002
+ // Hash API key to create owner ID (use a placeholder for direct CDP URL users)
1003
+ const ownerId = browserCashApiKey ? hashApiKey(browserCashApiKey) : hashApiKey(cdpUrl);
1004
+ // Get the script (verifies ownership)
1005
+ const script = await getScript(id, ownerId);
1006
+ if (!script) {
1007
+ reply.code(404);
1008
+ return { error: 'Script not found' };
1009
+ }
1010
+ // Create task record for persistent storage
1011
+ const taskId = generateTaskId();
1012
+ const taskRecord = {
1013
+ taskId,
1014
+ ownerId,
1015
+ type: 'run_script',
1016
+ status: 'pending',
1017
+ task: script.metadata.task,
1018
+ scriptId: id,
1019
+ createdAt: new Date().toISOString(),
1020
+ updatedAt: new Date().toISOString(),
1021
+ };
1022
+ // Set SSE headers
1023
+ reply.raw.writeHead(200, {
1024
+ 'Content-Type': 'text/event-stream',
1025
+ 'Cache-Control': 'no-cache',
1026
+ 'Connection': 'keep-alive',
1027
+ 'Access-Control-Allow-Origin': '*',
1028
+ });
1029
+ const sendEvent = (type, data) => {
1030
+ const event = {
1031
+ type,
1032
+ data,
1033
+ timestamp: new Date().toISOString(),
1034
+ };
1035
+ try {
1036
+ reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
1037
+ }
1038
+ catch {
1039
+ // Client may have disconnected, ignore write errors
1040
+ }
1041
+ };
1042
+ // Browser session manager for cleanup
1043
+ let browserSession = null;
1044
+ let effectiveCdpUrl;
1045
+ // Collect output for task record
1046
+ let collectedOutput = '';
1047
+ // Save initial task record
1048
+ await saveTask(taskRecord);
1049
+ // Set up browser - either direct CDP URL or create Browser.cash session
1050
+ if (cdpUrl) {
1051
+ // Use direct CDP URL
1052
+ effectiveCdpUrl = cdpUrl;
1053
+ sendEvent('log', { message: 'Using direct CDP URL', level: 'info' });
1054
+ }
1055
+ else if (browserCashApiKey) {
1056
+ // Create browser session via Browser.cash
1057
+ sendEvent('log', { message: 'Creating Browser.cash session...', level: 'info' });
1058
+ try {
1059
+ const result = await createAndWaitForSession(browserCashApiKey, browserOptions || {});
1060
+ effectiveCdpUrl = result.cdpUrl;
1061
+ browserSession = { sessionId: result.session.sessionId, cdpUrl: result.cdpUrl };
1062
+ taskRecord.browserSessionId = result.session.sessionId;
1063
+ sendEvent('log', {
1064
+ message: `Browser session created: ${result.session.sessionId}`,
1065
+ level: 'info',
1066
+ browserSessionId: result.session.sessionId,
1067
+ });
1068
+ }
1069
+ catch (err) {
1070
+ const errorMsg = `Failed to create browser session: ${err instanceof Error ? err.message : 'Unknown error'}`;
1071
+ taskRecord.status = 'error';
1072
+ taskRecord.error = errorMsg;
1073
+ taskRecord.updatedAt = new Date().toISOString();
1074
+ await saveTask(taskRecord);
1075
+ sendEvent('error', { message: errorMsg, phase: 'browser_setup', taskId });
1076
+ reply.raw.end();
1077
+ return;
1078
+ }
1079
+ }
1080
+ // Update task to running
1081
+ taskRecord.status = 'running';
1082
+ taskRecord.updatedAt = new Date().toISOString();
1083
+ await saveTask(taskRecord);
1084
+ sendEvent('start', {
1085
+ taskId,
1086
+ scriptId: id,
1087
+ task: script.metadata.task,
1088
+ scriptSize: script.metadata.scriptSize,
1089
+ browserSessionId: browserSession?.sessionId,
1090
+ });
1091
+ // Write script to temp file
1092
+ const tempScript = `/tmp/run-${id}-${Date.now()}.sh`;
1093
+ fs.writeFileSync(tempScript, script.content);
1094
+ fs.chmodSync(tempScript, '755');
1095
+ try {
1096
+ // Execute the script with CDP_URL set
1097
+ const proc = spawn('bash', [tempScript], {
1098
+ env: {
1099
+ ...process.env,
1100
+ CDP_URL: effectiveCdpUrl,
1101
+ },
1102
+ stdio: ['ignore', 'pipe', 'pipe'],
1103
+ });
1104
+ proc.stdout?.on('data', (data) => {
1105
+ const text = data.toString();
1106
+ collectedOutput += text;
1107
+ sendEvent('output', { stream: 'stdout', text });
1108
+ });
1109
+ proc.stderr?.on('data', (data) => {
1110
+ const text = data.toString();
1111
+ collectedOutput += `[stderr] ${text}`;
1112
+ sendEvent('output', { stream: 'stderr', text });
1113
+ });
1114
+ proc.on('close', async (code) => {
1115
+ // Clean up temp file
1116
+ try {
1117
+ fs.unlinkSync(tempScript);
1118
+ }
1119
+ catch {
1120
+ // Ignore
1121
+ }
1122
+ // Clean up browser session (only if we created one)
1123
+ if (browserSession && browserCashApiKey) {
1124
+ try {
1125
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
1126
+ sendEvent('log', { message: 'Browser session stopped', level: 'info' });
1127
+ }
1128
+ catch {
1129
+ // Ignore cleanup errors
1130
+ }
1131
+ }
1132
+ // Update task record with final result
1133
+ taskRecord.status = code === 0 ? 'completed' : 'error';
1134
+ taskRecord.exitCode = code ?? undefined;
1135
+ taskRecord.output = collectedOutput;
1136
+ taskRecord.updatedAt = new Date().toISOString();
1137
+ if (code !== 0) {
1138
+ taskRecord.error = `Script exited with code ${code}`;
1139
+ }
1140
+ await saveTask(taskRecord);
1141
+ sendEvent('complete', {
1142
+ taskId,
1143
+ exitCode: code,
1144
+ success: code === 0,
1145
+ browserSessionId: browserSession?.sessionId,
1146
+ });
1147
+ reply.raw.end();
1148
+ });
1149
+ proc.on('error', async (err) => {
1150
+ try {
1151
+ fs.unlinkSync(tempScript);
1152
+ }
1153
+ catch {
1154
+ // Ignore
1155
+ }
1156
+ // Clean up browser session (only if we created one)
1157
+ if (browserSession && browserCashApiKey) {
1158
+ try {
1159
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
1160
+ }
1161
+ catch {
1162
+ // Ignore cleanup errors
1163
+ }
1164
+ }
1165
+ // Update task record with error
1166
+ taskRecord.status = 'error';
1167
+ taskRecord.error = err.message;
1168
+ taskRecord.output = collectedOutput;
1169
+ taskRecord.updatedAt = new Date().toISOString();
1170
+ await saveTask(taskRecord);
1171
+ sendEvent('error', { message: err.message, taskId });
1172
+ reply.raw.end();
1173
+ });
1174
+ }
1175
+ catch (error) {
1176
+ // Clean up browser session (only if we created one)
1177
+ if (browserSession && browserCashApiKey) {
1178
+ try {
1179
+ await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
1180
+ }
1181
+ catch {
1182
+ // Ignore cleanup errors
1183
+ }
1184
+ }
1185
+ const message = error instanceof Error ? error.message : 'Unknown error';
1186
+ taskRecord.status = 'error';
1187
+ taskRecord.error = message;
1188
+ taskRecord.updatedAt = new Date().toISOString();
1189
+ await saveTask(taskRecord);
1190
+ sendEvent('error', { message, taskId });
1191
+ reply.raw.end();
1192
+ }
1193
+ });
1194
+ /**
1195
+ * Generate a unique session ID
1196
+ */
1197
+ function generateSessionId() {
1198
+ const timestamp = Date.now().toString(36);
1199
+ const random = Math.random().toString(36).substring(2, 8);
1200
+ return `session-${timestamp}-${random}`;
1201
+ }
1202
+ /**
1203
+ * Streaming version of Claude script generation
1204
+ */
1205
+ async function runClaudeStreamingGeneration(taskPrompt, config, sendEvent) {
1206
+ const sessionId = generateSessionId();
1207
+ // Ensure sessions directory exists
1208
+ if (!fs.existsSync(config.sessionsDir)) {
1209
+ fs.mkdirSync(config.sessionsDir, { recursive: true });
1210
+ }
1211
+ // Get the system prompt and combine with task
1212
+ const systemPrompt = getSystemPrompt(config.cdpUrl);
1213
+ const fullPrompt = `${systemPrompt}\n\n## Your Task\n\n${taskPrompt}`;
1214
+ // Write full prompt to a file
1215
+ const promptFile = path.join(config.sessionsDir, `${sessionId}-prompt.txt`);
1216
+ fs.writeFileSync(promptFile, fullPrompt);
1217
+ sendEvent('log', { message: `Starting Claude session: ${sessionId}`, level: 'info' });
1218
+ return new Promise((resolve) => {
1219
+ let output = '';
1220
+ const env = {
1221
+ ...process.env,
1222
+ CDP_URL: config.cdpUrl,
1223
+ };
1224
+ const shellCmd = `cat "${promptFile}" | claude -p --model ${config.model} --max-turns ${config.maxTurns} --allowedTools "Bash" --output-format stream-json --verbose`;
1225
+ const claude = spawn('bash', ['-c', shellCmd], {
1226
+ env,
1227
+ stdio: ['ignore', 'pipe', 'pipe'],
1228
+ detached: false,
1229
+ });
1230
+ claude.stdout?.on('data', (data) => {
1231
+ const text = data.toString();
1232
+ output += text;
1233
+ // Parse and stream each line
1234
+ const lines = text.split('\n');
1235
+ for (const line of lines) {
1236
+ if (!line.trim())
1237
+ continue;
1238
+ try {
1239
+ const json = JSON.parse(line);
1240
+ // Stream different event types
1241
+ if (json.type === 'assistant' && json.message?.content) {
1242
+ for (const block of json.message.content) {
1243
+ if (block.type === 'text') {
1244
+ sendEvent('claude_output', { type: 'text', content: block.text });
1245
+ }
1246
+ else if (block.type === 'tool_use') {
1247
+ sendEvent('claude_output', { type: 'tool_use', name: block.name, input: block.input });
1248
+ }
1249
+ }
1250
+ }
1251
+ else if (json.type === 'tool_result') {
1252
+ sendEvent('claude_output', { type: 'tool_result', content: json.content?.substring(0, 500) });
1253
+ }
1254
+ }
1255
+ catch {
1256
+ // Non-JSON output, log as raw
1257
+ if (line.trim()) {
1258
+ sendEvent('log', { message: line, level: 'debug' });
1259
+ }
1260
+ }
1261
+ }
1262
+ });
1263
+ claude.stderr?.on('data', (data) => {
1264
+ const text = data.toString();
1265
+ sendEvent('log', { message: text, level: 'warn' });
1266
+ });
1267
+ claude.on('close', (code) => {
1268
+ // Clean up temp files
1269
+ try {
1270
+ if (fs.existsSync(promptFile)) {
1271
+ fs.unlinkSync(promptFile);
1272
+ }
1273
+ }
1274
+ catch {
1275
+ // Ignore cleanup errors
1276
+ }
1277
+ // Extract the script from Claude's output
1278
+ const script = extractScriptFromOutput(output);
1279
+ if (script) {
1280
+ sendEvent('log', { message: 'Successfully extracted script from Claude output', level: 'info' });
1281
+ }
1282
+ else {
1283
+ sendEvent('log', { message: 'Could not extract script from Claude output', level: 'warn' });
1284
+ }
1285
+ resolve({
1286
+ success: code === 0 && script !== null,
1287
+ script,
1288
+ error: code !== 0 ? `Exit code: ${code}` : undefined,
1289
+ });
1290
+ });
1291
+ claude.on('error', (err) => {
1292
+ try {
1293
+ if (fs.existsSync(promptFile)) {
1294
+ fs.unlinkSync(promptFile);
1295
+ }
1296
+ }
1297
+ catch {
1298
+ // Ignore
1299
+ }
1300
+ resolve({
1301
+ success: false,
1302
+ script: null,
1303
+ error: err.message,
1304
+ });
1305
+ });
1306
+ });
1307
+ }
1308
+ /**
1309
+ * Streaming version of iterative testing
1310
+ */
1311
+ async function runStreamingIterativeTest(scriptContent, originalTask, config, sendEvent) {
1312
+ const maxIterations = config.maxFixIterations;
1313
+ sendEvent('log', { message: `Starting iterative testing (max ${maxIterations} attempts)`, level: 'info' });
1314
+ // For now, delegate to the existing implementation
1315
+ // In a full implementation, we'd refactor iterative-tester.ts to support streaming
1316
+ const result = await runIterativeTest(scriptContent, originalTask, config);
1317
+ // Send iteration results
1318
+ for (let i = 1; i <= result.iterations; i++) {
1319
+ sendEvent('iteration_result', {
1320
+ iteration: i,
1321
+ maxIterations,
1322
+ success: i === result.iterations ? result.success : false,
1323
+ });
1324
+ }
1325
+ return {
1326
+ success: result.success,
1327
+ iterations: result.iterations,
1328
+ scriptContent: result.scriptContent,
1329
+ skippedDueToStaleCdp: result.skippedDueToStaleCdp,
1330
+ };
1331
+ }
1332
+ /**
1333
+ * Run agentic automation - Claude performs the task directly and returns results.
1334
+ * Unlike script generation mode, this does not create a reusable script.
1335
+ */
1336
+ async function runAgenticAutomation(taskPrompt, config, sendEvent) {
1337
+ const sessionId = generateSessionId();
1338
+ // Ensure sessions directory exists
1339
+ if (!fs.existsSync(config.sessionsDir)) {
1340
+ fs.mkdirSync(config.sessionsDir, { recursive: true });
1341
+ }
1342
+ // Get the agentic prompt and combine with task
1343
+ const systemPrompt = getAgenticPrompt(config.cdpUrl);
1344
+ const fullPrompt = `${systemPrompt}\n\n## Your Task\n\n${taskPrompt}`;
1345
+ // Write full prompt to a file
1346
+ const promptFile = path.join(config.sessionsDir, `${sessionId}-agentic-prompt.txt`);
1347
+ fs.writeFileSync(promptFile, fullPrompt);
1348
+ sendEvent('log', { message: `Starting agentic session: ${sessionId}`, level: 'info' });
1349
+ return new Promise((resolve) => {
1350
+ let output = '';
1351
+ const env = {
1352
+ ...process.env,
1353
+ CDP_URL: config.cdpUrl,
1354
+ };
1355
+ const shellCmd = `cat "${promptFile}" | claude -p --model ${config.model} --max-turns ${config.maxTurns} --allowedTools "Bash" --output-format stream-json --verbose`;
1356
+ const claude = spawn('bash', ['-c', shellCmd], {
1357
+ env,
1358
+ stdio: ['ignore', 'pipe', 'pipe'],
1359
+ detached: false,
1360
+ });
1361
+ claude.stdout?.on('data', (data) => {
1362
+ const text = data.toString();
1363
+ output += text;
1364
+ // Parse and stream each line
1365
+ const lines = text.split('\n');
1366
+ for (const line of lines) {
1367
+ if (!line.trim())
1368
+ continue;
1369
+ try {
1370
+ const json = JSON.parse(line);
1371
+ // Stream different event types
1372
+ if (json.type === 'assistant' && json.message?.content) {
1373
+ for (const block of json.message.content) {
1374
+ if (block.type === 'text') {
1375
+ sendEvent('claude_output', { type: 'text', content: block.text });
1376
+ }
1377
+ else if (block.type === 'tool_use') {
1378
+ sendEvent('claude_output', { type: 'tool_use', name: block.name, input: block.input });
1379
+ }
1380
+ }
1381
+ }
1382
+ else if (json.type === 'tool_result') {
1383
+ sendEvent('claude_output', { type: 'tool_result', content: json.content?.substring(0, 500) });
1384
+ }
1385
+ }
1386
+ catch {
1387
+ // Non-JSON output, log as raw
1388
+ if (line.trim()) {
1389
+ sendEvent('log', { message: line, level: 'debug' });
1390
+ }
1391
+ }
1392
+ }
1393
+ });
1394
+ claude.stderr?.on('data', (data) => {
1395
+ const text = data.toString();
1396
+ sendEvent('log', { message: text, level: 'warn' });
1397
+ });
1398
+ claude.on('close', (code) => {
1399
+ // Clean up temp files
1400
+ try {
1401
+ if (fs.existsSync(promptFile)) {
1402
+ fs.unlinkSync(promptFile);
1403
+ }
1404
+ }
1405
+ catch {
1406
+ // Ignore cleanup errors
1407
+ }
1408
+ // Extract the JSON result from Claude's output
1409
+ const result = extractAgenticResult(output);
1410
+ if (result) {
1411
+ sendEvent('log', { message: 'Successfully extracted result from Claude output', level: 'info' });
1412
+ resolve({
1413
+ success: result.success,
1414
+ result: result.data,
1415
+ summary: result.summary,
1416
+ error: result.success ? undefined : result.error,
1417
+ });
1418
+ }
1419
+ else {
1420
+ sendEvent('log', { message: 'Could not extract structured result from Claude output', level: 'warn' });
1421
+ resolve({
1422
+ success: false,
1423
+ result: null,
1424
+ error: code !== 0 ? `Exit code: ${code}` : 'Failed to extract result from output',
1425
+ });
1426
+ }
1427
+ });
1428
+ claude.on('error', (err) => {
1429
+ try {
1430
+ if (fs.existsSync(promptFile)) {
1431
+ fs.unlinkSync(promptFile);
1432
+ }
1433
+ }
1434
+ catch {
1435
+ // Ignore
1436
+ }
1437
+ resolve({
1438
+ success: false,
1439
+ result: null,
1440
+ error: err.message,
1441
+ });
1442
+ });
1443
+ });
1444
+ }
1445
+ /**
1446
+ * Extract the final JSON result from agentic automation output.
1447
+ * Looks for the structured JSON output format in Claude's response.
1448
+ */
1449
+ function extractAgenticResult(output) {
1450
+ const textContent = extractTextFromStreamJson(output);
1451
+ // Look for JSON code blocks with our expected format
1452
+ const jsonBlockPattern = /```json\s*([\s\S]*?)```/g;
1453
+ let lastValidResult = null;
1454
+ let match;
1455
+ while ((match = jsonBlockPattern.exec(textContent)) !== null) {
1456
+ try {
1457
+ const jsonStr = match[1].trim();
1458
+ const parsed = JSON.parse(jsonStr);
1459
+ // Check if this looks like our expected output format
1460
+ if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
1461
+ lastValidResult = {
1462
+ success: parsed.success === true,
1463
+ data: parsed.data,
1464
+ summary: parsed.summary,
1465
+ error: parsed.error,
1466
+ attempted: parsed.attempted,
1467
+ };
1468
+ }
1469
+ }
1470
+ catch {
1471
+ // Not valid JSON, continue looking
1472
+ }
1473
+ }
1474
+ // If we found a structured result, return it
1475
+ if (lastValidResult) {
1476
+ return lastValidResult;
1477
+ }
1478
+ // Fallback: try to find any JSON that looks like extracted data
1479
+ // This handles cases where Claude outputs data directly without our wrapper format
1480
+ const anyJsonPattern = /```(?:json)?\s*([\s\S]*?)```/g;
1481
+ while ((match = anyJsonPattern.exec(textContent)) !== null) {
1482
+ try {
1483
+ const jsonStr = match[1].trim();
1484
+ // Skip if it doesn't look like JSON
1485
+ if (!jsonStr.startsWith('{') && !jsonStr.startsWith('['))
1486
+ continue;
1487
+ const parsed = JSON.parse(jsonStr);
1488
+ // If it's an array or object with data, wrap it in our format
1489
+ if (Array.isArray(parsed) || (typeof parsed === 'object' && parsed !== null)) {
1490
+ return {
1491
+ success: true,
1492
+ data: parsed,
1493
+ summary: `Extracted ${Array.isArray(parsed) ? parsed.length + ' items' : 'data'}`,
1494
+ };
1495
+ }
1496
+ }
1497
+ catch {
1498
+ // Not valid JSON, continue looking
1499
+ }
1500
+ }
1501
+ return null;
1502
+ }
1503
+ /**
1504
+ * Extract text content from stream-json format output
1505
+ */
1506
+ function extractTextFromStreamJson(output) {
1507
+ const lines = output.split('\n');
1508
+ const textParts = [];
1509
+ for (const line of lines) {
1510
+ if (!line.trim().startsWith('{'))
1511
+ continue;
1512
+ try {
1513
+ const json = JSON.parse(line);
1514
+ if (json.type === 'assistant' && json.message?.content) {
1515
+ for (const block of json.message.content) {
1516
+ if (block.type === 'text') {
1517
+ textParts.push(block.text);
1518
+ }
1519
+ }
1520
+ }
1521
+ if (json.type === 'result' && json.result) {
1522
+ textParts.push(json.result);
1523
+ }
1524
+ }
1525
+ catch {
1526
+ textParts.push(line);
1527
+ }
1528
+ }
1529
+ return textParts.join('\n');
1530
+ }
1531
+ /**
1532
+ * Extract a bash script from Claude's output.
1533
+ */
1534
+ function extractScriptFromOutput(output) {
1535
+ const textContent = extractTextFromStreamJson(output);
1536
+ const codeBlockPatterns = [
1537
+ new RegExp('```bash\\n([\\s\\S]*?)```', 'g'),
1538
+ new RegExp('```sh\\n([\\s\\S]*?)```', 'g'),
1539
+ new RegExp('```shell\\n([\\s\\S]*?)```', 'g'),
1540
+ new RegExp('```\\n(#!/bin/bash[\\s\\S]*?)```', 'g'),
1541
+ ];
1542
+ let bestScript = null;
1543
+ let bestScore = 0;
1544
+ for (const pattern of codeBlockPatterns) {
1545
+ let match;
1546
+ while ((match = pattern.exec(textContent)) !== null) {
1547
+ const script = match[1].trim();
1548
+ const score = scoreScript(script);
1549
+ if (score > bestScore) {
1550
+ bestScore = score;
1551
+ bestScript = script;
1552
+ }
1553
+ }
1554
+ }
1555
+ if (bestScript) {
1556
+ if (!bestScript.startsWith('#!/')) {
1557
+ bestScript = '#!/bin/bash\nset -e\n\nCDP="${CDP_URL:?Required}"\n\n' + bestScript;
1558
+ }
1559
+ return bestScript;
1560
+ }
1561
+ return null;
1562
+ }
1563
+ /**
1564
+ * Score a script based on how complete it looks
1565
+ */
1566
+ function scoreScript(script) {
1567
+ let score = 0;
1568
+ if (!script.includes('agent-browser'))
1569
+ return 0;
1570
+ if (script.includes('#!/bin/bash'))
1571
+ score += 10;
1572
+ if (script.includes('CDP=') || script.includes('$CDP'))
1573
+ score += 10;
1574
+ if (script.includes('open "http'))
1575
+ score += 20;
1576
+ if (script.includes('eval "'))
1577
+ score += 20;
1578
+ if (script.includes('FINAL RESULTS') || script.includes('echo "$'))
1579
+ score += 10;
1580
+ const snapshotCount = (script.match(/snapshot/g) || []).length;
1581
+ score -= snapshotCount * 5;
1582
+ const refCount = (script.match(/@e\d+/g) || []).length;
1583
+ score -= refCount * 10;
1584
+ const openCount = (script.match(/\bopen\s+"/g) || []).length;
1585
+ const evalCount = (script.match(/\beval\s+"/g) || []).length;
1586
+ score += openCount * 5 + evalCount * 10;
1587
+ return score;
1588
+ }
1589
+ // ============================================
1590
+ // MCP HTTP Transport Endpoint
1591
+ // ============================================
1592
+ // MCP tool definitions
1593
+ const mcpTools = [
1594
+ {
1595
+ name: 'browser_automate',
1596
+ description: 'Perform one-off browser automation task. Claude will navigate to websites, interact with elements, and extract data directly. Returns results immediately (no script generated).',
1597
+ inputSchema: {
1598
+ type: 'object',
1599
+ properties: {
1600
+ task: { type: 'string', description: 'Description of the automation task' },
1601
+ browserOptions: {
1602
+ type: 'object',
1603
+ description: 'Browser session options',
1604
+ properties: {
1605
+ country: { type: 'string', description: '2-letter ISO country code' },
1606
+ adblock: { type: 'boolean', description: 'Enable ad-blocking' },
1607
+ captchaSolver: { type: 'boolean', description: 'Enable CAPTCHA solving' },
1608
+ },
1609
+ },
1610
+ waitForCompletion: { type: 'boolean', description: 'Wait for results (default: true for HTTP MCP)' },
1611
+ maxWaitSeconds: { type: 'number', description: 'Max wait time (default: 120)' },
1612
+ },
1613
+ required: ['task'],
1614
+ },
1615
+ },
1616
+ {
1617
+ name: 'generate_script',
1618
+ description: 'Generate a reusable browser automation script that can be run multiple times.',
1619
+ inputSchema: {
1620
+ type: 'object',
1621
+ properties: {
1622
+ task: { type: 'string', description: 'Description of what the script should do' },
1623
+ browserOptions: { type: 'object', description: 'Browser session options' },
1624
+ skipTest: { type: 'boolean', description: 'Skip iterative testing (default: false)' },
1625
+ waitForCompletion: { type: 'boolean', description: 'Wait for script generation (default: true)' },
1626
+ maxWaitSeconds: { type: 'number', description: 'Max wait time (default: 300)' },
1627
+ },
1628
+ required: ['task'],
1629
+ },
1630
+ },
1631
+ {
1632
+ name: 'list_scripts',
1633
+ description: 'List all stored automation scripts.',
1634
+ inputSchema: { type: 'object', properties: {}, required: [] },
1635
+ },
1636
+ {
1637
+ name: 'get_script',
1638
+ description: 'Get details of a specific script including content.',
1639
+ inputSchema: {
1640
+ type: 'object',
1641
+ properties: {
1642
+ scriptId: { type: 'string', description: 'The script ID' },
1643
+ },
1644
+ required: ['scriptId'],
1645
+ },
1646
+ },
1647
+ {
1648
+ name: 'run_script',
1649
+ description: 'Execute a stored automation script.',
1650
+ inputSchema: {
1651
+ type: 'object',
1652
+ properties: {
1653
+ scriptId: { type: 'string', description: 'The script ID to run' },
1654
+ browserOptions: { type: 'object', description: 'Browser session options' },
1655
+ waitForCompletion: { type: 'boolean', description: 'Wait for execution (default: true)' },
1656
+ maxWaitSeconds: { type: 'number', description: 'Max wait time (default: 120)' },
1657
+ },
1658
+ required: ['scriptId'],
1659
+ },
1660
+ },
1661
+ {
1662
+ name: 'get_task',
1663
+ description: 'Get the status and result of a task by ID.',
1664
+ inputSchema: {
1665
+ type: 'object',
1666
+ properties: {
1667
+ taskId: { type: 'string', description: 'The task ID' },
1668
+ },
1669
+ required: ['taskId'],
1670
+ },
1671
+ },
1672
+ {
1673
+ name: 'list_tasks',
1674
+ description: 'List recent tasks.',
1675
+ inputSchema: { type: 'object', properties: {}, required: [] },
1676
+ },
1677
+ ];
1678
+ // Helper to consume SSE stream and wait for completion
1679
+ async function consumeMcpStream(response, apiKey, endpoint, body, maxWaitMs) {
1680
+ const apiUrl = `http://localhost:${process.env.PORT || 3000}${endpoint}`;
1681
+ return new Promise(async (resolve) => {
1682
+ try {
1683
+ const fetchResponse = await fetch(apiUrl, {
1684
+ method: 'POST',
1685
+ headers: { 'Content-Type': 'application/json' },
1686
+ body: JSON.stringify({ ...body, browserCashApiKey: apiKey }),
1687
+ });
1688
+ if (!fetchResponse.ok) {
1689
+ const error = await fetchResponse.json().catch(() => ({ error: 'Request failed' }));
1690
+ resolve({ success: false, data: null, error: error.error || 'Request failed' });
1691
+ return;
1692
+ }
1693
+ const reader = fetchResponse.body?.getReader();
1694
+ if (!reader) {
1695
+ resolve({ success: false, data: null, error: 'No response body' });
1696
+ return;
1697
+ }
1698
+ const decoder = new TextDecoder();
1699
+ let buffer = '';
1700
+ const timeout = setTimeout(() => {
1701
+ reader.cancel();
1702
+ resolve({ success: false, data: null, error: 'Timeout' });
1703
+ }, maxWaitMs);
1704
+ while (true) {
1705
+ const { value, done } = await reader.read();
1706
+ if (done)
1707
+ break;
1708
+ buffer += decoder.decode(value, { stream: true });
1709
+ const lines = buffer.split('\n');
1710
+ buffer = lines.pop() || '';
1711
+ for (const line of lines) {
1712
+ if (line.startsWith('data: ')) {
1713
+ try {
1714
+ const event = JSON.parse(line.slice(6));
1715
+ if (event.type === 'complete') {
1716
+ clearTimeout(timeout);
1717
+ resolve({ success: event.data?.success ?? true, data: event.data });
1718
+ return;
1719
+ }
1720
+ if (event.type === 'error') {
1721
+ clearTimeout(timeout);
1722
+ resolve({ success: false, data: null, error: event.data?.message || 'Error' });
1723
+ return;
1724
+ }
1725
+ }
1726
+ catch {
1727
+ // Ignore parse errors
1728
+ }
1729
+ }
1730
+ }
1731
+ }
1732
+ clearTimeout(timeout);
1733
+ resolve({ success: false, data: null, error: 'Stream ended without completion' });
1734
+ }
1735
+ catch (err) {
1736
+ resolve({ success: false, data: null, error: err instanceof Error ? err.message : 'Unknown error' });
1737
+ }
1738
+ });
1739
+ }
1740
+ // MCP HTTP endpoint
1741
+ server.post('/mcp', async (request, reply) => {
1742
+ const apiKey = request.headers['x-api-key'];
1743
+ if (!apiKey) {
1744
+ return reply.code(401).send({
1745
+ jsonrpc: '2.0',
1746
+ id: request.body?.id || null,
1747
+ error: { code: -32001, message: 'x-api-key header required' },
1748
+ });
1749
+ }
1750
+ const { jsonrpc, id, method, params } = request.body;
1751
+ if (jsonrpc !== '2.0') {
1752
+ return reply.code(400).send({
1753
+ jsonrpc: '2.0',
1754
+ id,
1755
+ error: { code: -32600, message: 'Invalid JSON-RPC version' },
1756
+ });
1757
+ }
1758
+ const ownerId = hashApiKey(apiKey);
1759
+ // Handle MCP methods
1760
+ switch (method) {
1761
+ case 'initialize':
1762
+ return {
1763
+ jsonrpc: '2.0',
1764
+ id,
1765
+ result: {
1766
+ protocolVersion: '2024-11-05',
1767
+ serverInfo: { name: 'claude-gen', version: '1.0.0' },
1768
+ capabilities: { tools: {} },
1769
+ },
1770
+ };
1771
+ case 'tools/list':
1772
+ return {
1773
+ jsonrpc: '2.0',
1774
+ id,
1775
+ result: { tools: mcpTools },
1776
+ };
1777
+ case 'tools/call': {
1778
+ const { name, arguments: args = {} } = (params || {});
1779
+ const maxWait = (args.maxWaitSeconds || 120) * 1000;
1780
+ try {
1781
+ let result;
1782
+ switch (name) {
1783
+ case 'browser_automate':
1784
+ result = await consumeMcpStream(reply, apiKey, '/automate/stream', {
1785
+ task: args.task,
1786
+ browserOptions: args.browserOptions,
1787
+ }, maxWait);
1788
+ break;
1789
+ case 'generate_script':
1790
+ result = await consumeMcpStream(reply, apiKey, '/generate/stream', {
1791
+ task: args.task,
1792
+ browserOptions: args.browserOptions,
1793
+ skipTest: args.skipTest,
1794
+ }, (args.maxWaitSeconds || 300) * 1000);
1795
+ break;
1796
+ case 'run_script':
1797
+ result = await consumeMcpStream(reply, apiKey, `/scripts/${args.scriptId}/run`, {
1798
+ browserOptions: args.browserOptions,
1799
+ }, maxWait);
1800
+ break;
1801
+ case 'list_scripts': {
1802
+ const scripts = await listScripts(ownerId);
1803
+ result = { success: true, data: { scripts } };
1804
+ break;
1805
+ }
1806
+ case 'get_script': {
1807
+ const script = await getScript(args.scriptId, ownerId);
1808
+ if (!script) {
1809
+ result = { success: false, data: null, error: 'Script not found' };
1810
+ }
1811
+ else {
1812
+ result = { success: true, data: script };
1813
+ }
1814
+ break;
1815
+ }
1816
+ case 'get_task': {
1817
+ const task = await getTask(args.taskId, ownerId);
1818
+ if (!task) {
1819
+ result = { success: false, data: null, error: 'Task not found' };
1820
+ }
1821
+ else {
1822
+ result = { success: true, data: task };
1823
+ }
1824
+ break;
1825
+ }
1826
+ case 'list_tasks': {
1827
+ const tasks = await listTasks(ownerId);
1828
+ result = { success: true, data: { tasks } };
1829
+ break;
1830
+ }
1831
+ default:
1832
+ return {
1833
+ jsonrpc: '2.0',
1834
+ id,
1835
+ error: { code: -32601, message: `Unknown tool: ${name}` },
1836
+ };
1837
+ }
1838
+ return {
1839
+ jsonrpc: '2.0',
1840
+ id,
1841
+ result: {
1842
+ content: [{ type: 'text', text: JSON.stringify(result.data, null, 2) }],
1843
+ },
1844
+ };
1845
+ }
1846
+ catch (err) {
1847
+ return {
1848
+ jsonrpc: '2.0',
1849
+ id,
1850
+ error: { code: -32000, message: err instanceof Error ? err.message : 'Unknown error' },
1851
+ };
1852
+ }
1853
+ }
1854
+ default:
1855
+ return {
1856
+ jsonrpc: '2.0',
1857
+ id,
1858
+ error: { code: -32601, message: `Method not found: ${method}` },
1859
+ };
1860
+ }
1861
+ });
1862
+ // Start server
1863
+ async function start() {
1864
+ const port = parseInt(process.env.PORT || '3000', 10);
1865
+ const host = process.env.HOST || '0.0.0.0';
1866
+ try {
1867
+ await server.listen({ port, host });
1868
+ console.log(`
1869
+ ===========================================
1870
+ Claude-Gen HTTP API Server
1871
+ ===========================================
1872
+
1873
+ Server running at http://${host}:${port}
1874
+
1875
+ Endpoints:
1876
+ GET /health - Health check
1877
+ POST /mcp - MCP HTTP transport (for Claude Desktop/Cursor)
1878
+ POST /test-cdp - Test CDP connectivity
1879
+ POST /generate - Generate browser automation script (JSON response)
1880
+ POST /generate/stream - Generate with real-time SSE streaming
1881
+ POST /automate/stream - Agentic mode: perform task and return results directly
1882
+
1883
+ GET /scripts - List all stored scripts
1884
+ GET /scripts/:id - Get a specific script
1885
+ POST /scripts/:id/run - Run a stored script with SSE streaming
1886
+
1887
+ GET /tasks - List recent tasks (for result retrieval)
1888
+ GET /tasks/:taskId - Get task status and result (if client disconnects)
1889
+
1890
+ MCP Integration (Claude Code):
1891
+ claude mcp add --transport http claude-gen https://claude-gen-api-264851422957.us-central1.run.app/mcp -H "x-api-key: YOUR_API_KEY"
1892
+
1893
+ Browser Authentication (choose one):
1894
+ - cdpUrl: Direct WebSocket CDP URL
1895
+ - browserCashApiKey: Browser.cash API key (creates session automatically)
1896
+
1897
+ Example requests:
1898
+
1899
+ # Generate with Browser.cash API key (recommended)
1900
+ curl -X POST http://localhost:${port}/generate/stream \\
1901
+ -H "Content-Type: application/json" \\
1902
+ -d '{
1903
+ "task": "Go to example.com and get the title",
1904
+ "browserCashApiKey": "your-api-key",
1905
+ "browserOptions": {"country": "US", "adblock": true}
1906
+ }'
1907
+
1908
+ # Generate with direct CDP URL (legacy)
1909
+ curl -X POST http://localhost:${port}/generate/stream \\
1910
+ -H "Content-Type: application/json" \\
1911
+ -d '{"task": "Go to example.com and get the title", "cdpUrl": "wss://..."}'
1912
+
1913
+ # Run stored script with Browser.cash
1914
+ curl -X POST http://localhost:${port}/scripts/script-abc123/run \\
1915
+ -H "Content-Type: application/json" \\
1916
+ -d '{"browserCashApiKey": "your-api-key"}'
1917
+
1918
+ Environment variables:
1919
+ PORT - Server port (default: 3000)
1920
+ HOST - Server host (default: 0.0.0.0)
1921
+ GCS_BUCKET - GCS bucket for script storage (default: claude-gen-scripts)
1922
+ BROWSER_CASH_API_URL - Browser.cash API URL (default: https://api.browser.cash)
1923
+ ANTHROPIC_API_KEY - Required for Claude API calls
1924
+ MODEL - Claude model (default: claude-opus-4-5-20251101)
1925
+ MAX_TURNS - Max Claude turns (default: 15)
1926
+ MAX_FIX_ITERATIONS - Max fix attempts (default: 5)
1927
+ `);
1928
+ }
1929
+ catch (err) {
1930
+ server.log.error(err);
1931
+ process.exit(1);
1932
+ }
1933
+ }
1934
+ start();