autokap 1.0.7 → 1.0.8

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 (278) hide show
  1. package/assets/cursors/macos.svg +4 -0
  2. package/assets/cursors/windows.svg +15 -0
  3. package/assets/skill/OPCODE-REFERENCE.md +607 -0
  4. package/assets/skill/README.md +39 -0
  5. package/assets/skill/SKILL.md +453 -468
  6. package/assets/skill/STUDIO-SKILL.md +476 -0
  7. package/assets/skill/references/examples.md +104 -0
  8. package/assets/skill/references/interactive-demo.md +225 -0
  9. package/assets/skill/references/mock-data.md +178 -0
  10. package/dist/action-verifier.d.ts +29 -0
  11. package/dist/action-verifier.js +133 -0
  12. package/dist/agent-action-recovery.d.ts +45 -0
  13. package/dist/agent-action-recovery.js +370 -0
  14. package/dist/agent-message-utils.d.ts +21 -0
  15. package/dist/agent-message-utils.js +77 -0
  16. package/dist/agent-url-utils.d.ts +30 -0
  17. package/dist/agent-url-utils.js +138 -0
  18. package/dist/agent.d.ts +92 -8
  19. package/dist/agent.js +2936 -781
  20. package/dist/ak-tree.d.ts +39 -0
  21. package/dist/ak-tree.js +368 -0
  22. package/dist/alt-text.d.ts +26 -0
  23. package/dist/alt-text.js +55 -0
  24. package/dist/auth-capture.d.ts +17 -0
  25. package/dist/auth-capture.js +164 -0
  26. package/dist/benchmark.d.ts +59 -0
  27. package/dist/benchmark.js +135 -0
  28. package/dist/browser-bar.d.ts +14 -6
  29. package/dist/browser-bar.js +145 -8
  30. package/dist/browser-pool.d.ts +7 -0
  31. package/dist/browser-pool.js +15 -5
  32. package/dist/browser-utils.d.ts +31 -0
  33. package/dist/browser-utils.js +97 -0
  34. package/dist/browser.d.ts +51 -1
  35. package/dist/browser.js +1481 -31
  36. package/dist/capture-alt-text.js +2 -1
  37. package/dist/capture-language-preflight.js +14 -0
  38. package/dist/capture-llm-page-identity.js +22 -10
  39. package/dist/capture-page-identity.d.ts +5 -7
  40. package/dist/capture-page-identity.js +211 -78
  41. package/dist/capture-preset-credentials.d.ts +50 -0
  42. package/dist/capture-preset-credentials.js +127 -0
  43. package/dist/capture-request-plan.d.ts +2 -2
  44. package/dist/capture-request-plan.js +64 -16
  45. package/dist/capture-run-optimizer.js +48 -33
  46. package/dist/capture-selector-memory.d.ts +5 -0
  47. package/dist/capture-selector-memory.js +18 -0
  48. package/dist/capture-strategy.d.ts +36 -0
  49. package/dist/capture-strategy.js +95 -0
  50. package/dist/capture-studio-sync.d.ts +1 -0
  51. package/dist/capture-studio-sync.js +9 -3
  52. package/dist/capture-surface-contract.d.ts +36 -0
  53. package/dist/capture-surface-contract.js +299 -0
  54. package/dist/capture-transition-engine.d.ts +28 -0
  55. package/dist/capture-transition-engine.js +292 -0
  56. package/dist/capture-variant-state.d.ts +2 -0
  57. package/dist/capture-variant-state.js +26 -0
  58. package/dist/capture-verification.d.ts +35 -0
  59. package/dist/capture-verification.js +95 -0
  60. package/dist/capture-viewport-lock.d.ts +48 -0
  61. package/dist/capture-viewport-lock.js +74 -0
  62. package/dist/circuit-breaker.d.ts +42 -0
  63. package/dist/circuit-breaker.js +119 -0
  64. package/dist/cli-config.d.ts +8 -1
  65. package/dist/cli-config.js +62 -6
  66. package/dist/cli-contract.d.ts +15 -0
  67. package/dist/cli-contract.js +167 -0
  68. package/dist/cli-runner-local.d.ts +12 -0
  69. package/dist/cli-runner-local.js +102 -0
  70. package/dist/cli-runner.d.ts +34 -0
  71. package/dist/cli-runner.js +433 -0
  72. package/dist/cli-utils.d.ts +0 -1
  73. package/dist/cli-utils.js +2 -5
  74. package/dist/cli.js +1005 -267
  75. package/dist/clip-orchestrator.js +9 -2
  76. package/dist/clip-postprocess.js +25 -16
  77. package/dist/cookie-dismiss.d.ts +2 -0
  78. package/dist/cookie-dismiss.js +48 -13
  79. package/dist/cost-logging.d.ts +8 -0
  80. package/dist/cost-logging.js +160 -46
  81. package/dist/cost-resolution-monitor.d.ts +16 -0
  82. package/dist/cost-resolution-monitor.js +34 -0
  83. package/dist/credential-templates.js +2 -2
  84. package/dist/cursor-overlay-script.d.ts +6 -0
  85. package/dist/cursor-overlay-script.js +169 -0
  86. package/dist/dom-css-purger.d.ts +65 -0
  87. package/dist/dom-css-purger.js +333 -0
  88. package/dist/dom-font-inliner.d.ts +45 -0
  89. package/dist/dom-font-inliner.js +148 -0
  90. package/dist/dom-patch-resolver.d.ts +52 -0
  91. package/dist/dom-patch-resolver.js +242 -0
  92. package/dist/dom-serializer.d.ts +82 -0
  93. package/dist/dom-serializer.js +378 -0
  94. package/dist/element-capture.d.ts +1 -41
  95. package/dist/element-capture.js +202 -446
  96. package/dist/env-validation.d.ts +5 -0
  97. package/dist/env-validation.js +29 -0
  98. package/dist/execution-schema.d.ts +4423 -0
  99. package/dist/execution-schema.js +507 -0
  100. package/dist/execution-types.d.ts +886 -0
  101. package/dist/execution-types.js +65 -0
  102. package/dist/fonts-loader.d.ts +14 -0
  103. package/dist/fonts-loader.js +55 -0
  104. package/dist/hybrid-navigator.js +12 -12
  105. package/dist/index.d.ts +9 -6
  106. package/dist/index.js +10 -4
  107. package/dist/legacy/agent-action-recovery.d.ts +45 -0
  108. package/dist/legacy/agent-action-recovery.js +370 -0
  109. package/dist/legacy/agent-message-utils.d.ts +21 -0
  110. package/dist/legacy/agent-message-utils.js +77 -0
  111. package/dist/legacy/agent-url-utils.d.ts +30 -0
  112. package/dist/legacy/agent-url-utils.js +138 -0
  113. package/dist/legacy/agent.d.ts +226 -0
  114. package/dist/legacy/agent.js +6666 -0
  115. package/dist/legacy/clip-orchestrator.d.ts +148 -0
  116. package/dist/legacy/clip-orchestrator.js +957 -0
  117. package/dist/legacy/credential-templates.d.ts +5 -0
  118. package/dist/legacy/credential-templates.js +60 -0
  119. package/dist/legacy/hybrid-navigator.d.ts +138 -0
  120. package/dist/legacy/hybrid-navigator.js +468 -0
  121. package/dist/legacy/llm-usage.d.ts +17 -0
  122. package/dist/legacy/llm-usage.js +45 -0
  123. package/dist/legacy/prompt-cache.d.ts +10 -0
  124. package/dist/legacy/prompt-cache.js +24 -0
  125. package/dist/legacy/prompts.d.ts +175 -0
  126. package/dist/legacy/prompts.js +1038 -0
  127. package/dist/legacy/tools.d.ts +4 -0
  128. package/dist/legacy/tools.js +216 -0
  129. package/dist/legacy/video-agent.d.ts +143 -0
  130. package/dist/legacy/video-agent.js +4788 -0
  131. package/dist/legacy/video-observation.d.ts +36 -0
  132. package/dist/legacy/video-observation.js +192 -0
  133. package/dist/legacy/video-planner.d.ts +12 -0
  134. package/dist/legacy/video-planner.js +501 -0
  135. package/dist/legacy/video-prompts.d.ts +37 -0
  136. package/dist/legacy/video-prompts.js +569 -0
  137. package/dist/legacy/video-tools.d.ts +3 -0
  138. package/dist/legacy/video-tools.js +59 -0
  139. package/dist/legacy/video-variant-state.d.ts +29 -0
  140. package/dist/legacy/video-variant-state.js +80 -0
  141. package/dist/legacy/vision-model.d.ts +17 -0
  142. package/dist/legacy/vision-model.js +74 -0
  143. package/dist/llm-healer.d.ts +63 -0
  144. package/dist/llm-healer.js +166 -0
  145. package/dist/llm-provider.d.ts +29 -0
  146. package/dist/llm-provider.js +80 -0
  147. package/dist/logger.d.ts +6 -2
  148. package/dist/logger.js +15 -1
  149. package/dist/mockup-html.js +35 -25
  150. package/dist/mockup.d.ts +95 -2
  151. package/dist/mockup.js +427 -166
  152. package/dist/mouse-animation.d.ts +2 -2
  153. package/dist/mouse-animation.js +34 -20
  154. package/dist/opcode-actions.d.ts +42 -0
  155. package/dist/opcode-actions.js +511 -0
  156. package/dist/opcode-runner.d.ts +51 -0
  157. package/dist/opcode-runner.js +770 -0
  158. package/dist/openrouter-client.d.ts +40 -0
  159. package/dist/openrouter-client.js +16 -0
  160. package/dist/overlay-engine.d.ts +24 -0
  161. package/dist/overlay-engine.js +176 -0
  162. package/dist/postcondition.d.ts +16 -0
  163. package/dist/postcondition.js +269 -0
  164. package/dist/program-patcher.d.ts +25 -0
  165. package/dist/program-patcher.js +44 -0
  166. package/dist/prompts.d.ts +13 -5
  167. package/dist/prompts.js +224 -351
  168. package/dist/provider-config.d.ts +12 -0
  169. package/dist/provider-config.js +15 -0
  170. package/dist/recovery-chain.d.ts +37 -0
  171. package/dist/recovery-chain.js +350 -0
  172. package/dist/remote-browser.d.ts +28 -4
  173. package/dist/remote-browser.js +60 -5
  174. package/dist/safari-browser-bar.d.ts +15 -0
  175. package/dist/safari-browser-bar.js +95 -0
  176. package/dist/safari-toolbar-asset.d.ts +15 -0
  177. package/dist/safari-toolbar-asset.js +12 -0
  178. package/dist/security.d.ts +2 -1
  179. package/dist/security.js +49 -10
  180. package/dist/selector-resolver.d.ts +34 -0
  181. package/dist/selector-resolver.js +181 -0
  182. package/dist/semantic-resolver.d.ts +35 -0
  183. package/dist/semantic-resolver.js +161 -0
  184. package/dist/server-capture-runtime.d.ts +5 -3
  185. package/dist/server-capture-runtime.js +42 -95
  186. package/dist/server-credit-usage.d.ts +2 -2
  187. package/dist/server-project-webhooks.d.ts +15 -1
  188. package/dist/server-project-webhooks.js +34 -8
  189. package/dist/server-screenshot-watermark.js +27 -5
  190. package/dist/session-profile.js +164 -1
  191. package/dist/sf-pro-symbols.d.ts +1 -0
  192. package/dist/sf-pro-symbols.js +55 -0
  193. package/dist/skill-packaging.d.ts +28 -0
  194. package/dist/skill-packaging.js +169 -0
  195. package/dist/smart-wait.d.ts +27 -0
  196. package/dist/smart-wait.js +81 -0
  197. package/dist/status-bar-render.d.ts +20 -0
  198. package/dist/status-bar-render.js +410 -0
  199. package/dist/status-bar.d.ts +9 -0
  200. package/dist/status-bar.js +298 -14
  201. package/dist/svg-browser-bar.d.ts +33 -0
  202. package/dist/svg-browser-bar.js +206 -0
  203. package/dist/svg-status-bar.d.ts +36 -0
  204. package/dist/svg-status-bar.js +597 -0
  205. package/dist/svg-text.d.ts +61 -0
  206. package/dist/svg-text.js +118 -0
  207. package/dist/tools.js +89 -451
  208. package/dist/types.d.ts +240 -5
  209. package/dist/types.js +23 -1
  210. package/dist/v2/action-verifier.d.ts +29 -0
  211. package/dist/v2/action-verifier.js +133 -0
  212. package/dist/v2/alt-text.d.ts +26 -0
  213. package/dist/v2/alt-text.js +55 -0
  214. package/dist/v2/benchmark.d.ts +59 -0
  215. package/dist/v2/benchmark.js +135 -0
  216. package/dist/v2/capture-strategy.d.ts +30 -0
  217. package/dist/v2/capture-strategy.js +67 -0
  218. package/dist/v2/capture-verification.d.ts +35 -0
  219. package/dist/v2/capture-verification.js +95 -0
  220. package/dist/v2/circuit-breaker.d.ts +42 -0
  221. package/dist/v2/circuit-breaker.js +119 -0
  222. package/dist/v2/cli-runner-local.d.ts +11 -0
  223. package/dist/v2/cli-runner-local.js +91 -0
  224. package/dist/v2/cli-runner.d.ts +34 -0
  225. package/dist/v2/cli-runner.js +300 -0
  226. package/dist/v2/compiler-prompts.d.ts +27 -0
  227. package/dist/v2/compiler-prompts.js +123 -0
  228. package/dist/v2/compiler.d.ts +37 -0
  229. package/dist/v2/compiler.js +147 -0
  230. package/dist/v2/explorer.d.ts +41 -0
  231. package/dist/v2/explorer.js +56 -0
  232. package/dist/v2/index.d.ts +37 -0
  233. package/dist/v2/index.js +31 -0
  234. package/dist/v2/llm-healer.d.ts +62 -0
  235. package/dist/v2/llm-healer.js +166 -0
  236. package/dist/v2/llm-provider.d.ts +29 -0
  237. package/dist/v2/llm-provider.js +80 -0
  238. package/dist/v2/opcode-runner.d.ts +47 -0
  239. package/dist/v2/opcode-runner.js +634 -0
  240. package/dist/v2/overlay-engine.d.ts +24 -0
  241. package/dist/v2/overlay-engine.js +150 -0
  242. package/dist/v2/postcondition.d.ts +16 -0
  243. package/dist/v2/postcondition.js +249 -0
  244. package/dist/v2/program-patcher.d.ts +25 -0
  245. package/dist/v2/program-patcher.js +44 -0
  246. package/dist/v2/recovery-chain.d.ts +30 -0
  247. package/dist/v2/recovery-chain.js +368 -0
  248. package/dist/v2/schema.d.ts +2580 -0
  249. package/dist/v2/schema.js +295 -0
  250. package/dist/v2/selector-resolver.d.ts +34 -0
  251. package/dist/v2/selector-resolver.js +181 -0
  252. package/dist/v2/semantic-resolver.d.ts +35 -0
  253. package/dist/v2/semantic-resolver.js +161 -0
  254. package/dist/v2/smart-wait.d.ts +27 -0
  255. package/dist/v2/smart-wait.js +81 -0
  256. package/dist/v2/types.d.ts +444 -0
  257. package/dist/v2/types.js +19 -0
  258. package/dist/v2/web-playwright-local.d.ts +69 -0
  259. package/dist/v2/web-playwright-local.js +392 -0
  260. package/dist/version.d.ts +1 -0
  261. package/dist/version.js +5 -0
  262. package/dist/video-agent.js +18 -13
  263. package/dist/video-planner.js +2 -1
  264. package/dist/video-prompts.js +3 -3
  265. package/dist/web-playwright-local.d.ts +126 -0
  266. package/dist/web-playwright-local.js +819 -0
  267. package/dist/ws-auth.js +4 -1
  268. package/dist/ws-broadcast.d.ts +34 -0
  269. package/dist/ws-broadcast.js +85 -0
  270. package/dist/ws-connection-limits.d.ts +12 -0
  271. package/dist/ws-connection-limits.js +44 -0
  272. package/dist/ws-handler-utils.d.ts +32 -0
  273. package/dist/ws-handler-utils.js +139 -0
  274. package/dist/ws-handler.js +294 -164
  275. package/dist/ws-metrics-server.d.ts +9 -0
  276. package/dist/ws-metrics-server.js +31 -0
  277. package/dist/ws-server.js +41 -1
  278. package/package.json +51 -34
@@ -6,33 +6,43 @@
6
6
  * so the WS flow does not drift from the HTTP capture flow.
7
7
  */
8
8
  import crypto from 'node:crypto';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
9
11
  import { chromium } from 'playwright';
10
12
  import { replayAgent, runAgent, verifyCaptureReadiness } from './agent.js';
11
- import { buildAgentRunHints } from './session-profile.js';
13
+ import { performDeterministicSessionRepair } from './session-profile.js';
12
14
  import { captureIsolatedElement } from './element-capture.js';
13
- import { getCapturePromptValidationError, getCaptureUrlHostname, resolveCaptureRequestPlan, sanitizeStorageSegment, urlsPointToSameCaptureState, } from './capture-request-plan.js';
15
+ import { getCapturePromptValidationError, getCaptureUrlHostname, resolveCaptureRequestPlan, sanitizeStorageSegment, } from './capture-request-plan.js';
16
+ import { lockCaptureViewport, takeViewportLockedScreenshot, } from './capture-viewport-lock.js';
17
+ import { CHROMIUM_ARGS } from './browser-pool.js';
14
18
  import { insertCostLogs, resolveAllCosts, updateCostLogCaptureContext, } from './cost-logging.js';
15
19
  import { insertScreenshotOperationLog, reconcilePendingBillingOperationCosts, } from './billing-operation-logging.js';
16
20
  import { generateAltText } from './capture-alt-text.js';
17
21
  import { ensureScreenshotVariantLanguage } from './capture-language-preflight.js';
18
22
  import { inferPageIdentitiesWithLLM } from './capture-llm-page-identity.js';
19
23
  import { resolveRunModels } from './capture-model-resolution.js';
24
+ import { decideCaptureTransition } from './capture-transition-engine.js';
20
25
  import { extractSelectorUpdates, loadScreenshotSelectorMemory, persistScreenshotSelectorMemoryUpdates, } from './capture-selector-memory.js';
21
26
  import { hydratePresetConfigFromStorage } from './capture-preset-credentials.js';
22
- import { applySelectorMemoryUpdates, buildSequentialHandoffArtifacts, buildSessionBootstrapProfile, deriveRunSharedAuthProfile, getUrlOrigin, mergeSelectorMemory, resolveIsolatedElementAssignments, resolveScopedSelectorMemory, scopeSelectorMemoryUpdates, shouldReuseLivePageState, shouldUseSequentialPageHandoff, urlMatchesCaptureTarget, } from './capture-run-optimizer.js';
27
+ import { applySelectorMemoryUpdates, buildSessionBootstrapProfile, deriveRunSharedAuthProfile, getUrlOrigin, mergeSelectorMemory, resolveIsolatedElementAssignments, resolveScopedSelectorMemory, scopeSelectorMemoryUpdates, shouldNormalizeDialogsBeforeTargetReuse, urlMatchesCaptureTarget, } from './capture-run-optimizer.js';
23
28
  import { decryptSessionField, encryptSessionField, } from './capture-session-profile-encryption.js';
24
29
  import { buildVariantManifestContext, createVariantCaptureState, markVariantCaptureBlocked, markVariantCaptureInProgress, recordValidatedVariantCapture, validateVariantCaptureState, } from './capture-variant-state.js';
25
30
  import { syncStudioVariantAfterCapture } from './capture-studio-sync.js';
26
31
  import { applyDeviceFrame, invalidateDeviceConfigCache } from './mockup.js';
27
32
  import { localizeStatusBar } from './status-bar-l10n.js';
28
- import { logger } from './logger.js';
33
+ import { logger, runWithLoggerCallbacks } from './logger.js';
34
+ import { createCaptureBroadcast } from './ws-broadcast.js';
35
+ import { APP_VERSION } from './version.js';
36
+ import { isTerminalAgentResultSuccess, isTerminalVerificationSuccess, } from './types.js';
37
+ import { buildHandoffContext, buildPersistableProfile, buildPreverifiedAgentResult, loadRunHints, makeFailedAgentResult, restorePreparedPageState, } from './ws-handler-utils.js';
29
38
  import { getRemoteBrowserCompatibilityError, RemoteBrowser, } from './remote-browser.js';
30
- import { recordCreditUsage } from './server-credit-usage.js';
39
+ import { recordCreditUsage, revokeCreditUsage } from './server-credit-usage.js';
31
40
  import { cleanupExpiredCapturesForOwner, ensureCaptureConfigAllowed, ensureMonthlyCreditsQuota, ensureResourceNotLocked, getBillingAccountForUser, getCaptureOverageState, getBillingPlan, getIncrementalOverageCount, getLockedResourceIds, getOwnerBillingUsage, getProjectOwnerBillingContext, getRemainingOverageCredits, getSignupBonusCredits, getStripeOveragePriceIdForPlan, isYearlySubscription, PlanLimitError, recordStripeMeterEvent, SCREENSHOT_CREDIT_COST, shouldUseStripePlan, } from './server-capture-runtime.js';
32
41
  import { buildCaptureWebhookPayload, dispatchProjectCaptureWebhook, getProjectWebhookConfig, } from './server-project-webhooks.js';
33
42
  import { getServerPostHog } from './server-posthog.js';
34
43
  import { applyPlanScreenshotWatermark } from './server-screenshot-watermark.js';
35
44
  import { validateApiKey, requireScope, getSupabase } from './ws-auth.js';
45
+ import { getUserConnectionCount, trackUserConnection, untrackUserConnection, } from './ws-connection-limits.js';
36
46
  const BUCKET = 'screenshots';
37
47
  function getOpenRouterKey() {
38
48
  const key = process.env.OPENROUTER_API_KEY ?? '';
@@ -74,7 +84,7 @@ async function waitForClientHello(ws, timeoutMs = 3_000) {
74
84
  return;
75
85
  settled = true;
76
86
  cleanup();
77
- resolve(parsed.client);
87
+ resolve({ client: parsed.client, apiKey: parsed.apiKey });
78
88
  }
79
89
  catch {
80
90
  // Ignore non-JSON or non-hello messages until timeout.
@@ -87,14 +97,16 @@ async function waitForClientHello(ws, timeoutMs = 3_000) {
87
97
  }
88
98
  export async function handleConnection(ws, req) {
89
99
  const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
90
- const rawKey = url.searchParams.get('key') ?? '';
91
100
  const presetId = url.searchParams.get('preset_id') ?? '';
92
- const clientHelloPromise = waitForClientHello(ws);
93
101
  const sendEvent = (type, data) => {
94
102
  if (ws.readyState === ws.OPEN) {
95
103
  ws.send(JSON.stringify({ type, ...data }));
96
104
  }
97
105
  };
106
+ // Wait for client_hello which now carries the API key (protocol v3+).
107
+ // Fall back to query param for older CLIs during transition.
108
+ const clientHello = await waitForClientHello(ws);
109
+ const rawKey = clientHello?.apiKey ?? url.searchParams.get('key') ?? '';
98
110
  const auth = await validateApiKey(rawKey);
99
111
  if (!auth) {
100
112
  sendEvent('error', { message: 'Invalid API key' });
@@ -121,7 +133,7 @@ export async function handleConnection(ws, req) {
121
133
  .eq('id', presetId)
122
134
  .single();
123
135
  if (presetError || !preset) {
124
- sendEvent('error', { message: `Preset not found: ${presetId}` });
136
+ sendEvent('error', { message: 'Preset not found' });
125
137
  ws.close(4004, 'Preset not found');
126
138
  return;
127
139
  }
@@ -140,13 +152,14 @@ export async function handleConnection(ws, req) {
140
152
  ws.close(4003, 'Forbidden');
141
153
  return;
142
154
  }
143
- const clientInfo = await clientHelloPromise;
155
+ const clientInfo = clientHello?.client ?? null;
144
156
  const compatibilityError = getRemoteBrowserCompatibilityError(clientInfo);
145
157
  if (compatibilityError) {
146
158
  sendEvent('error', { message: compatibilityError });
147
159
  ws.close(4000, 'CLI update required');
148
160
  return;
149
161
  }
162
+ logger.info(`[capture] cli-version: ${clientInfo?.version ?? 'unknown'}, app-version: ${APP_VERSION}`);
150
163
  const config = hydratePresetConfigFromStorage(preset.config);
151
164
  const projectUrl = String(project.url ?? '');
152
165
  const planInput = {
@@ -161,18 +174,14 @@ export async function handleConnection(ws, req) {
161
174
  return;
162
175
  }
163
176
  invalidateDeviceConfigCache();
164
- const { pageRuns: pages, pageDefinitionIssues, langs, themes, variantPlan, targets, elements, outputScale, totalCaptures, } = await resolveCaptureRequestPlan(planInput);
165
- if (pageDefinitionIssues.length > 0) {
166
- sendEvent('error', {
167
- message: pageDefinitionIssues.map((issue) => issue.reason).join(' '),
168
- });
169
- ws.close(4000, 'Ambiguous capture pages');
170
- return;
171
- }
177
+ const { pageRuns: pages, langs, themes, variantPlan, targets, elements, outputScale, totalCaptures, } = await resolveCaptureRequestPlan(planInput);
172
178
  sendEvent('progress', { message: `Loaded preset "${preset.name}" for ${projectUrl}` });
173
179
  let resolvedModel = '';
174
180
  let resolvedFallback;
175
181
  let resolvedVisionModel;
182
+ let enableGeminiExplicitCache = false;
183
+ let enableCacheLayoutV2 = false;
184
+ let useVisionModelForAuxTasks = false;
176
185
  const providerPreferences = {};
177
186
  try {
178
187
  const { data: cfgRows } = await supabase
@@ -181,6 +190,7 @@ export async function handleConnection(ws, req) {
181
190
  .in('key', [
182
191
  'default_model', 'fallback_model', 'vision_model',
183
192
  'default_model_provider', 'fallback_model_provider', 'vision_model_provider',
193
+ 'enable_gemini_explicit_cache', 'enable_cache_layout_v2', 'use_vision_model_for_aux_tasks',
184
194
  ]);
185
195
  const cfg = {};
186
196
  for (const row of cfgRows ?? [])
@@ -208,6 +218,9 @@ export async function handleConnection(ws, req) {
208
218
  // Ignore invalid provider preference JSON.
209
219
  }
210
220
  }
221
+ enableGeminiExplicitCache = cfg.enable_gemini_explicit_cache === '1';
222
+ enableCacheLayoutV2 = cfg.enable_cache_layout_v2 === '1';
223
+ useVisionModelForAuxTasks = cfg.use_vision_model_for_aux_tasks === '1';
211
224
  }
212
225
  catch {
213
226
  // Non-blocking: the app_config table may be missing during bootstrap.
@@ -227,6 +240,18 @@ export async function handleConnection(ws, req) {
227
240
  const billingContext = await getProjectOwnerBillingContext(supabase, project.id);
228
241
  plan = billingContext.plan;
229
242
  billingOwnerUserId = billingContext.ownerUserId;
243
+ // Enforce per-user concurrent connection limit based on plan
244
+ const maxParallel = plan.entitlements.maxParallelCaptures;
245
+ const currentConns = getUserConnectionCount(auth.userId);
246
+ if (currentConns >= maxParallel) {
247
+ sendEvent('error', {
248
+ message: `Concurrent capture limit reached (${maxParallel}). Upgrade your plan for more parallel captures.`,
249
+ });
250
+ ws.close(4029, 'Concurrent capture limit reached');
251
+ return;
252
+ }
253
+ trackUserConnection(auth.userId, ws);
254
+ ws.on('close', () => untrackUserConnection(auth.userId, ws));
230
255
  const lockState = await getLockedResourceIds(supabase, billingOwnerUserId, plan);
231
256
  ensureResourceNotLocked(project.id, lockState.lockedProjectIds, 'project');
232
257
  const lockedPresets = lockState.lockedPresetIdsByProject.get(project.id);
@@ -295,16 +320,71 @@ export async function handleConnection(ws, req) {
295
320
  started_at: runStartedAt,
296
321
  });
297
322
  if (runInsertError) {
298
- sendEvent('error', { message: `Failed to create run: ${runInsertError.message}` });
323
+ logger.error(`[capture] Failed to create run: ${runInsertError.message}`);
324
+ sendEvent('error', { message: 'Failed to create capture run' });
299
325
  ws.close(5000, 'Internal error');
300
326
  return;
301
327
  }
328
+ // ── Live broadcast bridge ─────────────────────────────────────────
329
+ const broadcast = createCaptureBroadcast(runId, supabase);
330
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? '';
331
+ if (appUrl) {
332
+ sendEvent('live_url', { url: `${appUrl}/capture/live?runId=${runId}` });
333
+ }
334
+ broadcast.send({ type: 'run_id', data: { runId } });
335
+ /** Wrap an async function with logger callbacks that forward to the broadcast. */
336
+ function withBroadcastCallbacks(ctx, fn) {
337
+ return runWithLoggerCallbacks({
338
+ onLog: (entry) => {
339
+ broadcast.send({ type: 'log', data: { ...entry, lang: ctx.lang, theme: ctx.theme, targetId: ctx.targetId } });
340
+ if (entry.iteration !== undefined && entry.maxIterations !== undefined) {
341
+ broadcast.send({
342
+ type: 'progress',
343
+ data: {
344
+ iteration: entry.iteration,
345
+ maxIterations: entry.maxIterations,
346
+ lang: ctx.lang,
347
+ theme: ctx.theme,
348
+ targetId: ctx.targetId,
349
+ },
350
+ });
351
+ }
352
+ },
353
+ onScreenshot: (base64) => {
354
+ broadcast.sendScreenshot(base64, { lang: ctx.lang, theme: ctx.theme, targetId: ctx.targetId });
355
+ },
356
+ onReasoningDelta: (delta, messageId) => {
357
+ broadcast.send({
358
+ type: 'reasoning_delta',
359
+ data: { delta, messageId, lang: ctx.lang, theme: ctx.theme, targetId: ctx.targetId },
360
+ });
361
+ },
362
+ }, fn);
363
+ }
302
364
  const remoteBrowser = new RemoteBrowser(ws);
303
365
  const abortController = new AbortController();
304
366
  let aborted = false;
367
+ // Idle timeout: close connection after 10 minutes of inactivity.
368
+ const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
369
+ let idleTimer = setTimeout(() => {
370
+ logger.warn('[WS] Closing idle connection');
371
+ sendEvent('error', { message: 'Connection timed out due to inactivity' });
372
+ ws.close(4008, 'Idle timeout');
373
+ }, IDLE_TIMEOUT_MS);
374
+ const resetIdleTimer = () => {
375
+ clearTimeout(idleTimer);
376
+ idleTimer = setTimeout(() => {
377
+ logger.warn('[WS] Closing idle connection');
378
+ sendEvent('error', { message: 'Connection timed out due to inactivity' });
379
+ ws.close(4008, 'Idle timeout');
380
+ }, IDLE_TIMEOUT_MS);
381
+ };
382
+ ws.on('message', resetIdleTimer);
305
383
  ws.on('close', () => {
384
+ clearTimeout(idleTimer);
306
385
  aborted = true;
307
386
  abortController.abort();
387
+ broadcast.close();
308
388
  });
309
389
  const mockupState = {
310
390
  browser: null,
@@ -312,7 +392,10 @@ export async function handleConnection(ws, req) {
312
392
  };
313
393
  const getMockupContext = async () => {
314
394
  if (!mockupState.context) {
315
- mockupState.browser = await chromium.launch({ headless: true });
395
+ mockupState.browser = await chromium.launch({
396
+ headless: true,
397
+ args: CHROMIUM_ARGS,
398
+ });
316
399
  mockupState.context = await mockupState.browser.newContext();
317
400
  }
318
401
  return mockupState.context;
@@ -540,14 +623,19 @@ export async function handleConnection(ws, req) {
540
623
  };
541
624
  const persistFullPageCapture = async (params) => {
542
625
  const { pageRun, target, lang, theme, agentResult, includeActions, includeWorkflowScreenshots } = params;
626
+ broadcast.send({ type: 'capture_start', data: { lang, theme, targetId: target.id, targetLabel: target.label } });
543
627
  const pageSegment = sanitizeStorageSegment(pageRun.pageId);
544
- let finalBuffer = await remoteBrowser.takeScreenshot();
628
+ let finalBuffer = Buffer.alloc(0);
545
629
  let rawScreenshotUrl = null;
546
630
  let browserBar;
547
631
  try {
548
- await remoteBrowser.resizeViewport(target.viewport.width, target.viewport.height);
549
- await remoteBrowser.wait(300);
550
- finalBuffer = await remoteBrowser.takeScreenshot();
632
+ logger.info(`[persist] target=${target.id} viewport=${target.viewport.width}x${target.viewport.height} deviceScaleFactor=${outputScale} deviceFrame=${target.deviceFrame ?? 'none'}`);
633
+ const lockedScreenshot = await takeViewportLockedScreenshot(remoteBrowser, target.viewport, { deviceScaleFactor: outputScale });
634
+ finalBuffer = lockedScreenshot.buffer;
635
+ logger.info(`[persist] screenshot=${lockedScreenshot.screenshotSize.width}x${lockedScreenshot.screenshotSize.height} expected=${lockedScreenshot.expectedScreenshotSize.width}x${lockedScreenshot.expectedScreenshotSize.height} repaired=${lockedScreenshot.repaired}`);
636
+ if (lockedScreenshot.repaired) {
637
+ logger.info(`Viewport lock repaired before persisting ${target.id}: expected ${lockedScreenshot.expectedScreenshotSize.width}x${lockedScreenshot.expectedScreenshotSize.height}, got ${lockedScreenshot.screenshotSize.width}x${lockedScreenshot.screenshotSize.height}.`);
638
+ }
551
639
  if (target.deviceFrame) {
552
640
  const rawPath = `screenshots/${auth.userId}/${runId}/${pageSegment}/${target.id}_${lang}_${theme}_raw.png`;
553
641
  const { error } = await supabase.storage
@@ -572,6 +660,7 @@ export async function handleConnection(ws, req) {
572
660
  const localizedOptions = {
573
661
  ...mockupOptions,
574
662
  outputScale,
663
+ colorScheme: theme,
575
664
  statusBar: localizeStatusBar(mockupOptions.statusBar, lang),
576
665
  browserBar,
577
666
  };
@@ -590,10 +679,11 @@ export async function handleConnection(ws, req) {
590
679
  const screenshotUrl = uploadError
591
680
  ? null
592
681
  : supabase.storage.from(BUCKET).getPublicUrl(storagePath).data.publicUrl;
593
- const persistedSuccess = agentResult.success && !!screenshotUrl;
682
+ const terminalAgentSuccess = isTerminalAgentResultSuccess(agentResult);
683
+ const persistedSuccess = terminalAgentSuccess && !!screenshotUrl;
594
684
  const persistedAssessment = persistedSuccess
595
685
  ? agentResult.assessment
596
- : agentResult.success
686
+ : terminalAgentSuccess
597
687
  ? 'Capture completed but screenshot upload failed.'
598
688
  : agentResult.assessment || 'Capture failed';
599
689
  captureResults.push({
@@ -610,6 +700,22 @@ export async function handleConnection(ws, req) {
610
700
  iterations: agentResult.iterations,
611
701
  finalScreenshotUrl: screenshotUrl ?? undefined,
612
702
  });
703
+ broadcast.send({
704
+ type: 'result',
705
+ data: {
706
+ url: pageRun.url,
707
+ lang,
708
+ theme,
709
+ targetId: target.id,
710
+ targetLabel: target.label,
711
+ success: persistedSuccess,
712
+ assessment: persistedAssessment,
713
+ iterations: agentResult.iterations,
714
+ finalScreenshotUrl: screenshotUrl ?? undefined,
715
+ // Omit finalScreenshot base64 — too large for broadcast.
716
+ finalScreenshot: '',
717
+ },
718
+ });
613
719
  const costLogIds = await logAgentUsage({
614
720
  usage: agentResult.usage,
615
721
  lang,
@@ -708,7 +814,7 @@ export async function handleConnection(ws, req) {
708
814
  });
709
815
  void reconcilePendingBillingOperationCosts(supabase).catch(() => undefined);
710
816
  if (!persistedSuccess) {
711
- const errorType = agentResult.success
817
+ const errorType = terminalAgentSuccess
712
818
  ? 'storage_upload_failure'
713
819
  : agentResult.actions.some((action) => action.action === 'give_up')
714
820
  ? 'give_up'
@@ -820,6 +926,7 @@ export async function handleConnection(ws, req) {
820
926
  sendEvent('progress', {
821
927
  message: `[${captureIndex}/${totalCaptures}] ${lang}/${theme} · ${pageRun.pageId ?? 'main'} · ${target.label}`,
822
928
  });
929
+ broadcast.send({ type: 'capture_complete', data: { lang, theme, targetId: target.id, success: isTerminalAgentResultSuccess(agentResult) } });
823
930
  }
824
931
  };
825
932
  const persistElementCapture = async (params) => {
@@ -1025,7 +1132,7 @@ export async function handleConnection(ws, req) {
1025
1132
  });
1026
1133
  }
1027
1134
  };
1028
- sendEvent('capture_plan', {
1135
+ const capturePlanPayload = {
1029
1136
  variants: variantPlan.map((variant) => ({
1030
1137
  key: variant.key,
1031
1138
  lang: variant.lang,
@@ -1033,8 +1140,11 @@ export async function handleConnection(ws, req) {
1033
1140
  pages: variant.pages,
1034
1141
  })),
1035
1142
  totalCaptures,
1036
- });
1037
- const { identities: sharedPageIdentities } = await inferPageIdentitiesWithLLM(variantPlan[0]?.pages ?? [], resolvedModel, getOpenRouterKey(), Object.keys(providerPreferences).length > 0 ? providerPreferences : undefined);
1143
+ };
1144
+ sendEvent('capture_plan', capturePlanPayload);
1145
+ broadcast.send({ type: 'capture_plan', data: capturePlanPayload });
1146
+ const pageIdentityModel = (useVisionModelForAuxTasks && resolvedVisionModel) ? resolvedVisionModel : resolvedModel;
1147
+ const { identities: sharedPageIdentities } = await inferPageIdentitiesWithLLM(variantPlan[0]?.pages ?? [], pageIdentityModel, getOpenRouterKey(), Object.keys(providerPreferences).length > 0 ? providerPreferences : undefined);
1038
1148
  try {
1039
1149
  for (const [variantIndex, variant] of variantPlan.entries()) {
1040
1150
  if (aborted)
@@ -1044,6 +1154,7 @@ export async function handleConnection(ws, req) {
1044
1154
  const firstPage = variantPages[0];
1045
1155
  const variantCaptureStartIndex = captureIndex;
1046
1156
  const expectedVariantCaptureCount = (variantPages.length * targets.length) + elements.length;
1157
+ const variantCaptureIds = [];
1047
1158
  try {
1048
1159
  const firstDomain = getCaptureUrlHostname(firstPage.url);
1049
1160
  const persistedSessionProfile = await loadSessionProfileForVariant(firstDomain, lang, theme);
@@ -1105,6 +1216,40 @@ export async function handleConnection(ws, req) {
1105
1216
  await remoteBrowser.wait(500);
1106
1217
  await remoteBrowser.dismissOverlays().catch(() => undefined);
1107
1218
  }
1219
+ // Pre-authenticate before the language preflight when credentials are available.
1220
+ // Language controls are often locked behind login — without this the preflight
1221
+ // wastes all its iterations trying to switch language on the public page.
1222
+ // We force login (bypassing authMatches) because the persisted session profile
1223
+ // may report "authenticated" even though the current browser context has no
1224
+ // active session (cookies were restored but the server session expired).
1225
+ if (credentials?.email && credentials?.password && credentials.loginUrl) {
1226
+ try {
1227
+ await remoteBrowser.navigateTo(credentials.loginUrl);
1228
+ await remoteBrowser.wait(500);
1229
+ await performDeterministicSessionRepair(remoteBrowser, {
1230
+ startUrl: firstPage.url,
1231
+ requestedLang: lang,
1232
+ requestedTheme: theme,
1233
+ credentials,
1234
+ profile: activeSessionProfile,
1235
+ selectorMemory: variantStartSelectorMemory,
1236
+ });
1237
+ sendEvent('progress', { message: 'Pre-authenticated before language preflight.' });
1238
+ // Navigate back to the start page after login redirect
1239
+ if (!urlMatchesCaptureTarget(remoteBrowser.currentPage.url(), firstPage.url)) {
1240
+ await remoteBrowser.navigateTo(firstPage.url);
1241
+ await remoteBrowser.wait(500);
1242
+ await remoteBrowser.dismissOverlays().catch(() => undefined);
1243
+ }
1244
+ }
1245
+ catch {
1246
+ // Non-blocking: if pre-auth fails the language preflight will retry.
1247
+ if (!urlMatchesCaptureTarget(remoteBrowser.currentPage.url(), firstPage.url)) {
1248
+ await remoteBrowser.navigateTo(firstPage.url).catch(() => undefined);
1249
+ await remoteBrowser.wait(500);
1250
+ }
1251
+ }
1252
+ }
1108
1253
  const variantLanguagePreflight = await ensureScreenshotVariantLanguage({
1109
1254
  browser: remoteBrowser,
1110
1255
  requestedLang: lang,
@@ -1124,19 +1269,21 @@ export async function handleConnection(ws, req) {
1124
1269
  await remoteBrowser.dismissOverlays().catch(() => undefined);
1125
1270
  },
1126
1271
  runLanguageSwitchAgent: async ({ languageState, themeState }) => {
1127
- return runAgent(remoteBrowser, {
1272
+ return withBroadcastCallbacks({ lang, theme }, () => runAgent(remoteBrowser, {
1128
1273
  url: remoteBrowser.currentPage.url() || firstPage.url,
1129
1274
  prompt: [
1130
1275
  `Prepare the current page so the UI is rendered in "${lang}" and the theme is "${theme}".`,
1131
1276
  `Current inspection: language=${languageState?.detected ?? 'unknown'}; theme=${themeState?.detected ?? 'unknown'}.`,
1277
+ `Strategy: try navigate_to /settings first for language/theme controls; if the settings page has no such controls, look for an account/profile button (usually at the bottom of the sidebar — NOT the app logo) and open its dropdown menu. Do not click the same element more than once if it had no effect.`,
1278
+ config.langInstructions ? `Language switch hint: ${config.langInstructions}` : '',
1132
1279
  firstPage.prompt,
1133
- ].join(' '),
1280
+ ].filter(Boolean).join(' '),
1134
1281
  dark: theme === 'dark',
1135
1282
  langs: [lang],
1136
- outputDir: '/tmp/autokap-ws',
1283
+ outputDir: path.join(os.tmpdir(), 'autokap-ws'),
1137
1284
  headed: false,
1138
1285
  viewport: firstTarget.viewport,
1139
- maxIterations: Math.min(maxIterations, 10),
1286
+ maxIterations: Math.min(maxIterations, 12),
1140
1287
  model: resolvedModel,
1141
1288
  fallbackModel: resolvedFallback,
1142
1289
  visionModel: resolvedVisionModel,
@@ -1146,10 +1293,12 @@ export async function handleConnection(ws, req) {
1146
1293
  currentTheme: theme,
1147
1294
  selectorMemory: Object.keys(variantStartSelectorMemory).length > 0 ? variantStartSelectorMemory : undefined,
1148
1295
  sessionProfile: activeSessionProfile,
1296
+ langInstructions: config.langInstructions,
1297
+ themeInstructions: config.themeInstructions,
1149
1298
  runHints: runHints && runHints.length > 0 ? runHints : undefined,
1150
1299
  runMode: 'language_preflight',
1151
1300
  abortSignal: abortController.signal,
1152
- }, getOpenRouterKey());
1301
+ }, getOpenRouterKey()));
1153
1302
  },
1154
1303
  });
1155
1304
  if (variantLanguagePreflight.selectorUpdates.length > 0) {
@@ -1191,13 +1340,14 @@ export async function handleConnection(ws, req) {
1191
1340
  pageIdentity: variantManifest.currentPageIdentity ?? null,
1192
1341
  pageUrl: pageRun.url,
1193
1342
  });
1343
+ let trustedHandoffContext;
1194
1344
  const buildConfig = (viewport = firstTarget.viewport) => {
1195
1345
  const nextConfig = {
1196
1346
  url: pageRun.url,
1197
1347
  prompt: pageRun.prompt,
1198
1348
  dark: theme === 'dark',
1199
1349
  langs: [lang],
1200
- outputDir: '/tmp/autokap-ws',
1350
+ outputDir: path.join(os.tmpdir(), 'autokap-ws'),
1201
1351
  headed: false,
1202
1352
  viewport,
1203
1353
  maxIterations,
@@ -1212,13 +1362,17 @@ export async function handleConnection(ws, req) {
1212
1362
  currentTheme: theme,
1213
1363
  reasoningLocale: config.reasoningLocale,
1214
1364
  reasoningEffort: config.reasoningEffort ?? 'medium',
1365
+ reasoningCapableModels: config.reasoningCapableModels,
1215
1366
  runHints: runHints && runHints.length > 0 ? runHints : undefined,
1216
1367
  selectorMemory: Object.keys(selectorMemory).length > 0 ? selectorMemory : undefined,
1217
1368
  sessionProfile: activeSessionProfile,
1218
- handoffContext: carryoverContext,
1369
+ handoffContext: trustedHandoffContext,
1219
1370
  variantManifest,
1220
1371
  runMode: 'capture',
1221
1372
  analyticsId: auth.userId,
1373
+ enableGeminiExplicitCache,
1374
+ enableCacheLayoutV2,
1375
+ useVisionModelForAuxTasks,
1222
1376
  enableDeterministicRecovery: true,
1223
1377
  enableRecoveryEvaluator: true,
1224
1378
  enableSalienceCompression: true,
@@ -1236,49 +1390,90 @@ export async function handleConnection(ws, req) {
1236
1390
  message: `Preparing ${lang}/${theme} · ${pageId} (${pageIndex + 1}/${variantPages.length})`,
1237
1391
  });
1238
1392
  let primaryAgentResult = null;
1239
- const currentUrl = remoteBrowser.currentPage.url();
1240
- const canUseSequentialHandoff = !!(carryoverContext
1241
- && shouldUseSequentialPageHandoff({
1242
- pageIndex,
1243
- currentUrl,
1244
- pageUrl: pageRun.url,
1245
- previousPageId: carryoverContext.previousPageId,
1246
- pageId: pageRun.pageId,
1247
- previousPrompt: carryoverContext.previousPrompt,
1248
- pagePrompt: pageRun.prompt,
1249
- }));
1250
- const reuseLiveState = shouldReuseLivePageState({
1393
+ await remoteBrowser.dismissOverlays().catch(() => undefined);
1394
+ let baselineObservation = await remoteBrowser.captureObservation().catch(() => null);
1395
+ if (shouldNormalizeDialogsBeforeTargetReuse({
1396
+ currentDialogCount: baselineObservation?.dialogCount,
1397
+ pageIdentity: variantManifest.currentPageIdentity ?? null,
1398
+ })) {
1399
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1400
+ await remoteBrowser.pressKey('Escape').catch(() => undefined);
1401
+ await remoteBrowser.wait(180);
1402
+ baselineObservation = await remoteBrowser.captureObservation().catch(() => null);
1403
+ if (!shouldNormalizeDialogsBeforeTargetReuse({
1404
+ currentDialogCount: baselineObservation?.dialogCount,
1405
+ pageIdentity: variantManifest.currentPageIdentity ?? null,
1406
+ })) {
1407
+ break;
1408
+ }
1409
+ }
1410
+ }
1411
+ await remoteBrowser.wait(250);
1412
+ baselineObservation = baselineObservation ?? await remoteBrowser.captureObservation().catch(() => null);
1413
+ const baselineSignals = await remoteBrowser.capturePageSignals({
1414
+ timeoutMs: 1_500,
1415
+ }).catch(() => null);
1416
+ const transition = decideCaptureTransition({
1251
1417
  pageIndex,
1252
- presetRootUrl: projectUrl,
1253
- pageUrl: pageRun.url,
1254
- currentUrl,
1255
- isAuthenticated: activeSessionProfile?.authState === 'authenticated',
1418
+ currentUrl: remoteBrowser.currentPage.url(),
1419
+ targetUrl: pageRun.url,
1420
+ pageIdentity: variantManifest.currentPageIdentity ?? null,
1421
+ observation: baselineObservation,
1422
+ pageSignals: baselineSignals,
1423
+ profile: activeSessionProfile,
1424
+ handoffContext: carryoverContext,
1425
+ requestedLang: lang,
1426
+ requestedTheme: theme,
1427
+ });
1428
+ trustedHandoffContext = transition.allowFastPath ? carryoverContext : undefined;
1429
+ sendEvent('progress', {
1430
+ message: `Transition ${lang}/${theme} · ${pageId}: ${transition.mode}`
1431
+ + (transition.reasons[0] ? ` (${transition.reasons[0]})` : ''),
1256
1432
  });
1257
- if (canUseSequentialHandoff) {
1433
+ if (transition.mode === 'dirty') {
1434
+ const storageState = await remoteBrowser.exportStorageState().catch(() => undefined)
1435
+ ?? activeSessionProfile?.storageState;
1436
+ const sessionStorage = await remoteBrowser.exportSessionStorage().catch(() => undefined)
1437
+ ?? activeSessionProfile?.sessionStorage;
1438
+ await remoteBrowser.recreateContext({
1439
+ viewport: firstTarget.viewport,
1440
+ deviceScaleFactor: outputScale,
1441
+ lang,
1442
+ colorScheme: theme,
1443
+ storageState,
1444
+ });
1445
+ await remoteBrowser.prepareSessionStorage(sessionStorage, { replace: false });
1446
+ await remoteBrowser.navigateTo(pageRun.url);
1447
+ await remoteBrowser.wait(500);
1448
+ await remoteBrowser.dismissOverlays().catch(() => undefined);
1449
+ trustedHandoffContext = undefined;
1450
+ }
1451
+ else if (transition.shouldNavigateToCanonical) {
1452
+ await remoteBrowser.navigateTo(pageRun.url);
1453
+ await remoteBrowser.wait(500);
1454
+ await remoteBrowser.dismissOverlays().catch(() => undefined);
1455
+ trustedHandoffContext = undefined;
1456
+ }
1457
+ if (transition.allowFastPath) {
1258
1458
  const readiness = await verifyCaptureReadiness(remoteBrowser, buildConfig(), getOpenRouterKey(), {
1259
1459
  assessment: 'Sequential handoff preflight: approve only if the current live page already satisfies this capture.',
1260
1460
  stepNumber: 0,
1261
1461
  }).catch(() => null);
1262
- if (readiness?.verified) {
1462
+ if (readiness && isTerminalVerificationSuccess(readiness)) {
1263
1463
  primaryAgentResult = buildPreverifiedAgentResult('Capture approved directly from sequential handoff state.', readiness);
1264
1464
  }
1265
1465
  }
1266
- if (!primaryAgentResult && !canUseSequentialHandoff && !reuseLiveState && !urlMatchesCaptureTarget(currentUrl, pageRun.url)) {
1267
- await remoteBrowser.navigateTo(pageRun.url);
1268
- await remoteBrowser.wait(500);
1269
- await remoteBrowser.dismissOverlays().catch(() => undefined);
1270
- }
1271
1466
  if (!primaryAgentResult && variantIndex > 0) {
1272
1467
  const recordedActions = liveVariantActions.get(pageId);
1273
1468
  if (recordedActions && recordedActions.length > 0) {
1274
1469
  sendEvent('progress', {
1275
1470
  message: `Replaying first successful variant for ${lang}/${theme} · ${pageId}`,
1276
1471
  });
1277
- primaryAgentResult = await replayAgent(remoteBrowser, buildConfig(), getOpenRouterKey(), recordedActions, { allowFullAgentFallback: true }).catch((error) => makeFailedAgentResult(error.message));
1472
+ primaryAgentResult = await withBroadcastCallbacks({ lang, theme, targetId: firstTarget.id }, () => replayAgent(remoteBrowser, buildConfig(), getOpenRouterKey(), recordedActions, { allowFullAgentFallback: true })).catch((error) => makeFailedAgentResult(error.message));
1278
1473
  }
1279
1474
  }
1280
1475
  if (!primaryAgentResult) {
1281
- primaryAgentResult = await runAgent(remoteBrowser, buildConfig(), getOpenRouterKey()).catch((error) => makeFailedAgentResult(error.message));
1476
+ primaryAgentResult = await withBroadcastCallbacks({ lang, theme, targetId: firstTarget.id }, () => runAgent(remoteBrowser, buildConfig(), getOpenRouterKey())).catch((error) => makeFailedAgentResult(error.message));
1282
1477
  }
1283
1478
  const preparedPageUrl = remoteBrowser.currentPage.url() || pageRun.url;
1284
1479
  await persistFullPageCapture({
@@ -1291,8 +1486,15 @@ export async function handleConnection(ws, req) {
1291
1486
  includeWorkflowScreenshots: true,
1292
1487
  });
1293
1488
  for (const target of targets.slice(1)) {
1294
- await remoteBrowser.resizeViewport(target.viewport.width, target.viewport.height);
1295
- await remoteBrowser.wait(250);
1489
+ await lockCaptureViewport(remoteBrowser, target.viewport, { settleMs: 250 });
1490
+ // Re-navigate to force a full CSS re-layout at the new viewport width.
1491
+ // Without this, the page content stays rendered at firstTarget's width
1492
+ // and gets squeezed into the smaller viewport.
1493
+ const currentUrl = remoteBrowser.currentPage.url();
1494
+ if (currentUrl) {
1495
+ await remoteBrowser.navigateTo(currentUrl);
1496
+ await remoteBrowser.wait(500);
1497
+ }
1296
1498
  await remoteBrowser.dismissOverlays().catch(() => undefined);
1297
1499
  let targetResult;
1298
1500
  const targetConfig = buildConfig(target.viewport);
@@ -1300,11 +1502,11 @@ export async function handleConnection(ws, req) {
1300
1502
  assessment: 'Same-session target recheck: approve only if the resized page still satisfies the capture.',
1301
1503
  stepNumber: 0,
1302
1504
  }).catch(() => null);
1303
- if (targetReadiness?.verified) {
1505
+ if (targetReadiness && isTerminalVerificationSuccess(targetReadiness)) {
1304
1506
  targetResult = buildPreverifiedAgentResult('Capture approved after same-session target recheck.', targetReadiness);
1305
1507
  }
1306
1508
  else {
1307
- targetResult = await replayAgent(remoteBrowser, targetConfig, getOpenRouterKey(), primaryAgentResult.actions, { allowFullAgentFallback: false }).catch((error) => makeFailedAgentResult(error.message));
1509
+ targetResult = await withBroadcastCallbacks({ lang, theme, targetId: target.id }, () => replayAgent(remoteBrowser, targetConfig, getOpenRouterKey(), primaryAgentResult.actions, { allowFullAgentFallback: false })).catch((error) => makeFailedAgentResult(error.message));
1308
1510
  }
1309
1511
  await persistFullPageCapture({
1310
1512
  pageRun,
@@ -1317,7 +1519,7 @@ export async function handleConnection(ws, req) {
1317
1519
  });
1318
1520
  await restorePreparedPageState(remoteBrowser, firstTarget.viewport, preparedPageUrl);
1319
1521
  }
1320
- if (primaryAgentResult.success) {
1522
+ if (isTerminalAgentResultSuccess(primaryAgentResult)) {
1321
1523
  const recorded = recordValidatedVariantCapture({
1322
1524
  state: variantState,
1323
1525
  capture: {
@@ -1344,7 +1546,7 @@ export async function handleConnection(ws, req) {
1344
1546
  };
1345
1547
  }
1346
1548
  }
1347
- if (primaryAgentResult.success) {
1549
+ if (isTerminalAgentResultSuccess(primaryAgentResult)) {
1348
1550
  if (primaryAgentResult.actions.length > 0 && !liveVariantActions.has(pageId)) {
1349
1551
  liveVariantActions.set(pageId, primaryAgentResult.actions);
1350
1552
  }
@@ -1434,6 +1636,7 @@ export async function handleConnection(ws, req) {
1434
1636
  theme,
1435
1637
  selectorMemory,
1436
1638
  actions: primaryAgentResult.actions,
1639
+ agentResult: primaryAgentResult,
1437
1640
  });
1438
1641
  }
1439
1642
  else {
@@ -1444,6 +1647,18 @@ export async function handleConnection(ws, req) {
1444
1647
  });
1445
1648
  carryoverContext = undefined;
1446
1649
  }
1650
+ // Always export storage state after each page attempt so subsequent
1651
+ // variants can reuse the authenticated session even if this capture failed.
1652
+ try {
1653
+ const latestStorageState = await remoteBrowser.exportStorageState();
1654
+ if (latestStorageState) {
1655
+ runSharedStorageState = latestStorageState;
1656
+ runSharedSessionStorage = await remoteBrowser.exportSessionStorage().catch(() => undefined);
1657
+ }
1658
+ }
1659
+ catch {
1660
+ // Best-effort: keep previous storage state
1661
+ }
1447
1662
  }
1448
1663
  const variantValidation = validateVariantCaptureState(variantState);
1449
1664
  if (!variantValidation.ok) {
@@ -1462,12 +1677,17 @@ export async function handleConnection(ws, req) {
1462
1677
  success: variantValidation.ok,
1463
1678
  reason: variantValidation.ok ? null : 'Variant completed with blocking issues.',
1464
1679
  });
1680
+ broadcast.send({ type: 'variant_complete', data: { lang, theme, success: variantValidation.ok, reason: variantValidation.ok ? undefined : 'Variant completed with blocking issues.' } });
1465
1681
  }
1466
1682
  catch (error) {
1467
1683
  if (aborted)
1468
1684
  throw error;
1469
1685
  const message = error.message;
1470
1686
  logger.error(`Variant ${lang}/${theme} failed: ${message}`);
1687
+ // Revoke credits for captures that were recorded during this failed variant
1688
+ for (const failedCaptureId of variantCaptureIds) {
1689
+ await revokeCreditUsage(supabase, failedCaptureId).catch((err) => logger.error(`Failed to revoke credit for capture ${failedCaptureId}: ${err.message}`));
1690
+ }
1471
1691
  const consumedCaptures = captureIndex - variantCaptureStartIndex;
1472
1692
  const remainingCaptures = Math.max(0, expectedVariantCaptureCount - consumedCaptures);
1473
1693
  if (remainingCaptures > 0) {
@@ -1497,6 +1717,7 @@ export async function handleConnection(ws, req) {
1497
1717
  success: false,
1498
1718
  reason: message,
1499
1719
  });
1720
+ broadcast.send({ type: 'variant_complete', data: { lang, theme, success: false, reason: message } });
1500
1721
  continue;
1501
1722
  }
1502
1723
  }
@@ -1513,6 +1734,8 @@ export async function handleConnection(ws, req) {
1513
1734
  })
1514
1735
  .eq('id', runId);
1515
1736
  sendEvent('error', { message });
1737
+ broadcast.send({ type: 'error', data: { message } });
1738
+ broadcast.close();
1516
1739
  await finalizeRunSideEffects();
1517
1740
  remoteBrowser.destroy();
1518
1741
  await closeMockup();
@@ -1540,6 +1763,8 @@ export async function handleConnection(ws, req) {
1540
1763
  },
1541
1764
  });
1542
1765
  sendEvent('done', { summary: { successes: successCount, total: totalCaptures } });
1766
+ broadcast.send({ type: 'done', data: { totalCaptures, successCount } });
1767
+ broadcast.close();
1543
1768
  if (plan.entitlements.captureCompleteWebhook) {
1544
1769
  try {
1545
1770
  const webhookConfig = await getProjectWebhookConfig(supabase, project.id);
@@ -1564,100 +1789,5 @@ export async function handleConnection(ws, req) {
1564
1789
  await closeMockup();
1565
1790
  logger.success(`Run ${runId} completed: ${successCount}/${totalCaptures}`);
1566
1791
  }
1567
- function urlsRoughlyMatch(left, right) {
1568
- return urlsPointToSameCaptureState(left, right);
1569
- }
1570
- async function loadRunHints(supabase, presetId) {
1571
- try {
1572
- const { data } = await supabase
1573
- .from('agent_errors')
1574
- .select('error_type, message, user_response, created_at')
1575
- .eq('preset_id', presetId)
1576
- .order('created_at', { ascending: false })
1577
- .limit(10);
1578
- const hints = buildAgentRunHints((data ?? []));
1579
- return hints.length > 0 ? hints : undefined;
1580
- }
1581
- catch {
1582
- return undefined;
1583
- }
1584
- }
1585
- function buildPersistableProfile(previousProfile, storageState, sessionStorage, currentUrl, lang, theme) {
1586
- return {
1587
- storageState,
1588
- sessionStorage,
1589
- authState: previousProfile?.authState ?? inferAuthState(storageState, sessionStorage),
1590
- accountLabel: previousProfile?.accountLabel ?? null,
1591
- detectedLang: lang,
1592
- detectedTheme: theme,
1593
- validatedStartUrl: previousProfile?.validatedStartUrl ?? currentUrl,
1594
- lastKnownUrl: currentUrl,
1595
- summary: previousProfile?.summary ?? 'Validated browser state captured from a successful WS-driven run.',
1596
- validationStatus: previousProfile?.validationStatus ?? 'unknown',
1597
- lastUsedAt: new Date().toISOString(),
1598
- profileVersion: previousProfile?.profileVersion ?? 1,
1599
- };
1600
- }
1601
- function inferAuthState(storageState, sessionStorage) {
1602
- const structuredStorageState = storageState && typeof storageState === 'object' && !Array.isArray(storageState)
1603
- ? storageState
1604
- : undefined;
1605
- const cookieCount = structuredStorageState?.cookies?.length ?? 0;
1606
- const originCount = structuredStorageState?.origins?.length ?? 0;
1607
- const sessionOriginCount = sessionStorage ? Object.keys(sessionStorage).length : 0;
1608
- return cookieCount > 0 || originCount > 0 || sessionOriginCount > 0
1609
- ? 'authenticated'
1610
- : 'unknown';
1611
- }
1612
- function buildPreverifiedAgentResult(assessment, verification) {
1613
- return {
1614
- success: true,
1615
- screenshotPath: null,
1616
- screenshots: [],
1617
- iterations: 0,
1618
- actions: [],
1619
- assessment,
1620
- usage: verification.usage ? [verification.usage] : [],
1621
- runtimeStrategy: 'preverified_handoff',
1622
- deterministicRecoveryUsed: false,
1623
- evaluatorUsed: false,
1624
- verification,
1625
- };
1626
- }
1627
- function makeFailedAgentResult(message) {
1628
- return {
1629
- success: false,
1630
- screenshotPath: null,
1631
- assessment: message,
1632
- iterations: 0,
1633
- actions: [],
1634
- screenshots: [],
1635
- usage: [],
1636
- };
1637
- }
1638
- async function buildHandoffContext(params) {
1639
- const handoffArtifacts = buildSequentialHandoffArtifacts(params.actions ?? []);
1640
- return {
1641
- previousPageId: params.pageRun.pageId,
1642
- previousPrompt: params.pageRun.prompt,
1643
- currentUrl: params.browser.currentPage.url() || params.pageRun.url,
1644
- pageTitle: await params.browser.currentPage.title().catch(() => null),
1645
- authState: params.profile.authState,
1646
- accountLabel: params.profile.accountLabel ?? null,
1647
- currentLang: params.lang,
1648
- currentTheme: params.theme,
1649
- summary: `Continue from the validated live state reached for ${params.pageRun.pageId ?? 'main'}.`,
1650
- selectorHints: handoffArtifacts.selectorHints,
1651
- navigationHints: [],
1652
- selectorMemory: mergeSelectorMemory(params.selectorMemory, handoffArtifacts.selectorMemory),
1653
- };
1654
- }
1655
- async function restorePreparedPageState(browser, viewport, preparedPageUrl) {
1656
- await browser.resizeViewport(viewport.width, viewport.height);
1657
- if (!urlsRoughlyMatch(browser.currentPage.url(), preparedPageUrl)) {
1658
- await browser.navigateTo(preparedPageUrl);
1659
- }
1660
- await browser.dismissOverlays().catch(() => undefined);
1661
- await browser.wait(250);
1662
- }
1792
+ // Standalone utilities have been extracted to ws-handler-utils.ts
1663
1793
  //# sourceMappingURL=ws-handler.js.map