cdp-skill 1.0.7 → 1.0.14

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 (47) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +198 -1344
  3. package/install.js +1 -0
  4. package/package.json +1 -1
  5. package/src/aria/index.js +8 -0
  6. package/src/aria/output-processor.js +173 -0
  7. package/src/aria/role-query.js +1229 -0
  8. package/src/aria/snapshot.js +459 -0
  9. package/src/aria.js +237 -43
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +268 -68
  12. package/src/dom/click-executor.js +240 -76
  13. package/src/dom/element-locator.js +34 -25
  14. package/src/dom/fill-executor.js +55 -27
  15. package/src/page/dialog-handler.js +119 -0
  16. package/src/page/page-controller.js +190 -3
  17. package/src/runner/context-helpers.js +33 -55
  18. package/src/runner/execute-dynamic.js +34 -143
  19. package/src/runner/execute-form.js +11 -11
  20. package/src/runner/execute-input.js +2 -2
  21. package/src/runner/execute-interaction.js +99 -120
  22. package/src/runner/execute-navigation.js +11 -26
  23. package/src/runner/execute-query.js +8 -5
  24. package/src/runner/step-executors.js +256 -95
  25. package/src/runner/step-registry.js +1064 -0
  26. package/src/runner/step-validator.js +16 -740
  27. package/src/tests/Aria.test.js +1025 -0
  28. package/src/tests/ContextHelpers.test.js +39 -28
  29. package/src/tests/ExecuteBrowser.test.js +572 -0
  30. package/src/tests/ExecuteDynamic.test.js +34 -736
  31. package/src/tests/ExecuteForm.test.js +700 -0
  32. package/src/tests/ExecuteInput.test.js +540 -0
  33. package/src/tests/ExecuteInteraction.test.js +319 -0
  34. package/src/tests/ExecuteQuery.test.js +820 -0
  35. package/src/tests/FillExecutor.test.js +2 -2
  36. package/src/tests/StepValidator.test.js +222 -76
  37. package/src/tests/TestRunner.test.js +36 -25
  38. package/src/tests/integration.test.js +2 -1
  39. package/src/types.js +9 -9
  40. package/src/utils/backoff.js +118 -0
  41. package/src/utils/cdp-helpers.js +130 -0
  42. package/src/utils/devices.js +140 -0
  43. package/src/utils/errors.js +242 -0
  44. package/src/utils/index.js +65 -0
  45. package/src/utils/temp.js +75 -0
  46. package/src/utils/validators.js +433 -0
  47. package/src/utils.js +14 -1142
package/src/cdp-skill.js CHANGED
@@ -60,9 +60,9 @@ function generateDebugFilename(steps, status, tabId) {
60
60
  const actions = steps.slice(0, 3).map(step => {
61
61
  // Find the action key in the step
62
62
  const actionKeys = ['goto', 'click', 'fill', 'type', 'press', 'scroll', 'snapshot',
63
- 'query', 'hover', 'wait', 'eval', 'openTab', 'closeTab', 'chromeStatus',
63
+ 'query', 'hover', 'wait', 'sleep', 'pageFunction', 'newTab', 'closeTab',
64
64
  'selectOption', 'select', 'viewport', 'cookies', 'back', 'forward', 'drag',
65
- 'fillForm', 'extract', 'formState', 'assert', 'validate', 'submit'];
65
+ 'frame', 'elementsAt', 'extract', 'formState', 'assert', 'validate', 'submit'];
66
66
  for (const key of actionKeys) {
67
67
  if (step[key] !== undefined) return key;
68
68
  }
@@ -95,7 +95,7 @@ function writeDebugLog(request, response) {
95
95
  const status = response.status || 'unknown';
96
96
 
97
97
  // Extract tab ID from request or response
98
- const tabId = request.tab || request.config?.tab || response.tab || response.closed || null;
98
+ const tabId = request.tab || response.tab || response.closed || null;
99
99
 
100
100
  const filename = generateDebugFilename(steps, status, tabId);
101
101
  const filepath = path.join(debugLogDir, filename);
@@ -114,9 +114,56 @@ function writeDebugLog(request, response) {
114
114
  }
115
115
  }
116
116
 
117
- // Tab registry - maps short aliases (t1, t2, ...) to targetIds
117
+ // Tab registry - maps short aliases (t1, t2, ...) to {targetId, host, port} entries
118
118
  const TAB_REGISTRY_PATH = path.join(os.tmpdir(), 'cdp-skill-tabs.json');
119
119
 
120
+ // Frame state registry - persists frame context across CLI invocations, keyed by targetId
121
+ const FRAME_STATE_PATH = path.join(os.tmpdir(), 'cdp-skill-frames.json');
122
+
123
+ function loadFrameStates() {
124
+ try {
125
+ if (fs.existsSync(FRAME_STATE_PATH)) {
126
+ return JSON.parse(fs.readFileSync(FRAME_STATE_PATH, 'utf8'));
127
+ }
128
+ } catch (e) {
129
+ // Ignore errors, start fresh
130
+ }
131
+ return {};
132
+ }
133
+
134
+ function saveFrameStates(states) {
135
+ try {
136
+ fs.writeFileSync(FRAME_STATE_PATH, JSON.stringify(states));
137
+ } catch (e) {
138
+ // Ignore errors
139
+ }
140
+ }
141
+
142
+ function saveFrameState(targetId, frameState) {
143
+ const states = loadFrameStates();
144
+ states[targetId] = { ...frameState, timestamp: Date.now() };
145
+ saveFrameStates(states);
146
+ }
147
+
148
+ function loadFrameState(targetId) {
149
+ const states = loadFrameStates();
150
+ const state = states[targetId];
151
+ if (!state) return null;
152
+ // Expire after 1 hour (frames may have reloaded)
153
+ if (Date.now() - state.timestamp > 3600000) {
154
+ delete states[targetId];
155
+ saveFrameStates(states);
156
+ return null;
157
+ }
158
+ return state;
159
+ }
160
+
161
+ function clearFrameState(targetId) {
162
+ const states = loadFrameStates();
163
+ delete states[targetId];
164
+ saveFrameStates(states);
165
+ }
166
+
120
167
  function loadTabRegistry() {
121
168
  try {
122
169
  if (fs.existsSync(TAB_REGISTRY_PATH)) {
@@ -136,22 +183,43 @@ function saveTabRegistry(registry) {
136
183
  }
137
184
  }
138
185
 
139
- function registerTab(targetId) {
186
+ function registerTab(targetId, host = 'localhost', port = 9222) {
140
187
  const registry = loadTabRegistry();
141
188
 
142
189
  // Check if already registered
143
- for (const [alias, tid] of Object.entries(registry.tabs)) {
144
- if (tid === targetId) return alias;
190
+ for (const [alias, entry] of Object.entries(registry.tabs)) {
191
+ const existingTargetId = typeof entry === 'string' ? entry : entry.targetId;
192
+ if (existingTargetId === targetId) return alias;
145
193
  }
146
194
 
147
195
  // Assign new alias
148
196
  const alias = `t${registry.nextId}`;
149
- registry.tabs[alias] = targetId;
197
+ registry.tabs[alias] = { targetId, host, port };
150
198
  registry.nextId++;
151
199
  saveTabRegistry(registry);
152
200
  return alias;
153
201
  }
154
202
 
203
+ function resolveTabEntry(aliasOrTargetId) {
204
+ if (!aliasOrTargetId) return null;
205
+
206
+ // If it looks like a full targetId (32 hex chars), return with defaults
207
+ if (/^[A-F0-9]{32}$/i.test(aliasOrTargetId)) {
208
+ return { targetId: aliasOrTargetId, host: 'localhost', port: 9222 };
209
+ }
210
+
211
+ const registry = loadTabRegistry();
212
+ const entry = registry.tabs[aliasOrTargetId];
213
+ if (!entry) return null;
214
+
215
+ // Defensive: handle stale registry files with string entries
216
+ if (typeof entry === 'string') {
217
+ return { targetId: entry, host: 'localhost', port: 9222 };
218
+ }
219
+
220
+ return { targetId: entry.targetId, host: entry.host || 'localhost', port: entry.port || 9222 };
221
+ }
222
+
155
223
  function resolveTabAlias(aliasOrTargetId) {
156
224
  if (!aliasOrTargetId) return null;
157
225
 
@@ -162,15 +230,21 @@ function resolveTabAlias(aliasOrTargetId) {
162
230
 
163
231
  // Try to resolve alias
164
232
  const registry = loadTabRegistry();
165
- return registry.tabs[aliasOrTargetId] || aliasOrTargetId;
233
+ const entry = registry.tabs[aliasOrTargetId];
234
+ if (!entry) return aliasOrTargetId;
235
+
236
+ // Defensive: handle stale registry files with string entries
237
+ return typeof entry === 'string' ? entry : entry.targetId;
166
238
  }
167
239
 
168
240
  function unregisterTab(targetId) {
169
241
  const registry = loadTabRegistry();
170
- for (const [alias, tid] of Object.entries(registry.tabs)) {
171
- if (tid === targetId) {
242
+ for (const [alias, entry] of Object.entries(registry.tabs)) {
243
+ const existingTargetId = typeof entry === 'string' ? entry : entry.targetId;
244
+ if (existingTargetId === targetId) {
172
245
  delete registry.tabs[alias];
173
246
  saveTabRegistry(registry);
247
+ clearFrameState(targetId);
174
248
  return alias;
175
249
  }
176
250
  }
@@ -179,8 +253,9 @@ function unregisterTab(targetId) {
179
253
 
180
254
  function getTabAlias(targetId) {
181
255
  const registry = loadTabRegistry();
182
- for (const [alias, tid] of Object.entries(registry.tabs)) {
183
- if (tid === targetId) return alias;
256
+ for (const [alias, entry] of Object.entries(registry.tabs)) {
257
+ const existingTargetId = typeof entry === 'string' ? entry : entry.targetId;
258
+ if (existingTargetId === targetId) return alias;
184
259
  }
185
260
  return null;
186
261
  }
@@ -283,6 +358,13 @@ function parseInput(input) {
283
358
  throw { type: ErrorType.VALIDATION, message: 'Input must be a JSON object' };
284
359
  }
285
360
 
361
+ if (json.config) {
362
+ throw {
363
+ type: ErrorType.VALIDATION,
364
+ message: '"config" is no longer supported. Use top-level "tab"/"timeout". Connection params go in newTab: {"steps":[{"newTab":{"url":"...","port":9333}}]}'
365
+ };
366
+ }
367
+
286
368
  if (!json.steps) {
287
369
  throw { type: ErrorType.VALIDATION, message: 'Missing required "steps" array' };
288
370
  }
@@ -325,11 +407,12 @@ function isCloseTabOnly(steps) {
325
407
  /**
326
408
  * Handle chromeStatus step - lightweight, no session needed
327
409
  */
328
- async function handleChromeStatus(config, step) {
329
- const host = config.host || 'localhost';
330
- const port = config.port || 9222;
331
- const autoLaunch = step.chromeStatus === true || step.chromeStatus?.autoLaunch !== false;
332
- const headless = step.chromeStatus?.headless || false;
410
+ async function handleChromeStatus(step) {
411
+ const params = typeof step.chromeStatus === 'object' ? step.chromeStatus : {};
412
+ const host = params.host || 'localhost';
413
+ const port = params.port || 9222;
414
+ const autoLaunch = step.chromeStatus === true || params.autoLaunch !== false;
415
+ const headless = params.headless || false;
333
416
 
334
417
  const status = await getChromeStatus({ host, port, autoLaunch, headless });
335
418
 
@@ -351,9 +434,7 @@ async function handleChromeStatus(config, step) {
351
434
  /**
352
435
  * Handle closeTab step - no session needed, just close the target via CDP
353
436
  */
354
- async function handleCloseTab(config, step) {
355
- const host = config.host || 'localhost';
356
- const port = config.port || 9222;
437
+ async function handleCloseTab(step) {
357
438
  const tabRef = step.closeTab;
358
439
 
359
440
  if (!tabRef || typeof tabRef !== 'string') {
@@ -363,8 +444,11 @@ async function handleCloseTab(config, step) {
363
444
  };
364
445
  }
365
446
 
366
- // Resolve alias to targetId
367
- const targetId = resolveTabAlias(tabRef);
447
+ // Resolve alias to full entry (targetId + host + port)
448
+ const entry = resolveTabEntry(tabRef);
449
+ const targetId = entry ? entry.targetId : tabRef;
450
+ const host = entry ? entry.host : 'localhost';
451
+ const port = entry ? entry.port : 9222;
368
452
  const alias = getTabAlias(targetId);
369
453
 
370
454
  try {
@@ -374,6 +458,7 @@ async function handleCloseTab(config, step) {
374
458
 
375
459
  await new Promise((resolve, reject) => {
376
460
  const req = http.get(closeUrl, (res) => {
461
+ res.resume(); // Drain response body to prevent memory leak
377
462
  if (res.statusCode === 200) {
378
463
  resolve();
379
464
  } else {
@@ -409,6 +494,7 @@ async function handleCloseTab(config, step) {
409
494
  * Main CLI execution
410
495
  */
411
496
  async function main() {
497
+ const startTime = Date.now();
412
498
  let browser = null;
413
499
  let pageController = null;
414
500
  let parsedRequest = null; // Track for debug logging in error handler
@@ -419,17 +505,16 @@ async function main() {
419
505
  const json = parseInput(input);
420
506
  parsedRequest = json; // Store for error handler
421
507
 
422
- // Extract config with defaults - tab can be at top level or in config
423
- const config = json.config || {};
424
- const host = config.host || 'localhost';
425
- const port = config.port || 9222;
426
- const timeout = config.timeout || 30000;
427
- const headless = config.headless || false; // Run Chrome in headless mode (no focus stealing)
428
- const tab = json.tab || config.tab; // Top-level tab takes precedence
508
+ // Extract top-level fields
509
+ const tab = json.tab || null;
510
+ const timeout = json.timeout || 30000;
511
+ let host = 'localhost';
512
+ let port = 9222;
513
+ let headless = false;
429
514
 
430
515
  // Handle chromeStatus specially - no session needed
431
516
  if (isChromeStatusOnly(json.steps)) {
432
- const result = await handleChromeStatus(config, json.steps[0]);
517
+ const result = await handleChromeStatus(json.steps[0]);
433
518
  writeDebugLog(json, result);
434
519
  console.log(JSON.stringify(result));
435
520
  process.exit(result.status === 'ok' ? 0 : 1);
@@ -437,12 +522,55 @@ async function main() {
437
522
 
438
523
  // Handle closeTab specially - no session needed, just close the target
439
524
  if (isCloseTabOnly(json.steps)) {
440
- const result = await handleCloseTab(config, json.steps[0]);
525
+ const result = await handleCloseTab(json.steps[0]);
441
526
  writeDebugLog(json, result);
442
527
  console.log(JSON.stringify(result));
443
528
  process.exit(result.status === 'ok' ? 0 : 1);
444
529
  }
445
530
 
531
+ // Check if first step is newTab or switchTab
532
+ const firstStep = json.steps[0];
533
+ const hasNewTab = firstStep && firstStep.newTab !== undefined;
534
+ const hasSwitchTab = firstStep && firstStep.switchTab !== undefined;
535
+
536
+ // Extract URL and options from newTab if provided
537
+ let newTabUrl = null;
538
+ let newTabTimeout = null;
539
+ if (hasNewTab) {
540
+ const newTabParam = firstStep.newTab;
541
+ if (typeof newTabParam === 'string') {
542
+ newTabUrl = newTabParam;
543
+ } else if (typeof newTabParam === 'object' && newTabParam !== null) {
544
+ newTabUrl = newTabParam.url || null;
545
+ newTabTimeout = newTabParam.timeout || null;
546
+ // Extract connection overrides from newTab object form
547
+ if (newTabParam.host) host = newTabParam.host;
548
+ if (newTabParam.port) port = newTabParam.port;
549
+ if (newTabParam.headless) headless = newTabParam.headless;
550
+ }
551
+ }
552
+
553
+ // Extract connection overrides from switchTab object form
554
+ if (hasSwitchTab) {
555
+ const switchParam = firstStep.switchTab;
556
+ if (typeof switchParam === 'object' && switchParam !== null) {
557
+ if (switchParam.host) host = switchParam.host;
558
+ if (switchParam.port) port = switchParam.port;
559
+ }
560
+ }
561
+
562
+ // If tab specified, resolve host/port from registry
563
+ if (tab) {
564
+ const tabEntry = resolveTabEntry(tab);
565
+ if (tabEntry) {
566
+ host = tabEntry.host;
567
+ port = tabEntry.port;
568
+ }
569
+ }
570
+
571
+ // Resolve tab alias to targetId
572
+ const resolvedTargetId = tab ? resolveTabAlias(tab) : null;
573
+
446
574
  // Connect to browser, auto-launch if needed
447
575
  browser = createBrowser({ host, port, connectTimeout: timeout });
448
576
 
@@ -468,27 +596,9 @@ async function main() {
468
596
  }
469
597
  }
470
598
 
471
- // Get page session - requires explicit targetId or openTab step
599
+ // Get page session - requires explicit targetId or newTab step
472
600
  let session;
473
601
 
474
- // Check if first step is openTab
475
- const firstStep = json.steps[0];
476
- const hasOpenTab = firstStep && firstStep.openTab !== undefined;
477
-
478
- // Extract URL from openTab if provided
479
- let openTabUrl = null;
480
- if (hasOpenTab) {
481
- const openTabParam = firstStep.openTab;
482
- if (typeof openTabParam === 'string') {
483
- openTabUrl = openTabParam;
484
- } else if (typeof openTabParam === 'object' && openTabParam !== null && openTabParam.url) {
485
- openTabUrl = openTabParam.url;
486
- }
487
- }
488
-
489
- // Resolve tab alias to targetId
490
- const resolvedTargetId = tab ? resolveTabAlias(tab) : null;
491
-
492
602
  if (resolvedTargetId) {
493
603
  try {
494
604
  session = await browser.attachToPage(resolvedTargetId);
@@ -498,16 +608,56 @@ async function main() {
498
608
  message: `Could not attach to tab ${tab}${tab !== resolvedTargetId ? ` (${resolvedTargetId})` : ''}: ${err.message}`
499
609
  };
500
610
  }
501
- } else if (hasOpenTab) {
502
- // Create new tab via openTab step
611
+ } else if (hasSwitchTab) {
612
+ // Connect to an existing tab by alias, targetId, or URL regex
503
613
  try {
504
- session = await browser.newPage();
614
+ const switchParam = firstStep.switchTab;
615
+ let switchTargetId = null;
616
+
617
+ if (typeof switchParam === 'string') {
618
+ // Try alias first, then targetId
619
+ switchTargetId = resolveTabAlias(switchParam);
620
+ } else if (switchParam && typeof switchParam === 'object') {
621
+ if (switchParam.targetId) {
622
+ switchTargetId = switchParam.targetId;
623
+ } else if (switchParam.url) {
624
+ // Find tab by URL regex
625
+ const pages = await browser.getPages();
626
+ const urlRegex = new RegExp(switchParam.url);
627
+ const match = pages.find(p => urlRegex.test(p.url));
628
+ if (!match) {
629
+ throw new Error(`No tab matches URL pattern: ${switchParam.url}`);
630
+ }
631
+ switchTargetId = match.targetId;
632
+ }
633
+ }
634
+
635
+ if (!switchTargetId) {
636
+ throw new Error('Could not resolve switchTab target');
637
+ }
638
+
639
+ session = await browser.attachToPage(switchTargetId);
640
+ const tabAlias = getTabAlias(switchTargetId) || registerTab(switchTargetId, host, port);
641
+ json.steps[0]._switchTabHandled = true;
642
+ json.steps[0]._switchTabAlias = tabAlias;
643
+ } catch (err) {
644
+ throw {
645
+ type: ErrorType.CONNECTION,
646
+ message: `switchTab failed: ${err.message}`
647
+ };
648
+ }
649
+ } else if (hasNewTab) {
650
+ // Create new tab via newTab step
651
+ try {
652
+ // Create blank tab - URL navigation happens in step executor
653
+ session = await browser.newPage('about:blank');
505
654
  // Register the new tab and get its alias
506
- const tabAlias = registerTab(session.targetId);
507
- // Mark openTab as handled and store URL/alias if provided
508
- json.steps[0]._openTabHandled = true;
509
- json.steps[0]._openTabUrl = openTabUrl;
510
- json.steps[0]._openTabAlias = tabAlias;
655
+ const tabAlias = registerTab(session.targetId, host, port);
656
+ // Mark newTab as handled and store URL/alias/timeout if provided
657
+ json.steps[0]._newTabHandled = true;
658
+ json.steps[0]._newTabUrl = newTabUrl;
659
+ json.steps[0]._newTabTimeout = newTabTimeout;
660
+ json.steps[0]._newTabAlias = tabAlias;
511
661
  } catch (err) {
512
662
  if (err.message.includes('no browser is open')) {
513
663
  throw {
@@ -521,23 +671,28 @@ async function main() {
521
671
  };
522
672
  }
523
673
  } else {
524
- // No targetId and no openTab step - fail with helpful message
674
+ // No targetId and no newTab/switchTab step - fail with helpful message
525
675
  throw {
526
676
  type: ErrorType.VALIDATION,
527
677
  message: `No tab specified. Either:\n` +
528
- ` 1. Use {"steps":[{"openTab":"url"},...]} to create a new tab\n` +
529
- ` 2. Pass tab id: {"tab":"t1", "steps":[...]}`
678
+ ` 1. Use {"steps":[{"newTab":"url"},...]} to create a new tab\n` +
679
+ ` 2. Use {"steps":[{"switchTab":"t1"},...]} to connect to an existing tab\n` +
680
+ ` 3. Pass tab id: {"tab":"t1", "steps":[...]}`
530
681
  };
531
682
  }
532
683
 
533
684
  // Create dependencies
534
- pageController = createPageController(session);
535
- const elementLocator = createElementLocator(session);
685
+ pageController = createPageController(session, {
686
+ onFrameChanged: (frameState) => saveFrameState(session.targetId, frameState),
687
+ getSavedFrameState: () => loadFrameState(session.targetId)
688
+ });
689
+ const frameContextProvider = () => pageController.getFrameContext();
690
+ const elementLocator = createElementLocator(session, { getFrameContext: frameContextProvider });
536
691
  const inputEmulator = createInputEmulator(session);
537
692
  const screenshotCapture = createScreenshotCapture(session);
538
693
  const consoleCapture = createConsoleCapture(session);
539
694
  const pdfCapture = createPdfCapture(session);
540
- const ariaSnapshot = createAriaSnapshot(session);
695
+ const ariaSnapshot = createAriaSnapshot(session, { getFrameContext: frameContextProvider });
541
696
  const cookieManager = createCookieManager(session);
542
697
 
543
698
  // Initialize page controller (enables required CDP domains)
@@ -558,11 +713,12 @@ async function main() {
558
713
  consoleCapture,
559
714
  pdfCapture,
560
715
  ariaSnapshot,
561
- cookieManager
716
+ cookieManager,
717
+ registerNewTab: (targetId) => registerTab(targetId, host, port)
562
718
  };
563
719
 
564
720
  // Run steps (pass tab alias for auto-screenshots)
565
- const tabAlias = getTabAlias(session.targetId) || registerTab(session.targetId);
721
+ const tabAlias = getTabAlias(session.targetId) || registerTab(session.targetId, host, port);
566
722
  const result = await runSteps(deps, json.steps, {
567
723
  stopOnError: true,
568
724
  stepTimeout: timeout,
@@ -586,10 +742,32 @@ async function main() {
586
742
  screenshotPath = null;
587
743
  }
588
744
 
745
+ // Bubble up site profile info from goto/newTab steps to top level
746
+ let siteProfile = undefined;
747
+ let actionRequired = undefined;
748
+ for (const step of result.steps) {
749
+ if (step.siteProfile) {
750
+ siteProfile = step.siteProfile;
751
+ delete step.siteProfile;
752
+ } else if (step.profileAvailable === false) {
753
+ actionRequired = {
754
+ action: 'createSiteProfile',
755
+ domain: step.profileDomain,
756
+ message: `STOP: No site profile for "${step.profileDomain}". You MUST create one before doing anything else. Run these steps now: (1) snapshot — see page structure, (2) pageFunction — detect framework/routing, (3) writeSiteProfile — save what you learned. Then continue your task. Skipping this causes unreliable automation.`
757
+ };
758
+ delete step.profileAvailable;
759
+ delete step.profileDomain;
760
+ delete step.hint;
761
+ }
762
+ }
763
+
589
764
  // Build streamlined output
590
765
  const output = {
591
766
  status: result.status,
592
- tab: getTabAlias(session.targetId) || registerTab(session.targetId),
767
+ tab: getTabAlias(session.targetId) || registerTab(session.targetId, host, port),
768
+ // Site profile — prominent, right after status/tab
769
+ siteProfile,
770
+ actionRequired,
593
771
  // Command-level auto-snapshot results
594
772
  navigated: result.navigated,
595
773
  fullSnapshot: result.fullSnapshot,
@@ -606,6 +784,8 @@ async function main() {
606
784
  };
607
785
 
608
786
  // Remove null/undefined fields for compactness
787
+ if (!output.siteProfile) delete output.siteProfile;
788
+ if (!output.actionRequired) delete output.actionRequired;
609
789
  if (output.navigated === undefined) delete output.navigated;
610
790
  if (!output.fullSnapshot) delete output.fullSnapshot;
611
791
  if (!output.context) delete output.context;
@@ -631,6 +811,26 @@ async function main() {
631
811
  // Debug logging
632
812
  writeDebugLog(json, finalOutput);
633
813
 
814
+ // Write metrics if CDP_METRICS_FILE is set
815
+ const metricsFile = process.env.CDP_METRICS_FILE;
816
+ if (metricsFile) {
817
+ const inputBytes = Buffer.byteLength(input, 'utf8');
818
+ const outputJson = JSON.stringify(finalOutput);
819
+ const outputBytes = Buffer.byteLength(outputJson, 'utf8');
820
+ const metricsLine = JSON.stringify({
821
+ ts: new Date().toISOString(),
822
+ input_bytes: inputBytes,
823
+ output_bytes: outputBytes,
824
+ steps: json.steps.length,
825
+ time_ms: Date.now() - startTime
826
+ }) + '\n';
827
+ try {
828
+ const metricsDir = path.dirname(metricsFile);
829
+ if (!fs.existsSync(metricsDir)) fs.mkdirSync(metricsDir, { recursive: true });
830
+ fs.appendFileSync(metricsFile, metricsLine);
831
+ } catch (e) { /* metrics write failure is non-fatal */ }
832
+ }
833
+
634
834
  // Output result
635
835
  console.log(JSON.stringify(finalOutput));
636
836