synabun 2.0.0-beta → 2.0.5

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 (374) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/NOTICE +5 -0
  3. package/README.md +1 -1
  4. package/THIRD-PARTY-LICENSES.md +51 -0
  5. package/benchmarks/fixtures/browser/README.md +1 -1
  6. package/benchmarks/fixtures/desktop/README.md +1 -1
  7. package/docs/whatsapp.md +2 -2
  8. package/hooks/claude-code/pre-compact.mjs +11 -0
  9. package/hooks/claude-code/prompt-submit.mjs +60 -184
  10. package/hooks/claude-code/session-start.mjs +73 -16
  11. package/hooks/claude-code/shared.mjs +20 -0
  12. package/hooks/claude-code/stop.mjs +5 -5
  13. package/launcher.mjs +40 -0
  14. package/lib/claude-hooks.js +61 -8
  15. package/lib/client-config-repair.js +4 -3
  16. package/lib/data-home-migration.js +4 -0
  17. package/lib/desktop-pwa.js +206 -0
  18. package/lib/external-tools.js +221 -0
  19. package/lib/first-launch-protection.js +5 -0
  20. package/lib/packaged-runtime.js +110 -0
  21. package/lib/start-launcher.js +1396 -0
  22. package/mcp-server/package-lock.json +2 -2
  23. package/mcp-server/package.json +1 -3
  24. package/neural-interface/i18n/en.json +1540 -154
  25. package/neural-interface/i18n/pt-BR.json +1538 -153
  26. package/neural-interface/lib/assistant-api.js +62 -6
  27. package/neural-interface/lib/assistant-brains/claude.js +4 -0
  28. package/neural-interface/lib/assistant-catalog.js +179 -27
  29. package/neural-interface/lib/assistant-config.js +31 -2
  30. package/neural-interface/lib/assistant-dispatch.js +62 -13
  31. package/neural-interface/lib/assistant-envelope.js +9 -6
  32. package/neural-interface/lib/assistant-hidden-models.js +393 -66
  33. package/neural-interface/lib/assistant-memory.js +24 -10
  34. package/neural-interface/lib/assistant-persona.js +6 -2
  35. package/neural-interface/lib/assistant-pricing.js +21 -6
  36. package/neural-interface/lib/assistant-runtime.js +181 -12
  37. package/neural-interface/lib/assistant-task-prompt.js +13 -3
  38. package/neural-interface/lib/assistant-test-density.js +142 -0
  39. package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
  40. package/neural-interface/lib/assistant-user-context.js +908 -0
  41. package/neural-interface/lib/assistant-user-knowledge.js +328 -0
  42. package/neural-interface/lib/assistant-user-learn.js +404 -0
  43. package/neural-interface/lib/assistant-user-threads.js +226 -0
  44. package/neural-interface/lib/assistant-user-writer.js +181 -0
  45. package/neural-interface/lib/augmented-path.js +23 -3
  46. package/neural-interface/lib/browser-background-tab.js +134 -0
  47. package/neural-interface/lib/browser-language.js +83 -0
  48. package/neural-interface/lib/browser-screenshot.js +678 -61
  49. package/neural-interface/lib/claude-agent-bridge.js +87 -153
  50. package/neural-interface/lib/claude-executable.js +241 -0
  51. package/neural-interface/lib/claude-model-catalog.js +82 -93
  52. package/neural-interface/lib/claude-temporary.js +46 -7
  53. package/neural-interface/lib/cloudflared/install.js +533 -0
  54. package/neural-interface/lib/cloudflared/manager.js +545 -0
  55. package/neural-interface/lib/cloudflared/pins.js +115 -0
  56. package/neural-interface/lib/cloudflared/pins.json +63 -0
  57. package/neural-interface/lib/cloudflared/tunnel.js +265 -0
  58. package/neural-interface/lib/editor-completion-endpoint.js +264 -0
  59. package/neural-interface/lib/editor-completion-opencode.js +436 -0
  60. package/neural-interface/lib/editor-completion.js +714 -0
  61. package/neural-interface/lib/editor-config.js +149 -0
  62. package/neural-interface/lib/editor-files-api.js +268 -0
  63. package/neural-interface/lib/editor-git-api.js +134 -0
  64. package/neural-interface/lib/file-explorer-api.js +1039 -0
  65. package/neural-interface/lib/file-explorer-system.js +355 -0
  66. package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
  67. package/neural-interface/lib/file-explorer-watch.js +239 -0
  68. package/neural-interface/lib/git-diff-api.js +103 -0
  69. package/neural-interface/lib/git-read.js +202 -0
  70. package/neural-interface/lib/greeting/api.js +89 -0
  71. package/neural-interface/lib/greeting/budgets.js +90 -0
  72. package/neural-interface/lib/greeting/engine.js +461 -0
  73. package/neural-interface/lib/greeting/facts.js +320 -0
  74. package/neural-interface/lib/greeting/sources.js +252 -0
  75. package/neural-interface/lib/greeting/writer.js +139 -0
  76. package/neural-interface/lib/http-guards.js +189 -13
  77. package/neural-interface/lib/machine-names.js +88 -0
  78. package/neural-interface/lib/memory-api.js +31 -7
  79. package/neural-interface/lib/native-binary-runtime.js +69 -337
  80. package/neural-interface/lib/native-loop-providers.js +40 -49
  81. package/neural-interface/lib/offline-page.js +54 -0
  82. package/neural-interface/lib/plugin-installer.js +4 -2
  83. package/neural-interface/lib/project-storage.js +5 -7
  84. package/neural-interface/lib/request-gate.js +589 -0
  85. package/neural-interface/lib/session-title-generator.js +5 -0
  86. package/neural-interface/lib/synabun-update-plan.js +19 -0
  87. package/neural-interface/lib/ui-blob-api.js +108 -0
  88. package/neural-interface/lib/ui-blob-store.js +68 -0
  89. package/neural-interface/package-lock.json +701 -253
  90. package/neural-interface/package.json +13 -2
  91. package/neural-interface/public/install-app.html +34 -0
  92. package/neural-interface/public/manifest.json +2 -0
  93. package/neural-interface/public/offline.html +402 -66
  94. package/neural-interface/public/onboarding.html +264 -147
  95. package/neural-interface/public/shared/agent-runtime-options.js +22 -6
  96. package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
  97. package/neural-interface/public/shared/assistant/asst-brain-picker.js +312 -62
  98. package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
  99. package/neural-interface/public/shared/assistant/asst-mascot.js +26 -64
  100. package/neural-interface/public/shared/assistant/asst-menu.js +12 -8
  101. package/neural-interface/public/shared/assistant/asst-panel.js +150 -28
  102. package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
  103. package/neural-interface/public/shared/assistant/asst-render.js +219 -67
  104. package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
  105. package/neural-interface/public/shared/assistant/asst-route.js +23 -329
  106. package/neural-interface/public/shared/assistant/asst-routes-editor.js +495 -0
  107. package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
  108. package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
  109. package/neural-interface/public/shared/assistant/asst-state.js +185 -1
  110. package/neural-interface/public/shared/assistant/asst-styles.js +162 -6
  111. package/neural-interface/public/shared/blob-store.js +111 -22
  112. package/neural-interface/public/shared/cdx/cdx-panel.js +32 -11
  113. package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
  114. package/neural-interface/public/shared/cdx/cdx-styles.js +36 -0
  115. package/neural-interface/public/shared/cdx/cdx-tabs.js +87 -4
  116. package/neural-interface/public/shared/command-runner-model.js +30 -0
  117. package/neural-interface/public/shared/constants.js +6 -1
  118. package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
  119. package/neural-interface/public/shared/cp/cp-styles.js +15 -0
  120. package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
  121. package/neural-interface/public/shared/editor/editor-ai.js +619 -0
  122. package/neural-interface/public/shared/editor/editor-commands.js +212 -0
  123. package/neural-interface/public/shared/editor/editor-compare.js +103 -0
  124. package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
  125. package/neural-interface/public/shared/editor/editor-doc.js +257 -0
  126. package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
  127. package/neural-interface/public/shared/editor/editor-git.js +95 -0
  128. package/neural-interface/public/shared/editor/editor-languages.js +156 -0
  129. package/neural-interface/public/shared/editor/editor-lint.js +229 -0
  130. package/neural-interface/public/shared/editor/editor-loader.js +51 -0
  131. package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
  132. package/neural-interface/public/shared/editor/editor-menu.js +233 -0
  133. package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
  134. package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
  135. package/neural-interface/public/shared/editor/editor-palette.js +125 -0
  136. package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
  137. package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
  138. package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
  139. package/neural-interface/public/shared/editor/editor-safety.js +500 -0
  140. package/neural-interface/public/shared/editor/editor-search.js +352 -0
  141. package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
  142. package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
  143. package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
  144. package/neural-interface/public/shared/editor/editor-theme.js +244 -0
  145. package/neural-interface/public/shared/editor/editor-view.js +332 -0
  146. package/neural-interface/public/shared/explorer/fx-api.js +216 -0
  147. package/neural-interface/public/shared/explorer/fx-links.js +204 -0
  148. package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
  149. package/neural-interface/public/shared/explorer/fx-main.js +865 -0
  150. package/neural-interface/public/shared/explorer/fx-names.js +87 -0
  151. package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
  152. package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
  153. package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
  154. package/neural-interface/public/shared/file-links.js +339 -0
  155. package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
  156. package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
  157. package/neural-interface/public/shared/html-shell.js +169 -170
  158. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
  159. package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
  160. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +99 -17
  161. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
  162. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +5 -0
  163. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +40 -0
  164. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
  165. package/neural-interface/public/shared/pwa-install-page.js +62 -0
  166. package/neural-interface/public/shared/pwa-install.css +12 -0
  167. package/neural-interface/public/shared/pwa-install.js +127 -0
  168. package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
  169. package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
  170. package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
  171. package/neural-interface/public/shared/settings/settings-ia.js +18 -4
  172. package/neural-interface/public/shared/settings/settings-rules.js +160 -0
  173. package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
  174. package/neural-interface/public/shared/start-bridge.js +802 -0
  175. package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
  176. package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
  177. package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
  178. package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
  179. package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
  180. package/neural-interface/public/shared/styles.css +2219 -1117
  181. package/neural-interface/public/shared/synabun-mascot.js +20 -56
  182. package/neural-interface/public/shared/term-ready.js +307 -0
  183. package/neural-interface/public/shared/tunnel-view.js +167 -0
  184. package/neural-interface/public/shared/ui-claude-panel.js +160 -19
  185. package/neural-interface/public/shared/ui-command-runner.js +251 -468
  186. package/neural-interface/public/shared/ui-detail.js +15 -0
  187. package/neural-interface/public/shared/ui-explorer.js +22 -0
  188. package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
  189. package/neural-interface/public/shared/ui-file-links.js +103 -0
  190. package/neural-interface/public/shared/ui-help.js +26 -0
  191. package/neural-interface/public/shared/ui-invite.js +126 -116
  192. package/neural-interface/public/shared/ui-judgments.js +1 -1
  193. package/neural-interface/public/shared/ui-keybinds.js +4 -0
  194. package/neural-interface/public/shared/ui-loading.js +367 -12
  195. package/neural-interface/public/shared/ui-menubar.js +21 -55
  196. package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
  197. package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
  198. package/neural-interface/public/shared/ui-navbar.js +45 -21
  199. package/neural-interface/public/shared/ui-settings.js +491 -547
  200. package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
  201. package/neural-interface/public/shared/ui-sync.js +16 -0
  202. package/neural-interface/public/shared/ui-terminal.js +153 -149
  203. package/neural-interface/public/shared/ui-tunnel.js +130 -0
  204. package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
  205. package/neural-interface/public/shared/ui-whiteboard.js +12 -0
  206. package/neural-interface/public/shared/ui-worker-end.js +245 -0
  207. package/neural-interface/public/shared/ui-workspaces.js +41 -27
  208. package/neural-interface/public/shared/worker-end.js +251 -0
  209. package/neural-interface/public/sw.js +3 -1
  210. package/neural-interface/public/variant/2d/main.js +2 -1
  211. package/neural-interface/public/variant/3d/main.js +2 -1
  212. package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
  213. package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
  214. package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
  215. package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
  216. package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
  217. package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
  218. package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
  219. package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
  220. package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
  221. package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
  222. package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
  223. package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
  224. package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
  225. package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
  226. package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
  227. package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
  228. package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
  229. package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
  230. package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
  231. package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
  232. package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
  233. package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
  234. package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
  235. package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
  236. package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
  237. package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
  238. package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
  239. package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
  240. package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
  241. package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
  242. package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
  243. package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
  244. package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
  245. package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
  246. package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
  247. package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
  248. package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
  249. package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
  250. package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
  251. package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
  252. package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
  253. package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
  254. package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
  255. package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
  256. package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
  257. package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
  258. package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
  259. package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
  260. package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
  261. package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
  262. package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
  263. package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
  264. package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
  265. package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
  266. package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
  267. package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
  268. package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
  269. package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
  270. package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
  271. package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
  272. package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
  273. package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
  274. package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
  275. package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
  276. package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
  277. package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
  278. package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
  279. package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
  280. package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
  281. package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
  282. package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
  283. package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
  284. package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
  285. package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
  286. package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
  287. package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
  288. package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
  289. package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
  290. package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
  291. package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
  292. package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
  293. package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
  294. package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
  295. package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
  296. package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
  297. package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
  298. package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
  299. package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
  300. package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
  301. package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
  302. package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
  303. package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
  304. package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
  305. package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
  306. package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
  307. package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
  308. package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
  309. package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
  310. package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
  311. package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
  312. package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
  313. package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
  314. package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
  315. package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
  316. package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
  317. package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
  318. package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
  319. package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
  320. package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
  321. package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
  322. package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
  323. package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
  324. package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
  325. package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
  326. package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
  327. package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
  328. package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
  329. package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
  330. package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
  331. package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
  332. package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
  333. package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
  334. package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
  335. package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
  336. package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
  337. package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
  338. package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
  339. package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
  340. package/neural-interface/scripts/build-vendor.mjs +78 -2
  341. package/neural-interface/scripts/rebuild-pty.js +15 -12
  342. package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
  343. package/neural-interface/server.js +1092 -1212
  344. package/neural-interface/templates/rulesets/README.md +1 -1
  345. package/package.json +13 -7
  346. package/postinstall.js +11 -81
  347. package/setup.js +111 -6
  348. package/benchmarks/browser-live-results.json +0 -79
  349. package/benchmarks/browser-v2-results.json +0 -258
  350. package/docs/browser-v2.md +0 -109
  351. package/docs/hook-integrity.md +0 -89
  352. package/docs/judgments.md +0 -182
  353. package/docs/style-guide.md +0 -246
  354. package/neural-interface/lib/codex-capabilities.d.ts +0 -41
  355. package/neural-interface/public/category-logos/claude.svg +0 -1
  356. package/neural-interface/public/category-logos/gemini-color.svg +0 -1
  357. package/neural-interface/public/category-logos/openai.svg +0 -1
  358. package/neural-interface/public/category-logos/synabun.png +0 -0
  359. package/neural-interface/public/claude.svg +0 -1
  360. package/neural-interface/public/gallery.svg +0 -6
  361. package/neural-interface/public/leonardoai.svg +0 -1
  362. package/neural-interface/public/mcp.svg +0 -4
  363. package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
  364. package/neural-interface/public/pin-svgrepo-com.svg +0 -25
  365. package/neural-interface/public/sessionicon.svg +0 -8
  366. package/neural-interface/public/toolbar-album.svg +0 -8
  367. package/neural-interface/public/toolbar-question.svg +0 -6
  368. package/neural-interface/public/toolbar-sessions.svg +0 -6
  369. package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
  370. package/scripts/audit-claude-hook-usage.mjs +0 -12
  371. package/scripts/audit-memory-duplicates.mjs +0 -44
  372. package/scripts/measure-claude-hook-overhead.mjs +0 -77
  373. package/scripts/recover-user-config.mjs +0 -254
  374. package/skills/synabun/synabunicon.png +0 -0
@@ -8,12 +8,34 @@
8
8
  // A viewport size is a CDP device-metrics override sent from a CDP session opened
9
9
  // for this capture only. Chromium keeps one override per page, and detaching the
10
10
  // session does not remove it, so it is always cleared explicitly when the capture
11
- // ends — on errors and timeouts too — and the tab's innerWidth and
11
+ // ends — on errors and timeouts too — and the tab's innerWidth, innerHeight and
12
12
  // devicePixelRatio are read again against the values from before the capture. A
13
13
  // clear that fails, hangs or does not take is sent once more from a fresh CDP
14
- // session; a tab still not confirmed is reported (viewportRestored: false) and
15
- // server.js closes it, so a MoreLogin tab never keeps a size, DPR or mobile flag
16
- // its profile does not have.
14
+ // session; a tab still not confirmed is retired (viewportRestored: false):
15
+ // server.js takes it out of routing at once and closes it, so a MoreLogin tab
16
+ // never keeps a size, DPR or mobile flag its profile does not have.
17
+ //
18
+ // Measured in stock Chromium 147 with a real window (tests/browser-screenshot-
19
+ // window.browser.mjs), not yet on a MoreLogin browser: Page.captureScreenshot got
20
+ // no answer for the selected tab of a window that was minimized or whose
21
+ // application was hidden, for as long as it was waited on, while background tabs
22
+ // of that window were captured. The page cannot tell (visibilityState stays
23
+ // visible, requestAnimationFrame keeps firing), and for a capture with a clip
24
+ // Chromium has by then resized the tab's view to the clip (64x64 for the scale
25
+ // measurement) and puts the size back only when the capture answers. So every
26
+ // capture first sends one capture without a clip, which resizes nothing: no
27
+ // answer in time is reported as such, with the window state CDP reports, and the
28
+ // tab is untouched. A capture that stops answering later is cut short with the
29
+ // name of its phase, and the view is put back with Emulation.setVisibleSize. The
30
+ // page reads the clip's size only once the capture's CDP session is gone (its own
31
+ // emulation hides it until then; measured, about 2 ms after the detach answers, 2.2 ms seen),
32
+ // so the tab is confirmed after the detach, on two readings 150 ms apart.
33
+ //
34
+ // One capture runs per tab at a time, and one budget (the request deadline)
35
+ // covers the capture and putting the tab back: the answer never waits past it.
36
+ // A capture the browser never answered, on a CDP session that could not be
37
+ // confirmed detached, may still be pending: its tab is not given to the next
38
+ // capture until one of the two ends, and is retired if neither does.
17
39
 
18
40
  import { closeSync, constants as fsConstants, existsSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, unlinkSync, writeSync } from 'node:fs';
19
41
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
@@ -234,44 +256,318 @@ export function writeScreenshotFile(path, bytes, { dataHome = getDataHome(), reg
234
256
  // ── capture ─────────────────────────────────────────────────────────────────
235
257
 
236
258
  const delay = (ms) => new Promise((done) => setTimeout(done, ms));
237
- const within = (promise, ms, what) => Promise.race([promise, delay(ms).then(() => { throw new Error(`${what} timed out`); })]);
238
- // How long one clear, one fresh CDP session or one viewport read may take.
259
+ // `promise`, or a rejection after `ms`. `late(value)` gets a value that arrives
260
+ // after the wait was given up, so what it names (a CDP session) is not left behind.
261
+ const within = (promise, ms, what, late = null) => {
262
+ let timer;
263
+ let over = false;
264
+ const watched = Promise.resolve(promise);
265
+ if (late) watched.then((value) => { if (over) Promise.resolve().then(() => late(value)).catch(() => {}); }, () => {});
266
+ return Promise.race([watched, new Promise((_, fail) => { timer = setTimeout(() => { over = true; fail(new Error(`${what} timed out`)); }, ms); })]).finally(() => clearTimeout(timer));
267
+ };
268
+ // The longest one clear, one fresh CDP session or one viewport read may take.
269
+ // Every such wait is a slice of the capture's one budget (stepUntil), never more.
239
270
  const RESTORE_STEP_MS = 2000;
271
+ // How long the browser gets to present one frame before a capture gives up.
272
+ const PRESENT_TIMEOUT_MS = 8000;
273
+ // The selected tab of a minimized window presents nothing; a background tab of it
274
+ // does, at once. This long to tell them apart.
275
+ const MINIMIZED_GRACE_MS = 1500;
276
+ // The budget of a capture whose caller names no deadline.
277
+ const DEFAULT_BUDGET_MS = 60_000;
278
+ // The part of the budget kept back for putting the tab's viewport back: a capture
279
+ // still working that long before the deadline is given up on (half of a budget
280
+ // under twice this).
281
+ const RECOVERY_SHARE_MS = 4000;
282
+ // Putting the viewport back never takes longer than this, however far the deadline is.
283
+ const RECOVERY_CAP_MS = 10_000;
284
+ // How long the answer waits to hear that a retired tab closed (never past the deadline).
285
+ const CLOSE_CONFIRM_MS = 3000;
286
+ // How long a tab stays kept from the next capture while an unanswered capture of it may
287
+ // still be pending (its CDP session was not confirmed detached); after that it is retired.
288
+ const PENDING_HOLD_MS = 60_000;
289
+
290
+ /** What is left of a wait that ends at `until`, at most `cap`; 1 ms once it is over, so the step fails at once. */
291
+ const stepUntil = (until, cap = RESTORE_STEP_MS) => Math.max(1, Math.min(cap, until - Date.now()));
292
+ const seconds = (ms) => `${Math.max(0.1, Math.round(ms / 100) / 10)} s`;
293
+ /** A CDP session not confirmed detached may still be attached in the browser: recorded, never dropped silently. */
294
+ const detachFailed = (what, error) => {
295
+ try { console.warn(`[browser] screenshot: ${what} was not confirmed detached and may still be attached: ${error?.message ?? error}`); } catch { /* nothing left to tell */ }
296
+ };
297
+ /**
298
+ * Detach `session`. `confirmed` settles true or false within the wait (never
299
+ * past `until`); `eventually` settles true when the browser does answer, however
300
+ * late, and false when it refuses. A detach refused or not answered in time is
301
+ * recorded.
302
+ */
303
+ function letGo(session, until, what) {
304
+ const eventually = Promise.resolve().then(() => session.detach()).then(() => true, (error) => { detachFailed(what, error); return false; });
305
+ const confirmed = within(eventually, stepUntil(until), 'detaching').catch((error) => { detachFailed(what, error); return false; });
306
+ return { confirmed, eventually };
307
+ }
308
+ const detachWithin = (session, until, what) => letGo(session, until, what).confirmed;
309
+ /** A CDP session nobody will use (it arrived after its wait was given up): detached, and a failure recorded. */
310
+ const discard = (session, what) => Promise.resolve().then(() => session?.detach?.()).catch((error) => detachFailed(what, error));
311
+ const openSession = (newSession, until) => within(Promise.resolve().then(newSession), stepUntil(until), 'opening a CDP session', (session) => discard(session, 'a CDP session that arrived after its wait was given up'));
312
+
313
+ // What a capture is doing, for the error of one that is cut short.
314
+ const PHASES = {
315
+ queue: 'waiting for an earlier screenshot of this tab to finish',
316
+ session: 'opening a CDP session',
317
+ viewport: 'reading the tab\'s viewport',
318
+ present: 'waiting for the answer to its first capture (the one without a clip)',
319
+ layout: 'reading the page layout',
320
+ emulate: 'setting the viewport size',
321
+ calibrate: 'measuring the capture scale',
322
+ file: 'capturing the image for the file',
323
+ capture: 'capturing the image',
324
+ };
325
+ /**
326
+ * The window state CDP reported for a failure: the one read when the capture
327
+ * stopped, and the one read when it began whenever that is another (a state that
328
+ * could not be read is `unknown`, never the other reading).
329
+ */
330
+ const windowWords = (atStop, atStart) => (atStop === atStart ? atStop || 'unknown' : `${atStop || 'unknown'} when it stopped, ${atStart || 'unknown'} when it began`);
331
+ /** What was measured when a capture stopped: its phase, how long it was in it and the window state CDP reported. */
332
+ const measured = (phase, waitedMs, windowState, windowAtStart = windowState) => `phase ${phase}, ${seconds(waitedMs)} in it; window state from CDP: ${windowWords(windowState || null, windowAtStart || null)}`;
333
+
334
+ /**
335
+ * The first capture, sent without a clip, got no answer in the time it was
336
+ * given; nothing that resizes the tab was sent. The message says what was
337
+ * measured (the command without an answer, the phase, the wait, the window state
338
+ * CDP reported when the capture began and when it stopped) and, for a minimized
339
+ * window, the one behaviour that was measured in Chromium 147; it names no other
340
+ * cause.
341
+ */
342
+ export class ScreenshotStalledError extends Error {
343
+ constructor(windowState, waitedMs, windowAtStart = windowState) {
344
+ super(`No screenshot: the screenshot command (Page.captureScreenshot, sent without a clip) got no answer in the time it was given (${measured('present', waitedMs, windowState, windowAtStart)}). The tab was not resized. ${windowState === 'minimized' || windowAtStart === 'minimized'
345
+ ? 'Measured in Chromium 147: a capture of the selected tab of a minimized window gets no answer until the window is restored, and captures of its other tabs do. Restore the window (it may stay behind other windows) and take the screenshot again.'
346
+ : 'CDP does not report a hidden application, and in Chromium 147 a capture of the selected tab of a hidden application\'s window gets no answer either; no other cause was measured. Check that the browser window is on screen and take the screenshot again.'}`);
347
+ this.status = 503;
348
+ this.code = 'SCREENSHOT_NOT_PRESENTING';
349
+ this.phase = 'present';
350
+ this.windowState = windowState || null;
351
+ this.windowAtStart = windowAtStart || null;
352
+ }
353
+ }
354
+
355
+ /** An earlier screenshot of this tab was cut short and the tab is still being put back at its own size: nothing was sent. */
356
+ export class ScreenshotTabRecoveringError extends Error {
357
+ constructor(recovering) {
358
+ super(`No screenshot: this tab is being put back at its own size after a screenshot that stopped while ${PHASES[recovering.phase] || recovering.phase} ${seconds(Date.now() - recovering.since)} ago (phase recover). Nothing was sent to the tab; take the screenshot again in a few seconds.`);
359
+ this.status = 503;
360
+ this.code = 'SCREENSHOT_TAB_RECOVERING';
361
+ this.phase = 'recover';
362
+ }
363
+ }
364
+
365
+ /**
366
+ * An earlier screenshot of this tab got no answer and its CDP session could not
367
+ * be confirmed detached, so that capture may still be pending in the browser:
368
+ * nothing was sent, and no second capture is started next to it.
369
+ */
370
+ export class ScreenshotPendingError extends Error {
371
+ constructor(pending) {
372
+ super(`No screenshot: an earlier screenshot of this tab got no answer while ${PHASES[pending.phase] || pending.phase} ${seconds(Date.now() - pending.since)} ago, and its CDP session could not be confirmed detached, so it may still be pending in the browser (phase pending). Nothing was sent to the tab; take the screenshot again in a few seconds. A tab still in this state ${seconds(pending.holdMs)} after that screenshot is retired.`);
373
+ this.status = 503;
374
+ this.code = 'SCREENSHOT_CAPTURE_PENDING';
375
+ this.phase = 'pending';
376
+ }
377
+ }
378
+
379
+ /** The tab was retired after an earlier screenshot (its size could not be confirmed, or a capture of it may be pending for good): nothing was sent. */
380
+ export class ScreenshotTabRetiredError extends Error {
381
+ constructor(retired) {
382
+ super(retired.reason === 'pending'
383
+ ? `No screenshot: this tab was retired ${seconds(Date.now() - retired.at)} ago: a screenshot that got no answer while ${PHASES[retired.phase] || retired.phase} may still be pending in the browser, and its CDP session was never confirmed detached (phase retired). Nothing was sent to the tab; the next browser call opens a fresh one.`
384
+ : `No screenshot: this tab was retired ${seconds(Date.now() - retired.at)} ago, after a screenshot that stopped while ${PHASES[retired.phase] || retired.phase} left it at a size SynaBun could not confirm (phase retired). Nothing was sent to the tab; the next browser call opens a fresh one.`);
385
+ this.status = 410;
386
+ this.code = 'SCREENSHOT_TAB_RETIRED';
387
+ this.phase = 'retired';
388
+ }
389
+ }
390
+
391
+ // One capture per tab at a time: a second one would read, resize and restore the
392
+ // view under the first. A gate per page holds the queue (`tail` settles when every
393
+ // turn taken so far has been released), what a cut-short capture is still putting
394
+ // back (`recovering`), a capture that may still be pending in the browser
395
+ // (`pending`) and whether the tab was retired. It lives as long as the
396
+ // page object, so a waiter that gives up frees its own place and nothing else:
397
+ // whoever comes after it still waits for the capture that is active.
398
+ const gates = new WeakMap();
399
+ function gateOf(page) {
400
+ let gate = gates.get(page);
401
+ if (!gate) {
402
+ gate = { tail: Promise.resolve(), holder: null, recovering: null, pending: null, retired: null, watchers: new Set() };
403
+ gates.set(page, gate);
404
+ }
405
+ return gate;
406
+ }
407
+ function takeTurn(gate) {
408
+ const previous = gate.tail;
409
+ let release;
410
+ const mine = new Promise((done) => { release = done; });
411
+ gate.tail = previous.then(() => mine);
412
+ return { ready: previous, release };
413
+ }
414
+ /** Those waiting for the tab's turn look at its gate again now (retired, or a capture that may still be pending). */
415
+ function wakeWaiters(gate) {
416
+ for (const wake of gate.watchers) wake();
417
+ gate.watchers.clear();
418
+ }
419
+ /** From now on no capture touches the tab: those waiting for it are told at once, later ones refused. */
420
+ function retireGate(gate, phase, reason = 'size') {
421
+ if (gate.retired) return;
422
+ gate.retired = { phase, reason, at: Date.now() };
423
+ wakeWaiters(gate);
424
+ }
240
425
 
241
426
  /** The tab's own viewport: innerWidth, innerHeight and devicePixelRatio as the page sees them. */
242
- function readViewport(page) {
243
- return within(page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio })), RESTORE_STEP_MS, 'reading the viewport');
427
+ function readViewport(page, ms = RESTORE_STEP_MS) {
428
+ return within(page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio })), ms, 'reading the viewport');
244
429
  }
430
+ const sameView = (now, before) => now.width === before.width && now.height === before.height && now.dpr === before.dpr;
245
431
 
246
432
  /**
247
- * Clear the device-metrics override and confirm the tab is back: innerWidth
248
- * and devicePixelRatio equal to what they were before the capture (read twice,
249
- * a frame apart, for a resize still on its way). A clear that fails, hangs or
250
- * does not take is sent once more from a fresh CDP session. Chromium ignores a
251
- * clear from a session that never set an override, so that session first sets
252
- * an empty one (0 width, height and scale: nothing overridden). True when confirmed.
433
+ * True when the tab reads the width, height and devicePixelRatio it had before
434
+ * the capture (`before`). One reading that matches is enough after a capture
435
+ * that answered; one that does not is taken once more SETTLE_MS later, for a
436
+ * change still on its way. With `twice` (after a capture that failed with a
437
+ * clip sent) two readings SETTLE_MS apart must both match, and a reading that
438
+ * differs after one that matched is a tab that did not stay as it was. An
439
+ * unreadable tab is not confirmed.
253
440
  */
254
- export async function restoreViewport(page, cdp, before, { newSession = () => page.context().newCDPSession(page) } = {}) {
255
- const clear = (session) => within(session.send('Emulation.clearDeviceMetricsOverride'), RESTORE_STEP_MS, 'clearing the viewport');
256
- const back = async () => {
257
- for (let attempt = 0; attempt < 2; attempt += 1) {
258
- if (attempt) await delay(SETTLE_MS);
259
- try {
260
- const now = await readViewport(page);
261
- if (now.width === before.width && now.dpr === before.dpr) return true;
262
- } catch { /* unreadable: not confirmed */ }
441
+ async function viewportIs(page, before, until = Infinity, twice = false) {
442
+ let matched = 0;
443
+ for (let attempt = 0; attempt < (twice ? 3 : 2); attempt += 1) {
444
+ if (attempt) {
445
+ if (Date.now() + SETTLE_MS >= until) return false;
446
+ await delay(SETTLE_MS);
447
+ }
448
+ let same = false;
449
+ try {
450
+ same = sameView(await readViewport(page, stepUntil(until)), before);
451
+ } catch { /* unreadable: not confirmed */ }
452
+ if (same) {
453
+ matched += 1;
454
+ if (!twice || matched === 2) return true;
455
+ } else if (matched) {
456
+ return false;
457
+ }
458
+ }
459
+ return false;
460
+ }
461
+
462
+ // Chromium resizes a tab's view to the clip of a capture and puts the size back
463
+ // only when that capture answers. Emulation.setVisibleSize sets it directly, in
464
+ // DIP: CSS px times the page zoom. So the first size asked for is the CSS size;
465
+ // the second is corrected once in proportion to what the page then reads (the
466
+ // zoom), and after that only a pixel at a time, until width and height are
467
+ // exactly the tab's own. A view that does not follow that way is left alone:
468
+ // no size further from the tab's own is ever asked for twice.
469
+ async function resizeViewTo(page, session, before, until = Infinity) {
470
+ const sized = (now) => now.width === before.width && now.height === before.height;
471
+ const near = (got, want) => Math.abs(got - want) <= 2;
472
+ const left = await readViewport(page, stepUntil(until));
473
+ if (sized(left)) return true;
474
+ let last = left;
475
+ let width = before.width;
476
+ let height = before.height;
477
+ let scaled = false;
478
+ for (let attempt = 0; attempt < 4 && Date.now() < until; attempt += 1) {
479
+ await within(session.send('Emulation.setVisibleSize', { width, height }), stepUntil(until), 'resizing the view');
480
+ let now = last;
481
+ for (let read = 0; read < 4 && Date.now() < until; read += 1) {
482
+ await delay(read ? SETTLE_MS : 20);
483
+ now = await readViewport(page, stepUntil(until));
484
+ if (sized(now)) return true;
485
+ if (now.width !== last.width || now.height !== last.height) break;
263
486
  }
487
+ // Not applied at all, or a reading no page zoom explains (25% to 500%): leave it.
488
+ if (!attempt && now.width === left.width && now.height === left.height) return false;
489
+ if (!(now.width > 0 && now.height > 0)) return false;
490
+ const zoom = Math.max(width / now.width, height / now.height, now.width / width, now.height / height);
491
+ if (zoom > 5.5) return false;
492
+ if (!near(now.width, before.width) || !near(now.height, before.height)) {
493
+ if (scaled) return false;
494
+ scaled = true;
495
+ }
496
+ const next = (asked, got, want) => (got === want ? asked : near(got, want) ? asked + Math.sign(want - got) : Math.round(asked * want / got));
497
+ width = next(width, now.width, before.width);
498
+ height = next(height, now.height, before.height);
499
+ last = now;
500
+ }
501
+ return false;
502
+ }
503
+
504
+ /**
505
+ * After a capture with a clip that did not answer, once its CDP session is
506
+ * detached (a pending capture holds the view at its size, and the page reads
507
+ * that size only when the session is gone): put the tab's view back at `before`
508
+ * if Chromium left it at the clip's size. True only when the tab then reads its
509
+ * own width, height and devicePixelRatio on two readings SETTLE_MS apart (the
510
+ * size showed about 2 ms after the detach answered: at most 1.8 ms in headless
511
+ * Chromium, 20 of 20 detaches per run of browser-screenshot-stall.browser.mjs,
512
+ * and 2.2 ms seen in a real minimized window); every wait ends at `until`, and
513
+ * a budget too short for the second reading confirms nothing.
514
+ */
515
+ export async function restoreViewSize(page, before, { newSession = () => page.context().newCDPSession(page), until = Infinity } = {}) {
516
+ let now;
517
+ try {
518
+ now = await readViewport(page, stepUntil(until));
519
+ if (sameView(now, before)) {
520
+ if (Date.now() + SETTLE_MS >= until) return false;
521
+ await delay(SETTLE_MS);
522
+ now = await readViewport(page, stepUntil(until));
523
+ if (sameView(now, before)) return true;
524
+ }
525
+ } catch {
264
526
  return false;
265
- };
527
+ }
528
+ // Nothing here changes a devicePixelRatio: one that is not the tab's own stays unconfirmed.
529
+ if (now.dpr !== before.dpr) return false;
530
+ let fresh = null;
531
+ try {
532
+ fresh = await openSession(newSession, until);
533
+ await resizeViewTo(page, fresh, before, until);
534
+ } catch { /* checked below */ } finally {
535
+ if (fresh) await detachWithin(fresh, until, 'the CDP session that put the view back');
536
+ }
537
+ // A size put back counts once it has stayed: two readings apart, not the one right after the resize.
538
+ return viewportIs(page, before, until, true);
539
+ }
540
+
541
+ /**
542
+ * Clear the device-metrics override and confirm the tab is back: innerWidth,
543
+ * innerHeight and devicePixelRatio equal to what they were before the capture
544
+ * (a reading that differs is taken once more SETTLE_MS later). A clear that
545
+ * fails, hangs or does not take is sent once more from a fresh CDP session.
546
+ * Chromium ignores a clear from a session that never set an override, so that
547
+ * session first sets an empty one (0 width, height and scale: nothing
548
+ * overridden). A view still at another size then is one a scaled clip resized
549
+ * (the capture never answered): `release` detaches the capture's own session,
550
+ * whose pending capture holds the view, and the fresh session sets the size
551
+ * back. True when confirmed; every wait ends at `until`. After a capture that
552
+ * failed with a clip sent, captureScreenshot does not stop at this: it confirms
553
+ * the tab again once the capture's session is detached (restoreViewSize).
554
+ */
555
+ export async function restoreViewport(page, cdp, before, { newSession = () => page.context().newCDPSession(page), release = async () => {}, until = Infinity } = {}) {
556
+ const clear = (session) => within(session.send('Emulation.clearDeviceMetricsOverride'), stepUntil(until), 'clearing the viewport');
557
+ const back = () => viewportIs(page, before, until);
266
558
  try { await clear(cdp); } catch { /* retried below */ }
267
559
  if (await back()) return true;
268
560
  let fresh = null;
269
561
  try {
270
- fresh = await within(newSession(), RESTORE_STEP_MS, 'opening a CDP session');
271
- await within(fresh.send('Emulation.setDeviceMetricsOverride', { width: 0, height: 0, deviceScaleFactor: 0, mobile: false }), RESTORE_STEP_MS, 'resetting the viewport');
562
+ fresh = await openSession(newSession, until);
563
+ await within(fresh.send('Emulation.setDeviceMetricsOverride', { width: 0, height: 0, deviceScaleFactor: 0, mobile: false }), stepUntil(until), 'resetting the viewport');
272
564
  await clear(fresh);
565
+ if (!await back()) {
566
+ await release();
567
+ await resizeViewTo(page, fresh, before, until);
568
+ }
273
569
  } catch { /* checked below */ } finally {
274
- if (fresh) await within(fresh.detach(), RESTORE_STEP_MS, 'detaching').catch(() => {});
570
+ if (fresh) await detachWithin(fresh, until, 'the CDP session that cleared the viewport');
275
571
  }
276
572
  return back();
277
573
  }
@@ -284,6 +580,37 @@ async function settleLayout(page) {
284
580
  await delay(SETTLE_MS);
285
581
  }
286
582
 
583
+ /** The window state CDP reports for the tab's window (normal, minimized, maximized, fullscreen), or null. */
584
+ const readWindowState = (cdp, ms) => within(Promise.resolve().then(() => cdp.send('Browser.getWindowForTarget')), ms, 'reading the window state')
585
+ .then((target) => target?.bounds?.windowState || null, () => null); // a browser without windows: unknown
586
+
587
+ // One capture without a clip, or word that it got no answer in time
588
+ // (ScreenshotStalledError). Unlike every later capture it resizes nothing: when
589
+ // it does not answer, the tab is exactly as it was. In a minimized window the
590
+ // answer comes at once or not at all, so it is not waited on for long.
591
+ // `windowRead` settles with the window state CDP reported when the capture began.
592
+ function presentedFrame(cdp, timeoutMs) {
593
+ const started = Date.now();
594
+ const windowRead = readWindowState(cdp, 1000);
595
+ const frame = new Promise((done, fail) => {
596
+ let timer = null;
597
+ let over = false;
598
+ const finish = (settle, value) => {
599
+ if (over) return;
600
+ over = true;
601
+ clearTimeout(timer);
602
+ settle(value);
603
+ };
604
+ cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 1, optimizeForSpeed: true }).then(() => finish(done), (error) => finish(fail, error));
605
+ windowRead.then((windowState) => {
606
+ if (over) return;
607
+ const allowed = windowState === 'minimized' ? Math.min(MINIMIZED_GRACE_MS, timeoutMs) : timeoutMs;
608
+ timer = setTimeout(() => finish(fail, new ScreenshotStalledError(windowState, Date.now() - started)), Math.max(0, allowed - (Date.now() - started)));
609
+ });
610
+ });
611
+ return { frame, windowRead };
612
+ }
613
+
287
614
  // Device pixels per CSS pixel in this session's captures, measured: the width of
288
615
  // a small corner shot. The page's devicePixelRatio can be a fingerprint value, and
289
616
  // another client's DPR emulation does not apply to this session's captures.
@@ -297,46 +624,172 @@ async function captureScaleOf(cdp, visual) {
297
624
  }
298
625
 
299
626
  /**
300
- * Capture `page`. `options` from parseScreenshotOptions. `runBounded(work)`
301
- * bounds the capture (server.js passes the request deadline); the override is
302
- * cleared, the tab's viewport confirmed (restoreViewport) and the CDP session
303
- * detached when it settles, even if the capture itself never answers. Returns
304
- * the base64 image to show (downscaled to `maxWidth` unless 0, and under 8000
305
- * px), the full-resolution file bytes when saving, the sizes and, after an
306
- * emulated size, `viewportRestored`. A capture that fails after an override the
307
- * restore could not confirm throws with `viewportRestored: false` too.
627
+ * Capture `page`. `options` from parseScreenshotOptions.
628
+ *
629
+ * One budget, ending at `deadline` (epoch ms; 60 s from now when not given),
630
+ * covers the capture and putting the tab back: the call has settled by then,
631
+ * whatever the browser does. A capture still working RECOVERY_SHARE_MS before
632
+ * it is given up on, with the phase it was in (`phase`, and in the message,
633
+ * with the window state CDP reported); what is left goes to the viewport.
634
+ * `runBounded(work)` may end the capture earlier (server.js: the request was
635
+ * aborted); `presentTimeoutMs` is how long the browser gets to present a frame.
636
+ *
637
+ * Captures of one tab run one at a time, and the turn is held until everything
638
+ * a capture started is over. A first capture (no clip) that gets no answer
639
+ * throws ScreenshotStalledError and nothing that resizes the tab was sent. After
640
+ * any other failure that sent an override or a clip, the tab is put back
641
+ * (restoreViewport, restoreViewSize) and confirmed at the width, height and
642
+ * devicePixelRatio it had, after a clip on two readings apart taken once the
643
+ * capture's CDP session is detached; while that goes on, another call for the
644
+ * tab is refused at once (ScreenshotTabRecoveringError). A tab not confirmed
645
+ * when the capture answers, the deadline included, is retired: `retire(reason)`
646
+ * is called at once (server.js takes it out of routing and closes it;
647
+ * `{ closed }` settles true when the close was confirmed), every later call for
648
+ * the page is refused (ScreenshotTabRetiredError), and the answer carries
649
+ * `viewportRestored: false`, `tab: 'retired'` and `tabClosed`. A failure's `tab`
650
+ * is otherwise 'untouched' or 'restored'.
651
+ *
652
+ * When the capture's CDP session cannot be confirmed detached, what it left
653
+ * unanswered decides. The override or a capture with a clip: the browser may
654
+ * still apply it, so the tab is not confirmed and is retired as above. A capture
655
+ * without a clip: nothing can resize the tab, so it is kept, but its turn is not
656
+ * released (`capturePending` on the error; other calls are refused at once with
657
+ * ScreenshotPendingError) until that capture is answered or the detach is; a tab
658
+ * still held after `pendingHoldMs` is retired (`retire('pending')`). Nothing
659
+ * unanswered: the tab is given back and the session recorded as possibly left
660
+ * attached.
661
+ *
662
+ * `onRecord` gets one record per call: { outcome: 'ok' | 'failed' | 'refused',
663
+ * phase, code, windowState, windowAtStart, tab, tabClosed, ms } and
664
+ * `capturePending: true` when the tab is held. `windowState` is what CDP
665
+ * reported when the capture began; for a failure, what it reported when the
666
+ * capture stopped (null when that could not be read, never the earlier reading).
667
+ *
668
+ * Returns the base64 image to show (downscaled to `maxWidth` unless 0, and
669
+ * under 8000 px), the full-resolution file bytes when saving, the sizes and,
670
+ * after an emulated size, `viewportRestored`.
308
671
  */
309
- export async function captureScreenshot(page, options, { runBounded = (work) => work(), restore = restoreViewport } = {}) {
310
- const cdp = await page.context().newCDPSession(page);
311
- let overrideSent = false;
312
- let settled = false;
313
- let viewportBefore = null;
314
- let result = null;
315
- let failure = null;
316
- try {
317
- result = await runBounded(async () => {
672
+ export async function captureScreenshot(page, options, {
673
+ runBounded = (work) => work(),
674
+ deadline = null,
675
+ restore = restoreViewport,
676
+ restoreSize = restoreViewSize,
677
+ presentTimeoutMs = PRESENT_TIMEOUT_MS,
678
+ pendingHoldMs = PENDING_HOLD_MS,
679
+ retire = null,
680
+ onRecord = null,
681
+ } = {}) {
682
+ const started = Date.now();
683
+ const until = Number.isFinite(deadline) ? deadline : started + DEFAULT_BUDGET_MS;
684
+ const gate = gateOf(page);
685
+ const run = {
686
+ phase: 'queue', since: started, cdp: null, before: null,
687
+ overrideSent: false,
688
+ // A capture with a clip went out: Chromium resizes the view to the clip until it answers.
689
+ clipSent: false,
690
+ // Commands the browser has not answered yet: captures, and those that resize the tab (the override, a capture with a clip).
691
+ captures: 0, resizing: 0, quiet: null,
692
+ // Its unanswered capture may still be pending in the browser: the tab's turn is not released.
693
+ pending: false,
694
+ settled: false, finished: false,
695
+ // The window state CDP reported when the capture began, and when it stopped (read for failures only).
696
+ windowAtStart: null, windowAtStop: null, windowRead: null,
697
+ };
698
+ const record = (outcome, error = null, tab = 'untouched', tabClosed = null) => {
699
+ try {
700
+ onRecord?.({ outcome, phase: error?.phase ?? null, code: error?.code ?? null, windowState: outcome === 'failed' ? run.windowAtStop : run.windowAtStart, windowAtStart: run.windowAtStart, tab, tabClosed, ...(run.pending && { capturePending: true }), ms: Date.now() - started });
701
+ } catch { /* a record that cannot be written never fails a capture */ }
702
+ };
703
+ if (gate.retired || gate.pending || gate.recovering) {
704
+ const refusal = gate.retired ? new ScreenshotTabRetiredError(gate.retired) : gate.pending ? new ScreenshotPendingError(gate.pending) : new ScreenshotTabRecoveringError(gate.recovering);
705
+ record('refused', refusal);
706
+ throw refusal;
707
+ }
708
+
709
+ const turn = takeTurn(gate);
710
+ const enter = (phase) => { run.phase = phase; run.since = Date.now(); };
711
+ const cancelled = () => { if (run.settled) throw new Error('Screenshot cancelled'); };
712
+ const work = async () => {
713
+ try {
714
+ // The turn, or word that the tab was retired, or is held for a capture that may still be pending, while this call waited for it.
715
+ let wake;
716
+ const told = new Promise((done) => { wake = done; });
717
+ gate.watchers.add(wake);
718
+ try { await Promise.race([turn.ready, told]); } finally { gate.watchers.delete(wake); }
719
+ if (gate.retired) throw new ScreenshotTabRetiredError(gate.retired);
720
+ if (gate.pending) throw new ScreenshotPendingError(gate.pending);
721
+ cancelled();
722
+ gate.holder = run;
723
+ enter('session');
724
+ const session = await page.context().newCDPSession(page);
725
+ if (run.settled) {
726
+ // Opened after the capture was given up on: nothing was sent through it.
727
+ discard(session, 'a CDP session that arrived after its capture was given up on');
728
+ cancelled();
729
+ }
730
+ run.cdp = session;
731
+ // The capture's own commands: a capture, or a command that resizes the tab (the
732
+ // override, a capture with a clip), is counted until the browser answers it.
733
+ const cdp = {
734
+ send: (...command) => {
735
+ const [method, params] = command;
736
+ const answer = session.send(...command);
737
+ const capture = method === 'Page.captureScreenshot';
738
+ const resizes = method === 'Emulation.setDeviceMetricsOverride' || (capture && !!params?.clip);
739
+ if (capture || resizes) {
740
+ if (capture) run.captures += 1;
741
+ if (resizes) run.resizing += 1;
742
+ const over = () => {
743
+ if (capture) run.captures -= 1;
744
+ if (resizes) run.resizing -= 1;
745
+ if (!run.captures) run.quiet?.();
746
+ };
747
+ Promise.resolve(answer).then(over, over);
748
+ }
749
+ return answer;
750
+ },
751
+ };
752
+ enter('viewport');
753
+ // What the tab is put back to; unreadable, nothing is captured.
754
+ run.before = await readViewport(page, 5000);
755
+ cancelled();
756
+ enter('present');
757
+ const presented = presentedFrame(cdp, presentTimeoutMs);
758
+ run.windowRead = presented.windowRead.then((state) => { run.windowAtStart = state; });
759
+ await presented.frame;
318
760
  let emulated = null;
319
761
  if (options.width || options.height) {
762
+ cancelled();
763
+ enter('layout');
320
764
  const before = await cdp.send('Page.getLayoutMetrics');
321
765
  const layout = before.cssLayoutViewport || before.layoutViewport;
322
766
  emulated = {
323
767
  width: options.width || Math.round(layout.clientWidth),
324
768
  height: options.height || Math.round(layout.clientHeight),
325
769
  };
326
- // What the restore is confirmed against; unreadable, nothing is emulated.
327
- viewportBefore = await readViewport(page);
328
770
  // Checked and sent in one tick: once cleanup has begun, no override goes out.
329
- if (settled) throw new Error('Screenshot cancelled');
330
- overrideSent = true;
771
+ cancelled();
772
+ enter('emulate');
773
+ run.overrideSent = true;
331
774
  await cdp.send('Emulation.setDeviceMetricsOverride', {
332
775
  width: emulated.width, height: emulated.height, deviceScaleFactor: 1, mobile: emulated.width < 768,
333
776
  });
777
+ cancelled();
334
778
  await settleLayout(page);
335
779
  }
780
+ // Given up on: nothing more is sent, a read included.
781
+ cancelled();
782
+ enter('layout');
336
783
  const metrics = await cdp.send('Page.getLayoutMetrics');
337
784
  const visual = metrics.cssVisualViewport || metrics.visualViewport;
338
785
  const content = metrics.cssContentSize || metrics.contentSize;
339
- const dpr = emulated ? 1 : await captureScaleOf(cdp, visual);
786
+ let dpr = 1;
787
+ if (!emulated) {
788
+ cancelled();
789
+ enter('calibrate');
790
+ run.clipSent = true;
791
+ dpr = await captureScaleOf(cdp, visual);
792
+ }
340
793
  let region;
341
794
  let scale;
342
795
  let truncated = false;
@@ -359,6 +812,8 @@ export async function captureScreenshot(page, options, { runBounded = (work) =>
359
812
  const outHeight = height * dpr;
360
813
  const shrink = Math.min(1, options.maxWidth > 0 ? options.maxWidth / outWidth : 1, MAX_INLINE_EDGE / outWidth, MAX_INLINE_EDGE / outHeight);
361
814
  const shoot = async (format, quality, factor) => {
815
+ cancelled();
816
+ run.clipSent = true;
362
817
  const { data } = await cdp.send('Page.captureScreenshot', {
363
818
  format,
364
819
  ...(format === 'jpeg' && { quality }),
@@ -367,8 +822,10 @@ export async function captureScreenshot(page, options, { runBounded = (work) =>
367
822
  });
368
823
  return data;
369
824
  };
825
+ enter('file');
370
826
  const fileData = options.save ? await shoot(options.fileFormat, options.fileQuality, 1) : null;
371
827
  const reuse = fileData && shrink === 1 && options.inlineFormat === options.fileFormat;
828
+ enter('capture');
372
829
  const data = reuse ? fileData : await shoot(options.inlineFormat, options.quality, shrink);
373
830
  return {
374
831
  data,
@@ -382,18 +839,178 @@ export async function captureScreenshot(page, options, { runBounded = (work) =>
382
839
  emulated,
383
840
  fileData,
384
841
  };
385
- });
386
- } catch (error) {
387
- failure = error;
842
+ } finally {
843
+ run.finished = true;
844
+ }
845
+ };
846
+
847
+ let result = null;
848
+ let failure = null;
849
+ // Where the capture stood when it was over or given up on. From then on the
850
+ // work sends nothing more: each command that resizes is checked and sent in one tick.
851
+ let stopped = null;
852
+ const stop = () => {
853
+ if (stopped) return;
854
+ run.settled = true;
855
+ stopped = { phase: run.phase, waited: Date.now() - run.since, cut: !run.finished };
856
+ };
857
+ // true / false once the tab's viewport had to be confirmed, null when nothing that resizes went out.
858
+ let restored = null;
859
+ const resized = () => run.overrideSent || (!!failure && run.clipSent);
860
+
861
+ const capture = async () => {
862
+ const share = Math.min(RECOVERY_SHARE_MS, Math.max(0, until - started) / 2);
863
+ let timer;
864
+ try {
865
+ result = await Promise.race([
866
+ Promise.resolve().then(() => runBounded(work)),
867
+ new Promise((_, fail) => { timer = setTimeout(() => fail(Object.assign(new Error('no answer within the time the request allows'), { code: 'BROWSER_OPERATION_TIMEOUT', ownBudget: true })), Math.max(0, until - share - Date.now())); }),
868
+ ]);
869
+ } catch (error) {
870
+ failure = error;
871
+ } finally {
872
+ clearTimeout(timer);
873
+ }
874
+ stop();
875
+ };
876
+ // Settles when a tab held for a capture that may still be pending is given back or retired.
877
+ let held = null;
878
+ const putBack = async () => {
879
+ const session = run.cdp;
880
+ if (!session) return; // nothing was sent
881
+ const stopAt = Math.min(until, Date.now() + RECOVERY_CAP_MS);
882
+ // A capture cut short that may have resized the tab: until it is put back, other calls for it are refused at once.
883
+ if (failure && resized()) gate.recovering = { run, phase: stopped.phase, since: Date.now() };
884
+ // For the record of a failure, whatever its phase: what CDP reports of the window
885
+ // now, next to what it reported when the capture began. Asked for before the
886
+ // session is let go and kept as soon as it answers; waited for only when the
887
+ // tab is back, so it takes nothing from the time the viewport gets.
888
+ const windowNow = failure ? readWindowState(session, stepUntil(stopAt, 1000)).then((state) => { run.windowAtStop = state; }) : null;
889
+ let letting = null;
890
+ // True once the capture's CDP session is confirmed detached.
891
+ const drop = () => { letting ||= letGo(session, stopAt, 'the capture\'s CDP session'); return letting.confirmed; };
892
+ let detached = false;
893
+ try {
894
+ // Also when the capture timed out and is still pending: the clear goes out on
895
+ // its own (a detach alone would leave the override on the page).
896
+ if (run.overrideSent) restored = await restore(page, session, run.before, { release: drop, until: stopAt }) === true;
897
+ detached = await drop();
898
+ // A clip that did not answer: the view is at the clip's size, and the page reads
899
+ // that size only once the capture's session is gone. So the tab is confirmed
900
+ // from here, on two readings apart, whatever was read while it was attached.
901
+ if (failure && run.clipSent && restored !== false) restored = await restoreSize(page, run.before, { until: stopAt }) === true;
902
+ } catch {
903
+ if (resized()) restored = false;
904
+ detached = await drop();
905
+ }
906
+ if (!detached && run.resizing) {
907
+ // The override or a clip still unanswered on a session that may still be
908
+ // attached: the browser can apply it at any time. Nothing read now confirms the tab.
909
+ restored = false;
910
+ } else if (!detached && run.captures) {
911
+ // A capture without a clip still unanswered on a session that may still be
912
+ // attached: the browser may still be working on it, and nothing in it resizes
913
+ // the tab. The tab is kept, and so is its turn: those waiting are told, later
914
+ // callers refused, until the capture is answered or the detach is. A tab still
915
+ // held after `pendingHoldMs` is retired: no screenshot of it would ever start again.
916
+ run.pending = true;
917
+ gate.pending = { run, phase: stopped.phase, since: Date.now(), holdMs: pendingHoldMs };
918
+ wakeWaiters(gate);
919
+ let holdTimer;
920
+ held = Promise.race([
921
+ new Promise((done) => { run.quiet = () => done('answered'); }),
922
+ letting.eventually.then((gone) => (gone ? 'detached' : new Promise(() => {}))),
923
+ new Promise((done) => { holdTimer = setTimeout(() => done('over'), pendingHoldMs); holdTimer.unref?.(); }),
924
+ ]).then((how) => {
925
+ clearTimeout(holdTimer);
926
+ if (how !== 'over' || gate.retired) return;
927
+ retireGate(gate, stopped.phase, 'pending');
928
+ try { console.warn(`[browser] screenshot: a capture that got no answer while ${PHASES[stopped.phase]} was still unanswered, and its CDP session not confirmed detached, after ${seconds(pendingHoldMs)}: the tab is retired`); } catch { /* nothing left to tell */ }
929
+ try { retire?.('pending'); } catch { /* out of this module's hands */ }
930
+ });
931
+ }
932
+ // The window state the capture began with is an answer of a few ms; a browser that owes it is not waited on for long.
933
+ if (run.windowRead) await Promise.race([run.windowRead, delay(failure ? stepUntil(stopAt, 1000) : 100)]);
934
+ if (windowNow) await windowNow;
935
+ };
936
+ const everything = capture().then(putBack);
937
+ // The tab's turn ends when everything this capture started is over, whoever still waits for its answer.
938
+ everything.catch(() => {}).then(async () => {
939
+ if (gate.recovering?.run === run) gate.recovering = null;
940
+ if (held) {
941
+ await held;
942
+ if (gate.pending?.run === run) gate.pending = null;
943
+ }
944
+ if (gate.holder === run) gate.holder = null;
945
+ turn.release();
946
+ });
947
+
948
+ // The answer: when all of it is over, or at the deadline with whatever is not.
949
+ let timer;
950
+ const inTime = await Promise.race([
951
+ everything.then(() => true, () => true),
952
+ new Promise((done) => { timer = setTimeout(() => done(false), Math.max(0, until - Date.now())); }),
953
+ ]);
954
+ clearTimeout(timer);
955
+ if (!inTime) {
956
+ if (!stopped && !failure) failure = Object.assign(new Error('no answer within the time the request allows'), { code: 'BROWSER_OPERATION_TIMEOUT', ownBudget: true });
957
+ stop();
958
+ // Not confirmed by now is not confirmed: the rest goes on without the caller.
959
+ // (A tab already confirmed stays so; only its session is still being let go.)
960
+ if (resized() && restored === null) restored = false;
961
+ } else if (resized() && restored === null) {
962
+ restored = false; // putting it back ended without a verdict: not confirmed
388
963
  }
389
- // Also when the capture timed out and is still pending: the clear goes out on
390
- // its own (a detach alone would leave the override on the page).
391
- settled = true;
392
- const viewportRestored = overrideSent ? await restore(page, cdp, viewportBefore) : null;
393
- await Promise.race([cdp.detach(), delay(2000)]).catch(() => {});
394
- if (failure) {
395
- if (viewportRestored === false && failure && typeof failure === 'object') failure.viewportRestored = false;
396
- throw failure;
964
+ // What this call answers with, whatever the work or the cleanup still running sets later.
965
+ const failed = failure;
966
+ const shot = result;
967
+ const unverified = restored === false;
968
+ let tabClosed = null;
969
+ if (unverified) {
970
+ retireGate(gate, stopped.phase);
971
+ let retiring = null;
972
+ try { retiring = retire ? retire('size') : null; } catch { /* reported as not closed */ }
973
+ tabClosed = false;
974
+ if (retiring?.closed) {
975
+ let closeTimer;
976
+ tabClosed = await Promise.race([
977
+ Promise.resolve(retiring.closed).then((closed) => closed === true, () => false),
978
+ new Promise((done) => { closeTimer = setTimeout(() => done(false), Math.max(0, Math.min(CLOSE_CONFIRM_MS, until - Date.now()))); }),
979
+ ]);
980
+ clearTimeout(closeTimer);
981
+ }
982
+ }
983
+ const tab = unverified ? 'retired' : restored === true ? 'restored' : 'untouched';
984
+
985
+ if (failed) {
986
+ const { phase, waited } = stopped;
987
+ // What CDP reported of the window when the capture stopped (null when that was not read) and when it began.
988
+ const { windowAtStop, windowAtStart } = run;
989
+ let error = failed;
990
+ if (phase === 'present' && (stopped.cut || failed instanceof ScreenshotStalledError)) {
991
+ // The first capture got no answer, whoever stopped the wait: said with both window readings.
992
+ error = new ScreenshotStalledError(windowAtStop, waited, windowAtStart);
993
+ } else if (stopped.cut) {
994
+ const earlier = phase === 'queue' && gate.holder && gate.holder !== run ? `; the earlier one is ${PHASES[gate.holder.phase]}` : '';
995
+ error = new Error(`Screenshot timed out while ${PHASES[phase]} (${measured(phase, waited, windowAtStop, windowAtStart)}${earlier})${failed.ownBudget ? '' : `: ${failed.message}`}`);
996
+ Object.assign(error, { code: failed.code, status: failed.status, phase });
997
+ } else if (!(failed instanceof ScreenshotTabRetiredError || failed instanceof ScreenshotPendingError)) {
998
+ error = new Error(`Screenshot failed while ${PHASES[phase]} (${measured(phase, waited, windowAtStop, windowAtStart)}): ${failed?.message ?? failed}`);
999
+ Object.assign(error, { code: failed?.code, status: failed?.status, phase });
1000
+ }
1001
+ if (error !== failed) error.cause = failed;
1002
+ if (error.windowState === undefined) error.windowState = windowAtStop;
1003
+ if (error.windowAtStart === undefined) error.windowAtStart = windowAtStart;
1004
+ error.tab = tab;
1005
+ if (run.pending) {
1006
+ error.capturePending = true;
1007
+ error.message += ` Its CDP session could not be confirmed detached, so the capture may still be pending in the browser: until it is answered or the session detaches, other screenshots of this tab are refused (SCREENSHOT_CAPTURE_PENDING), and a tab still in that state after ${seconds(pendingHoldMs)} is retired.`;
1008
+ }
1009
+ if (unverified) Object.assign(error, { viewportRestored: false, tabClosed });
1010
+ record('failed', error, tab, tabClosed);
1011
+ throw error;
397
1012
  }
398
- return viewportRestored === null ? result : { ...result, viewportRestored };
1013
+ record('ok', null, tab, tabClosed);
1014
+ if (restored === null) return shot;
1015
+ return { ...shot, viewportRestored: restored, ...(unverified && { tab, tabClosed }) };
399
1016
  }