surf-cli 2.4.0 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -378,18 +378,17 @@ Auto-cleanup: 24 hours TTL, 200MB max.
378
378
  Execute multi-step browser automation as a single command:
379
379
 
380
380
  ```bash
381
- # Inline workflow (newline-separated commands)
382
- surf do 'go "https://example.com/login"
383
- type "user@example.com" --selector "input[name=email]"
384
- type "password123" --selector "input[name=password]"
385
- click --selector "button[type=submit]"
386
- screenshot --output /tmp/after-login.png'
381
+ # Inline workflow (pipe-separated)
382
+ surf do 'go "https://example.com" | click e5 | screenshot'
387
383
 
388
- # From JSON file (same format as --script)
389
- surf do --file login-workflow.json
384
+ # Multi-step login flow
385
+ surf do 'go "https://example.com/login" | type "user@example.com" --selector "#email" | type "pass" --selector "#password" | click --selector "button[type=submit]"'
386
+
387
+ # From JSON file
388
+ surf do --file workflow.json
390
389
 
391
390
  # Validate without executing
392
- surf do 'go "url"\nclick e5\nscreenshot' --dry-run
391
+ surf do 'go "url" | click e5 | screenshot' --dry-run
393
392
  ```
394
393
 
395
394
  **Why workflows?** Instead of 6-8 separate CLI calls with LLM orchestration between each step, a workflow executes deterministically with smart auto-waits. Faster, cheaper, and more reliable.
package/native/cli.cjs CHANGED
@@ -817,9 +817,9 @@ const TOOLS = {
817
817
  "dry-run": "Parse and validate without executing"
818
818
  },
819
819
  examples: [
820
- { cmd: 'do \'go "https://example.com"\\nclick e5\\nscreenshot\'', desc: "Inline workflow" },
820
+ { cmd: 'do \'go "https://example.com" | click e5 | screenshot\'', desc: "Inline workflow" },
821
821
  { cmd: 'do -f login.json', desc: "From JSON file" },
822
- { cmd: 'do \'go "url"\\nclick e5\' --dry-run', desc: "Validate without running" },
822
+ { cmd: 'do \'go "url" | click e5\' --dry-run', desc: "Validate without running" },
823
823
  ]
824
824
  },
825
825
  }
@@ -206,17 +206,23 @@ function parseCommandLine(line) {
206
206
  }
207
207
 
208
208
  /**
209
- * Parse a multi-line workflow string into step array
210
- * @param {string} input - Multi-line workflow string
209
+ * Parse a workflow string into step array
210
+ * Supports pipe-separated (inline) or newline-separated (file) commands
211
+ * @param {string} input - Workflow string
211
212
  * @returns {Array<{ cmd: string, args: object }>}
212
213
  */
213
214
  function parseDoCommands(input) {
214
- // Replace literal \n (backslash + n) with actual newlines
215
- // This handles bash single-quoted strings like 'go "url"\nclick e5'
216
- const normalized = input.replace(/\\n/g, '\n');
215
+ // Determine separator: use pipe if present, otherwise newlines
216
+ // Pipe is preferred for inline: 'go "url" | click e5 | screenshot'
217
+ // Newlines for files or heredocs
218
+ const hasPipe = input.includes('|');
219
+ const separator = hasPipe ? '|' : '\n';
220
+
221
+ // Also handle literal \n for backwards compatibility
222
+ const normalized = hasPipe ? input : input.replace(/\\n/g, '\n');
217
223
 
218
224
  return normalized
219
- .split('\n')
225
+ .split(separator)
220
226
  .map(line => line.trim())
221
227
  .filter(line => line && !line.startsWith('#'))
222
228
  .map(line => parseCommandLine(line))
@@ -460,28 +460,76 @@ function extractGrokResponse(bodyText, userPrompt = '') {
460
460
  }
461
461
 
462
462
  async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
463
- // Grok 4.1 Thinking can take a LONG time (47+ seconds in screenshot)
464
- // Default to 5 minutes for thinking models
463
+ // Grok can take a long time:
464
+ // - Thinking models (Grok 4.1 Thinking): 40-60+ seconds to think, then streams
465
+ // - Fast/Auto models: No thinking phase, just streams directly
465
466
 
466
467
  const deadline = Date.now() + timeoutMs;
467
468
  let previousText = '';
468
- let stableCycles = 0;
469
- const requiredStableCycles = 4; // 4 cycles at 300ms = 1.2s stable
469
+ let previousLength = 0;
470
470
  let lastChangeAt = Date.now();
471
- const minStableMs = 1500; // 1.5 seconds stable
472
471
  let thinkingTime = null;
473
-
474
- // Capture initial page state to detect new content
475
- const initialSnapshot = await evaluate(cdp, `document.body.innerText.length`);
476
- const initialLength = initialSnapshot || 0;
472
+ let thinkingComplete = false;
473
+ let lastResponseText = '';
474
+ let responseStableCycles = 0;
477
475
 
478
476
  while (Date.now() < deadline) {
479
- // Simple approach: get full body text and parse in Node.js
480
- const snapshot = await evaluate(cdp, `({
481
- bodyText: document.body.innerText || '',
482
- hasStopBtn: !!document.querySelector('button[aria-label*="Stop"], button[aria-label*="stop"]'),
483
- url: location.href
484
- })`);
477
+ // Get page state with multiple completion indicators
478
+ const snapshot = await evaluate(cdp, `(function() {
479
+ const bodyText = document.body.innerText || '';
480
+
481
+ // Check for stop/cancel button (indicates still generating)
482
+ const hasStopBtn = !!document.querySelector('button[aria-label*="Stop"], button[aria-label*="stop"], button[aria-label*="Cancel"]');
483
+
484
+ // Check for "Thought for Xs" which indicates thinking model completed thinking
485
+ const thinkMatch = bodyText.match(/Thought for (\\d+)s/i);
486
+ const thinkingDone = !!thinkMatch;
487
+ const thinkingSecs = thinkMatch ? parseInt(thinkMatch[1], 10) : null;
488
+
489
+ // Check if actively showing "thinking..." or similar loading state
490
+ const isThinking = /\\bthinking\\.\\.\\./i.test(bodyText) ||
491
+ /\\bSearching\\.\\.\\./i.test(bodyText) ||
492
+ bodyText.includes('Grok is thinking') ||
493
+ bodyText.includes('is thinking...');
494
+
495
+ // Try to find the actual Grok response in the DOM
496
+ // Look for the main content area - Grok responses appear in the conversation area
497
+ let responseText = '';
498
+
499
+ // Strategy 1: Look for article elements or main content containers
500
+ const articles = document.querySelectorAll('article');
501
+ if (articles.length > 0) {
502
+ // Get the last article which should be the response
503
+ const lastArticle = articles[articles.length - 1];
504
+ responseText = lastArticle.innerText || '';
505
+ }
506
+
507
+ // Strategy 2: If no articles, look for the conversation container
508
+ if (!responseText) {
509
+ const convArea = document.querySelector('[data-testid="conversation"], [role="main"] > div > div');
510
+ if (convArea) {
511
+ responseText = convArea.innerText || '';
512
+ }
513
+ }
514
+
515
+ // Strategy 3: Fallback to looking for text after common Grok UI patterns
516
+ if (!responseText || responseText.length < 10) {
517
+ // Find content between user question and follow-up suggestions
518
+ const mainArea = document.querySelector('main') || document.body;
519
+ responseText = mainArea.innerText || bodyText;
520
+ }
521
+
522
+ return {
523
+ bodyText: bodyText,
524
+ responseText: responseText,
525
+ bodyLength: bodyText.length,
526
+ hasStopBtn: hasStopBtn,
527
+ thinkingDone: thinkingDone,
528
+ thinkingSecs: thinkingSecs,
529
+ isThinking: isThinking,
530
+ url: location.href
531
+ };
532
+ })()`);
485
533
 
486
534
  if (!snapshot || !snapshot.bodyText) {
487
535
  await delay(300);
@@ -489,62 +537,82 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
489
537
  }
490
538
 
491
539
  const bodyText = snapshot.bodyText;
540
+ const bodyLength = snapshot.bodyLength;
492
541
 
493
- // Parse thinking time from body text
494
- const thinkMatch = bodyText.match(/Thought for (\d+)s/i);
495
- if (thinkMatch) {
496
- const t = parseInt(thinkMatch[1], 10);
497
- if (!thinkingTime || t > thinkingTime) thinkingTime = t;
542
+ // Track thinking time (for thinking models)
543
+ if (snapshot.thinkingSecs) {
544
+ if (!thinkingTime || snapshot.thinkingSecs > thinkingTime) {
545
+ thinkingTime = snapshot.thinkingSecs;
546
+ }
498
547
  }
499
548
 
500
- // Check if still actively thinking
501
- const isThinking = /\bthinking\.{0,3}$/im.test(bodyText);
549
+ // Detect when thinking completes (thinking models only)
550
+ // "Thought for Xs" is a DEFINITIVE signal that thinking AND response generation is done
551
+ if (snapshot.thinkingDone && !thinkingComplete) {
552
+ thinkingComplete = true;
553
+ // Give a brief moment for final render, then we're done
554
+ await delay(500);
555
+ }
502
556
 
503
- // Check for completion indicators in body text
504
- // Grok shows follow-up suggestions when done
505
- const hasFollowUps = /Explain|Tell me more|Learn more/i.test(bodyText);
557
+ // Extract the actual response text - try DOM-extracted first, fall back to body parsing
558
+ let currentResponseText = '';
559
+ if (snapshot.responseText && snapshot.responseText.length > 10) {
560
+ currentResponseText = extractGrokResponse(snapshot.responseText, userPrompt) || '';
561
+ }
562
+ if (!currentResponseText || currentResponseText.length < 5) {
563
+ currentResponseText = extractGrokResponse(bodyText, userPrompt) || '';
564
+ }
506
565
 
507
- // Track body text changes for stability
508
- if (bodyText.length !== previousText.length) {
509
- previousText = bodyText;
510
- stableCycles = 0;
566
+ // Track RESPONSE text stability (more reliable than body text)
567
+ if (currentResponseText !== lastResponseText) {
568
+ lastResponseText = currentResponseText;
569
+ responseStableCycles = 0;
511
570
  lastChangeAt = Date.now();
512
- } else {
513
- stableCycles++;
571
+ } else if (currentResponseText.length > 0) {
572
+ responseStableCycles++;
573
+ }
574
+
575
+ // Track body text for timeout fallback
576
+ if (bodyLength !== previousLength) {
577
+ previousText = bodyText;
578
+ previousLength = bodyLength;
514
579
  }
515
580
 
516
581
  const stableMs = Date.now() - lastChangeAt;
517
- const isStable = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
582
+ const noStopButton = !snapshot.hasStopBtn;
583
+
584
+ // Response is stable if the extracted response text hasn't changed
585
+ // Use shorter thresholds since we're checking actual content, not noisy body text
586
+ // 4 cycles (1.2s) + 1.5s minimum is enough for response stability
587
+ const responseIsStable = responseStableCycles >= 4 && stableMs >= 1500 && currentResponseText.length > 10;
588
+
589
+ // "Thought for Xs" is the strongest completion signal - response is definitely done
590
+ const thinkingModelDone = snapshot.thinkingDone && noStopButton;
591
+
592
+ // SIMPLE CHECK: If we have response content, no stop button, and stable for 3+ cycles
593
+ const hasResponseNoStop = currentResponseText.length > 5 && noStopButton && responseStableCycles >= 3;
518
594
 
519
595
  // Response is complete when:
520
- // 1. Not generating (no stop button)
521
- // 2. Not actively thinking
522
- // 3. Has follow-up suggestions OR body text is stable
523
- // 4. Body is longer than initial (new content appeared)
524
- const isDone = !snapshot.hasStopBtn && !isThinking &&
525
- (hasFollowUps || isStable) &&
526
- bodyText.length > initialLength;
596
+ // 1. Has meaningful response content (> 5 chars)
597
+ // 2. No stop button
598
+ // 3. Either: thinking done, response stable for 3+ cycles, OR stable for 4+ cycles with 1.5s
599
+ const isDone = currentResponseText.length > 5 && noStopButton &&
600
+ (thinkingModelDone || hasResponseNoStop || responseIsStable);
527
601
 
528
602
  if (isDone) {
529
- // Extract the response from the body text
530
- // The response is typically between the user's question and the follow-up suggestions
531
- const responseText = extractGrokResponse(bodyText, userPrompt);
532
-
533
- if (responseText) {
534
- return {
535
- text: responseText,
536
- thinkingTime: thinkingTime,
537
- url: snapshot.url,
538
- };
539
- }
603
+ return {
604
+ text: currentResponseText,
605
+ thinkingTime: thinkingTime,
606
+ url: snapshot.url,
607
+ };
540
608
  }
541
609
 
542
610
  await delay(300);
543
611
  }
544
612
 
545
- // Timeout - return whatever we have
613
+ // Timeout - return whatever we have (partial response is better than nothing)
546
614
  const finalText = extractGrokResponse(previousText, userPrompt);
547
- if (finalText) {
615
+ if (finalText && finalText.length > 10) {
548
616
  return {
549
617
  text: finalText,
550
618
  thinkingTime: thinkingTime,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",