@xiaobuyu/nodesign 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/README.md +156 -106
  2. package/bin/nodesign.js +0 -0
  3. package/package.json +1 -1
  4. package/server/api/README.md +45 -45
  5. package/server/api/_guard.js +75 -75
  6. package/server/api/admin.js +187 -187
  7. package/server/api/assets/docx-page.js +103 -103
  8. package/server/api/assets/helpers.js +22 -22
  9. package/server/api/assets/notes.js +144 -144
  10. package/server/api/assets.js +896 -896
  11. package/server/api/board.js +57 -57
  12. package/server/api/browse.js +138 -138
  13. package/server/api/canvas.js +444 -444
  14. package/server/api/chatai.js +324 -324
  15. package/server/api/exports/build-standalone.js +980 -980
  16. package/server/api/exports/cards.js +118 -118
  17. package/server/api/exports/docx-pdf.js +46 -46
  18. package/server/api/exports/export-page.js +142 -142
  19. package/server/api/exports/handoff.js +147 -147
  20. package/server/api/exports.js +690 -690
  21. package/server/api/instruction.js +75 -75
  22. package/server/api/me.js +116 -116
  23. package/server/api/memory.js +185 -185
  24. package/server/api/pending-changes.js +366 -366
  25. package/server/api/plugins.js +164 -164
  26. package/server/api/projects.js +220 -220
  27. package/server/api/publish.js +64 -64
  28. package/server/api/recent.js +73 -73
  29. package/server/api/sessions.js +589 -589
  30. package/server/api/skills.js +77 -77
  31. package/server/api/standalone-fit.js +168 -168
  32. package/server/api/turn-compose.js +180 -180
  33. package/server/api/turn-inflight.js +44 -44
  34. package/server/auth/README.md +17 -17
  35. package/server/auth/middleware.js +138 -138
  36. package/server/auth/origin-guard.js +71 -71
  37. package/server/auth/session.js +108 -108
  38. package/server/auth/tier.js +113 -113
  39. package/server/auth/users-store.js +348 -348
  40. package/server/edit/README.md +29 -29
  41. package/server/engine/README.md +113 -113
  42. package/server/engine/agent/agent-shared.js +550 -550
  43. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
  44. package/server/engine/agent/auto-mode-rules.js +78 -78
  45. package/server/engine/agent/context.js +418 -418
  46. package/server/engine/agent/events.js +464 -464
  47. package/server/engine/agent/hooks/canvas-validate.js +199 -199
  48. package/server/engine/agent/hooks/failure.js +120 -120
  49. package/server/engine/agent/hooks/file-events.js +75 -75
  50. package/server/engine/agent/hooks/lifecycle.js +233 -233
  51. package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
  52. package/server/engine/agent/hooks/post-guidance.js +206 -206
  53. package/server/engine/agent/hooks/post-subagent-report.js +104 -104
  54. package/server/engine/agent/hooks/post-trim.js +58 -58
  55. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
  56. package/server/engine/agent/hooks/pre-defaults.js +84 -84
  57. package/server/engine/agent/hooks/pre-injectors.js +277 -277
  58. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
  59. package/server/engine/agent/hooks/pre-starter-files.js +61 -61
  60. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
  61. package/server/engine/agent/hooks/site-validate.js +94 -94
  62. package/server/engine/agent/hooks/tool-prompts.js +31 -31
  63. package/server/engine/agent/hooks/turn-state-memory.js +56 -56
  64. package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
  65. package/server/engine/agent/hooks.js +354 -354
  66. package/server/engine/agent/init-contract.js +69 -69
  67. package/server/engine/agent/isolation.js +159 -159
  68. package/server/engine/agent/model-table.js +292 -292
  69. package/server/engine/agent/plugin-loader.js +266 -266
  70. package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
  71. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
  72. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
  73. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
  74. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
  75. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
  76. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
  77. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
  78. package/server/engine/agent/prompts/tools/site-reference.md +106 -106
  79. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
  80. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
  81. package/server/engine/agent/session-loop.js +1024 -1024
  82. package/server/engine/agent/session-model.js +164 -164
  83. package/server/engine/agent/skill.js +204 -204
  84. package/server/engine/agent/task-events.js +78 -78
  85. package/server/engine/agents/ds-extractor.md +132 -132
  86. package/server/engine/agents/explorer.md +190 -190
  87. package/server/engine/agents/index.js +230 -230
  88. package/server/engine/agents/schemas/design-system.json +156 -156
  89. package/server/engine/agents/schemas/tweak-schema.json +113 -113
  90. package/server/engine/agents/tweak-proposer.md +127 -127
  91. package/server/engine/agents/vision-checker.md +254 -254
  92. package/server/engine/browse/capture.js +422 -422
  93. package/server/engine/browse/card.js +137 -137
  94. package/server/engine/browse/handover.js +59 -59
  95. package/server/engine/browse/page-digest.js +119 -119
  96. package/server/engine/browse/refs.js +196 -196
  97. package/server/engine/browse/registry.js +307 -307
  98. package/server/engine/browse/screencast.js +170 -170
  99. package/server/engine/browse/state.js +121 -121
  100. package/server/engine/chatai/chat-log.js +79 -79
  101. package/server/engine/chatai/index.js +193 -193
  102. package/server/engine/chatai/openai-compat.js +152 -152
  103. package/server/engine/chatai/orchestrate.js +309 -309
  104. package/server/engine/chatai/perform.js +40 -40
  105. package/server/engine/chatai/summarize.js +79 -79
  106. package/server/engine/mcp/capability-gate.js +52 -52
  107. package/server/engine/mcp/index.js +339 -339
  108. package/server/engine/mcp/param-sanitizer.js +142 -142
  109. package/server/engine/mcp/tools/arrange-on-board.js +124 -124
  110. package/server/engine/mcp/tools/artifact-session.js +278 -278
  111. package/server/engine/mcp/tools/browse-computer.js +421 -421
  112. package/server/engine/mcp/tools/browse-find-batch.js +183 -183
  113. package/server/engine/mcp/tools/browse-screenshot.js +157 -157
  114. package/server/engine/mcp/tools/browse.js +480 -480
  115. package/server/engine/mcp/tools/build-docx.js +101 -101
  116. package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
  117. package/server/engine/mcp/tools/create-on-board.js +155 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/explain-style.js +237 -237
  120. package/server/engine/mcp/tools/export-handoff.js +133 -133
  121. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  122. package/server/engine/mcp/tools/generate-image.js +842 -842
  123. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  124. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  125. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  126. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  127. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  128. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  129. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  130. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  131. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  132. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  133. package/server/engine/mcp/tools/highlight.js +68 -68
  134. package/server/engine/mcp/tools/list-pages.js +222 -222
  135. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  136. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  137. package/server/engine/mcp/tools/organize-board.js +64 -64
  138. package/server/engine/mcp/tools/paint-still.js +434 -434
  139. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  140. package/server/engine/mcp/tools/preview-deck.js +130 -130
  141. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  142. package/server/engine/mcp/tools/publish-site.js +102 -102
  143. package/server/engine/mcp/tools/query-elements.js +195 -195
  144. package/server/engine/mcp/tools/read-board.js +103 -103
  145. package/server/engine/mcp/tools/read-document.js +93 -93
  146. package/server/engine/mcp/tools/read-page.js +264 -264
  147. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  148. package/server/engine/mcp/tools/record-decision.js +140 -140
  149. package/server/engine/mcp/tools/relate-on-board.js +110 -110
  150. package/server/engine/mcp/tools/report-issue.js +115 -115
  151. package/server/engine/mcp/tools/roll-film.js +266 -266
  152. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  153. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  154. package/server/engine/mcp/tools/screenshot.js +583 -583
  155. package/server/engine/mcp/tools/tier-gate.js +96 -96
  156. package/server/engine/mcp/tools/trace-motion.js +215 -215
  157. package/server/engine/mcp/tools/web-search.js +548 -548
  158. package/server/engine/motion/inventory.js +349 -349
  159. package/server/engine/perception/session.js +235 -235
  160. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  161. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
  162. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  163. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  164. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  165. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  169. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
  170. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  171. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  172. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
  173. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
  174. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  175. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
  176. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  177. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  178. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  179. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  180. package/server/engine/runs/active-runs.js +698 -698
  181. package/server/engine/runs/live-turn.js +252 -252
  182. package/server/engine/runs/store.js +364 -364
  183. package/server/engine/runs/turn-relay.js +217 -217
  184. package/server/engine/runtime/workspace.js +164 -164
  185. package/server/lib/artifact-file-path.js +80 -80
  186. package/server/lib/artifact-target.js +284 -284
  187. package/server/lib/asset-refs.js +239 -239
  188. package/server/lib/async-queue.js +104 -104
  189. package/server/lib/auto-relations.js +130 -130
  190. package/server/lib/binding-types.js +60 -60
  191. package/server/lib/board-kind-sizes.js +62 -62
  192. package/server/lib/board-relations.js +133 -133
  193. package/server/lib/browse-proxy.js +180 -180
  194. package/server/lib/canvas-id.js +36 -36
  195. package/server/lib/cover.js +227 -227
  196. package/server/lib/danbooru-tags.js +291 -291
  197. package/server/lib/doc-extract.js +156 -156
  198. package/server/lib/docx/build-from-source.js +260 -260
  199. package/server/lib/docx/build.js +490 -490
  200. package/server/lib/docx/dump-styles.js +287 -287
  201. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  202. package/server/lib/docx/merge-runs.js +129 -129
  203. package/server/lib/docx/numbering.js +218 -218
  204. package/server/lib/docx/order.js +152 -152
  205. package/server/lib/docx/rawzip.js +172 -172
  206. package/server/lib/docx/render.js +92 -92
  207. package/server/lib/docx/text-lint.js +225 -225
  208. package/server/lib/docx/tokens.js +335 -335
  209. package/server/lib/docx/units.js +29 -29
  210. package/server/lib/docx/xml.js +291 -291
  211. package/server/lib/docx-pages.js +187 -187
  212. package/server/lib/export-collect.js +289 -289
  213. package/server/lib/export-package.js +210 -210
  214. package/server/lib/html-srcset.js +145 -145
  215. package/server/lib/image-variant.js +460 -460
  216. package/server/lib/ingress/forward-openai-chat.js +305 -305
  217. package/server/lib/ingress/openai-chat.js +491 -491
  218. package/server/lib/ingress/session-notice.js +66 -66
  219. package/server/lib/ingress/session-routes.js +68 -68
  220. package/server/lib/ingress/slot-probe.js +130 -130
  221. package/server/lib/ingress/upstream-billing.js +52 -52
  222. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  223. package/server/lib/ingress/upstream-truncation.js +48 -48
  224. package/server/lib/issues-store.js +182 -182
  225. package/server/lib/kinds/deck.js +72 -72
  226. package/server/lib/kinds/docx.js +283 -283
  227. package/server/lib/kinds/file-kinds.js +113 -113
  228. package/server/lib/kinds/index.js +202 -202
  229. package/server/lib/kinds/site.js +161 -161
  230. package/server/lib/model-ingress.js +543 -543
  231. package/server/lib/moderation.js +255 -255
  232. package/server/lib/notice-store.js +103 -103
  233. package/server/lib/plugin-install.js +104 -104
  234. package/server/lib/plugin-validator.js +721 -721
  235. package/server/lib/publish-store.js +115 -115
  236. package/server/lib/quick-summary.js +52 -52
  237. package/server/lib/quota.js +270 -270
  238. package/server/lib/rate-window.js +29 -29
  239. package/server/lib/reference-assets.js +92 -92
  240. package/server/lib/region-shot.js +135 -135
  241. package/server/lib/safe-path.js +73 -73
  242. package/server/lib/sdk-session.js +33 -33
  243. package/server/lib/showcase-store.js +92 -92
  244. package/server/lib/site-publish.js +465 -465
  245. package/server/lib/ssrf-guard.js +432 -432
  246. package/server/lib/task-scan.js +158 -158
  247. package/server/lib/tavern-json.js +154 -154
  248. package/server/lib/video-variant.js +237 -237
  249. package/server/lib/workspace-path.js +33 -33
  250. package/server/ops/fix-sdk-musl.mjs +40 -40
  251. package/server/ops/install-macos-fonts.sh +114 -114
  252. package/server/ops/macos-fonts.conf +336 -336
  253. package/server/ops/sandbox-shim/bwrap +57 -57
  254. package/server/projects/assets-summary.js +119 -119
  255. package/server/projects/auto-name.js +53 -53
  256. package/server/projects/board-store.js +640 -640
  257. package/server/projects/move-entry.js +103 -103
  258. package/server/projects/store.js +270 -270
  259. package/server/projects/ui-config.js +54 -54
  260. package/server/projects/workspace-templates.js +53 -53
  261. package/server/projects/workspace.js +1025 -1025
  262. package/server/runtime/local-config.js +177 -177
  263. package/server/runtime/profile.js +72 -72
  264. package/server/services/rembg-service.py +334 -334
  265. package/server/shared/README.md +25 -25
  266. package/server/shared/deck.js +54 -54
  267. package/server/shared/time.js +49 -49
  268. package/server/style-pipeline/README.md +53 -53
  269. package/server/ws/broker.js +36 -36
  270. package/server/ws/browse-channel.js +177 -177
  271. package/server/ws/index.js +386 -386
  272. package/web/README.md +89 -89
  273. package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
  274. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
  275. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
  276. package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
  277. package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
  278. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
  279. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
  280. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
  281. package/web/dist/index.html +17 -17
  282. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
@@ -1,307 +1,307 @@
1
- /**
2
- * server/engine/browse/registry.js — 常驻浏览器登记处(2026-08-18)
3
- *
4
- * agent 用浏览器看设计参考这条线的本体。**进程级单例,按 projectId 键。**
5
- *
6
- * ## 为什么是常驻,为什么按项目键
7
- *
8
- * 在这之前所有浏览器工具都是 launch→用→close 即弃(`screenshot-url.js` 那套),
9
- * 所以**没有会话**:点不了链接、翻不了子页、登录态留不住、Cookie 同意弹窗每次重新
10
- * 弹,想看第三层页面只能猜 URL。用户要的正是这条:让 agent 能真的**逛**。
11
- *
12
- * 键按 **projectId**(用户拍板):项目 owner 唯一,所以这等于 per-user per-project;
13
- * 登录态天然跨会话复用 —— 用户的原话是「**此后**从浏览器中获得的可复用内容」,
14
- * 那个"此后"包含下一次开新对话。
15
- *
16
- * ## 这台机器只有 1 vCPU,所以每条约束都是硬的
17
- *
18
- * - 常驻浏览器 **≤ 2**:空闲实测 185 MiB PSS / 0.1% CPU,内存不是矛盾,但超了就
19
- * 按 LRU 关最久没用的**空闲**那个;一个都腾不出来就**拒绝并告诉 agent**,
20
- * 不静默排队(静默降级是这个仓库的老账)
21
- * - 同项目的调用走 **mutex 串行**:同一个项目可能同时开着两个会话,两个 agent 抢
22
- * 一个 page 会互相把对方导航走
23
- * - 空闲 5 分钟回收(保留 profile)。timer `.unref()` —— 别让它拖着进程不退出
24
- *
25
- * ## 跟感知通道刻意分开的两条通道
26
- *
27
- * `perception-page.js` 的 `launchPerceptionBrowser` 是**感知通道**:URL 由我们构造
28
- * (artifact-file loopback)、允许 loopback、用完即弃。这里是**浏览通道**:URL 由
29
- * agent 自由指定、**loopback 一律禁**(SSRF 闸)、常驻带持久 profile。
30
- * 两个函数并排放着而不是合成一个带 flag 的 —— 「同一件东西有多个实例」的反面是
31
- * 「两件不同的东西硬塞进一个函数」,安全语义会被 flag 搅浑。共享的只有
32
- * `FIDELITY_LAUNCH_ARGS` 那一份常量。
33
- */
34
-
35
- import path from 'node:path';
36
- import { mutex } from 'async-mutex-lite';
37
- import { getProjectWorkspace } from '../../projects/workspace.js';
38
- import { FIDELITY_LAUNCH_ARGS } from '../mcp/tools/helpers/perception-page.js';
39
- import { attachSsrfGuard } from '../../lib/ssrf-guard.js';
40
- import { startBrowseProxy } from '../../lib/browse-proxy.js';
41
-
42
- /**
43
- * 常驻上限(内存)。1 vCPU 上活跃画面流另有 ≤1 的上限,见 screencast 那一层。
44
- *
45
- * ⚠️ 写法要 NaN-safe:`Number('yes')` 是 NaN,而 `live.size >= NaN` **恒为 false**
46
- * → 上限静默消失,chromium 无限起。这台机器 1 核 8G **无 swap**,尖峰直接进
47
- * 内核 OOM killer。(隔壁 IDLE_MS 用的 `|| 默认` 写法本来就是 NaN-safe 的。)
48
- */
49
- const MAX_RESIDENT = Number(process.env.ND_BROWSE_MAX) > 0
50
- ? Math.floor(Number(process.env.ND_BROWSE_MAX)) : 2;
51
- /** 空闲多久回收。仿 h3box 的 ControlPersist=600 与 WS grace 的思路。 */
52
- const IDLE_MS = Number(process.env.ND_BROWSE_IDLE_MS || 0) || 5 * 60 * 1000;
53
- const NAV_TIMEOUT_MS = 30_000;
54
-
55
- /**
56
- * 视口:桌面宽(看设计参考要的是版面不是移动端),**且坐标 1:1**。
57
- * 08-21 从 1440×900 改 1366×768:改的时候 shot-pipeline 阈值还是 1568/1.15MP,1440×900=1.30MP
58
- * 会被缩 0.94 倍 → browser_computer 从图上读的坐标和页面像素差 6%。同日阈值抬到 2000px/3.75MP
59
- * 后 1440×900 其实也不缩了,但 1366×768 留着:便宜(≈1372 token/张)、是文档推荐档、坐标 1:1。
60
- * 也是 Anthropic computer-use 文档给 web 应用推荐的档位。改它要连 browse-computer.test.js
61
- * 的"视口在阈值内"断言一起过。
62
- */
63
- const VIEWPORT = { width: 1366, height: 768 };
64
-
65
- /** projectId → entry */
66
- const live = new Map();
67
-
68
- /**
69
- * ⭐ 把 UA 里的 HeadlessChrome 换成 Chrome。
70
- *
71
- * 依据是一篇受控实验(arXiv:2606.14525,Tranco Top 10K × 4 万次抓取):在「只有
72
- * headless 被封」的 784 个站上,**仅仅**把 UA 与 Client Hints 里的 `HeadlessChrome`
73
- * 换成 `Chrome`,590 个(**75%**)当场转 HTTP 2xx。也就是说 headed 相对 headless
74
- * 那 8 个百分点的优势,四分之三来自这个头部信号 —— 不是「headed 本身更像人」。
75
- * 所以我们不装 Xvfb(1 vCPU 付不起一颗核,而且 headed vs **新** headless 全世界
76
- * 没人测过),改这个字符串就吃满大头。
77
- *
78
- * ⚠️ 这不是为了绕强反爬(数据中心 IP 是改不掉的底噪,最凶的墙怎么配都拦 ——
79
- * 那是「人接手」存在的理由)。这是为了不在最脏的一档上白挨拦。
80
- */
81
- function chromeUa(raw) {
82
- return String(raw).replace(/HeadlessChrome\//g, 'Chrome/');
83
- }
84
-
85
- async function launchBrowseBrowser(projectId) {
86
- const { chromium } = await import('playwright');
87
- // profile 必须落在 `<pid>/` 下、`shared/` **之外**:shared 是 agent 的 cwd 也是
88
- // artifact-file 的服务根 —— 放进去等于 (a) cookie jar 能被当文件服出去
89
- // (b) agent 能 Read 到自己的 cookie (c) 进 per-project git。
90
- // 放兄弟位就都避开了,而且删项目时 removeProjectWorkspace 删的是整个 `<pid>/`
91
- // (核过),profile 跟着走,不用额外写清理。
92
- const userDataDir = path.join(getProjectWorkspace(projectId), '.browser', 'default');
93
-
94
- // ⭐ 出网闸在**代理层**(2026-08-18 第二遍):`--proxy-server` 之后 chromium 的
95
- // 每一次连接都从这儿走 —— 包括 CDP 的 Fetch 阶段看不见的那些(WebSocket 握手、
96
- // `<link rel=prefetch>`、sendBeacon、还没装上闸的弹窗)。理由与实测见
97
- // lib/browse-proxy.js 文件头。CDP 那道闸保留当纵深,不再是唯一一道。
98
- // ⚠️ `bypass: ''` 是必须的:默认会放过 loopback,那正好是我们最要拦的。
99
- const { port: proxyPort } = await startBrowseProxy();
100
-
101
- const context = await chromium.launchPersistentContext(userDataDir, {
102
- channel: 'chromium', // ⭐ 不是默认的 headless_shell,见 chromeUa 的注释
103
- headless: true,
104
- proxy: { server: `http://127.0.0.1:${proxyPort}`, bypass: '' },
105
- // ⭐ WebRTC 是代理**看不见**的那条路:它走 UDP,不经 HTTP 代理,能被用来
106
- // 探内网存活(STUN 打内网地址)。看设计参考完全不需要 WebRTC,所以直接
107
- // 掐掉非代理 UDP —— 这是 Chromium 官方给的那个开关,不是我们发明的。
108
- args: [...FIDELITY_LAUNCH_ARGS, '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'],
109
- viewport: VIEWPORT,
110
- locale: 'zh-CN', // 面向中文用户;顺带摆脱默认的 en-US@posix 那种怪指纹
111
- timezoneId: 'Asia/Shanghai',
112
- acceptDownloads: false, // 下载不是这条线要的,而且是一条额外的写盘面
113
- });
114
-
115
- // proxied: true —— 第二道(连上后看对端 IP)在代理下是纯冗余,而且会把每个页面
116
- // 都误杀(代理自己就是 127.0.0.1)。第一道(Fetch 阶段)保留当纵深。
117
- const guard = await attachSsrfGuard(context, undefined, { proxied: true });
118
- // 持久 context 自带一个空白页;没有就造一个
119
- const page = context.pages()[0] || await context.newPage();
120
- await guard.armPage(page); // ⭐ 必须 await 完才允许导航(竞态是攻出来的)
121
-
122
- // UA 与 Client Hints 一起改 —— 只改 UA 字符串的话 sec-ch-ua 还在报 headless
123
- const cdp = await context.newCDPSession(page);
124
- const realUa = await page.evaluate(() => navigator.userAgent);
125
- const ua = chromeUa(realUa);
126
- const m = realUa.match(/Chrome\/(\d+)/);
127
- const major = m ? m[1] : '147';
128
- await cdp.send('Emulation.setUserAgentOverride', {
129
- userAgent: ua,
130
- acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
131
- platform: 'Linux x86_64',
132
- userAgentMetadata: {
133
- brands: [
134
- { brand: 'Chromium', version: major },
135
- { brand: 'Google Chrome', version: major },
136
- { brand: 'Not?A_Brand', version: '24' },
137
- ],
138
- fullVersion: `${major}.0.0.0`,
139
- platform: 'Linux',
140
- platformVersion: '6.1.0',
141
- architecture: 'x86',
142
- model: '',
143
- mobile: false,
144
- },
145
- }).catch(() => { /* 覆盖不上就退回只改 UA 字符串那一档,别因此起不来 */ });
146
-
147
- return { context, page, guard, ua };
148
- }
149
-
150
- function touch(entry) {
151
- entry.lastUsed = Date.now();
152
- clearTimeout(entry.idleTimer);
153
- entry.idleTimer = setTimeout(() => { closeFor(entry.projectId, 'idle').catch(() => {}); }, IDLE_MS);
154
- entry.idleTimer.unref?.();
155
- }
156
-
157
- /** 关掉某个项目的浏览器(**保留 profile** —— 登录态是资产) */
158
- export async function closeFor(projectId, why = 'explicit') {
159
- const entry = live.get(projectId);
160
- if (!entry) return false;
161
- live.delete(projectId);
162
- clearTimeout(entry.idleTimer);
163
- // 先让画面流那一层知道(它要通知正在看的人,并且停掉编码),再关浏览器 ——
164
- // 反过来的话 CDP 会话已经死了,stopScreencast 只会抛一堆没意义的错
165
- try { (await import('./screencast.js')).forget(projectId); } catch { /* */ }
166
- try { await entry.context.close(); } catch { /* 已经死了就算了 */ }
167
- console.log(`[browse] closed ${projectId} (${why})`);
168
- return true;
169
- }
170
-
171
- /** 腾一个位子出来:按 LRU 关最久没用的**空闲**实例。@returns 腾出来了没 */
172
- async function evictOne() {
173
- const idle = [...live.values()].filter(e => !e.busy).sort((a, b) => a.lastUsed - b.lastUsed);
174
- if (!idle.length) return false;
175
- await closeFor(idle[0].projectId, 'evicted (LRU)');
176
- return true;
177
- }
178
-
179
- /**
180
- * 拿到某个项目的浏览器(懒启动)。**同项目串行**。
181
- *
182
- * @param {string} projectId
183
- * @param {(h: {page: import('playwright').Page, guard: object, ua: string}) => Promise<any>} fn
184
- * @returns {Promise<any>} fn 的返回值
185
- * @throws 超上限时抛错,**不静默排队** —— agent 该看到"满了"这件事
186
- */
187
- export async function withBrowser(projectId, fn) {
188
- return mutex(`browse:${projectId}`, async () => {
189
- let entry = live.get(projectId);
190
- // ⚠️ 页可能已经不在了而 entry 还在:出网闸连到坏 IP 时会 `page.close()`
191
- // (ssrf-guard 的 [connected] 兜底)。任意页面塞一个
192
- // `<link rel=prefetch href=http://127.0.0.1:4001/>` 就能让常驻页变成一具尸体,
193
- // 而 5 分钟空闲计时器才会清它 —— 等于给外站开了个远程杀开关。
194
- // 这里发现就重建(别让 agent 拿着 "Target has been closed" 猜发生了什么)。
195
- if (entry && entry.page.isClosed()) {
196
- console.log(`[browse] ${projectId} 的页已被掐(多半是出网闸),重建`);
197
- await closeFor(projectId, 'page was closed underneath us');
198
- entry = null;
199
- }
200
- if (!entry) {
201
- if (live.size >= MAX_RESIDENT && !(await evictOne())) {
202
- throw Object.assign(
203
- new Error(`浏览器实例已满(${live.size}/${MAX_RESIDENT} 都在用)。`
204
- + '等一会儿再试,或者让别的会话先收工 —— 这台机器只有 1 个 CPU 核,常驻上限是硬的。'),
205
- { status: 503 },
206
- );
207
- }
208
- const t0 = Date.now();
209
- const h = await launchBrowseBrowser(projectId);
210
- entry = { projectId, ...h, lastUsed: Date.now(), busy: false, idleTimer: null };
211
- live.set(projectId, entry);
212
- console.log(`[browse] launched ${projectId} in ${Date.now() - t0}ms (${live.size}/${MAX_RESIDENT} resident)`);
213
- }
214
- entry.busy = true;
215
- try {
216
- touch(entry);
217
- return await fn({ page: entry.page, guard: entry.guard, ua: entry.ua, context: entry.context });
218
- } finally {
219
- entry.busy = false;
220
- touch(entry); // 用完重新起计时,别让长调用被自己的旧计时器掐掉
221
- }
222
- });
223
- }
224
-
225
- // ⚠️ **刻意没有 onSessionEnd。** 计划里写了「会话结束时在 session-loop 的 finally
226
- // 里关掉该项目的浏览器」,落地时否掉了,理由两条:
227
- // ① `active-runs` 是按 sessionId 键的,**没有「这个项目还有别的活会话吗」这个
228
- // 查询**。同一个项目同时开两个会话是常见的(用户经常这么用),照着关就是把
229
- // 另一个会话正在看的页面关掉。
230
- // ② 5 分钟空闲回收已经覆盖了"人走了"这件事,而且它按**实际使用**判,不按会话
231
- // 生命周期判 —— 后者跟"浏览器还有没有人要用"其实不是一回事。
232
- // 与其造一个用不上的导出摆在这儿(这个仓库有过"全仓无人写入的字段假装第一优先级"
233
- // 那种账),不如写清为什么没有。
234
-
235
- /**
236
- * 把某个项目的浏览器**钉住**:期间它不空闲、不会被 LRU 挤掉、不会被空闲计时器关。
237
- *
238
- * 为什么需要(审查抓到的):`browser_request_help` 是在 `withBrowser` **外面**等人的
239
- * (它必须放开 mutex,否则人在窗里点的每一下都会排在 agent 这次调用后面 = 死锁)。
240
- * 于是等人的那 120 秒里 `busy` 是 false、`lastUsed` 不动 —— `evictOne` 正好挑
241
- * "最久没用的空闲实例",它就是那个**人正在过验证码的浏览器**。
242
- *
243
- * @returns {() => void} 松手(幂等)
244
- */
245
- export function hold(projectId, why = 'held') {
246
- const entry = live.get(projectId);
247
- if (!entry) return () => {};
248
- entry.busy = true;
249
- entry.holds = (entry.holds || 0) + 1;
250
- touch(entry);
251
- let released = false;
252
- return () => {
253
- if (released) return;
254
- released = true;
255
- const e = live.get(projectId);
256
- if (!e) return;
257
- e.holds = Math.max(0, (e.holds || 1) - 1);
258
- if (!e.holds) e.busy = false;
259
- touch(e); // 松手时重新起计时,别让它带着旧计时器立刻被回收
260
- console.log(`[browse] ${projectId} 松手(${why})`);
261
- };
262
- }
263
-
264
- /**
265
- * 人在窗里动了一下 → 重置空闲计时。
266
- * 没有这条的话:人安静看五分钟,浏览器在他眼前被关掉(空闲计时只认 agent 的调用)。
267
- */
268
- export function touchProject(projectId) {
269
- const entry = live.get(projectId);
270
- if (entry) touch(entry);
271
- return !!entry;
272
- }
273
-
274
- /**
275
- * 刷新页面之后拿回「现在有没有浏览器 / agent 是不是正举着手」。
276
- * 求助 banner 原来只靠一次性的 WS 事件,刷新即失传 —— agent 在那儿等两分钟没人知道。
277
- */
278
- export function browseState(projectId) {
279
- const e = live.get(projectId);
280
- return {
281
- live: !!e,
282
- url: e ? e.page.url() : null,
283
- busy: e ? !!e.busy : false,
284
- };
285
- }
286
-
287
- /**
288
- * 拿到某项目**已经在跑**的浏览器(不懒启动)。
289
- * 给 WS 画面通道用:用户开窗时如果 agent 还没开始浏览,就该显示"还没开始",
290
- * 而不是替 agent 起一个浏览器(那会让 1 vCPU 上的常驻名额被看客占掉)。
291
- */
292
- export function peek(projectId) {
293
- const e = live.get(projectId);
294
- return e ? { page: e.page, context: e.context, guard: e.guard } : null;
295
- }
296
-
297
- /** 给体检/日志用 */
298
- export function status() {
299
- return [...live.values()].map(e => ({
300
- projectId: e.projectId,
301
- url: (() => { try { return e.page.url(); } catch { return '(gone)'; } })(),
302
- idleMs: Date.now() - e.lastUsed,
303
- busy: e.busy,
304
- }));
305
- }
306
-
307
- export const _limits = { MAX_RESIDENT, IDLE_MS, NAV_TIMEOUT_MS, VIEWPORT };
1
+ /**
2
+ * server/engine/browse/registry.js — 常驻浏览器登记处(2026-08-18)
3
+ *
4
+ * agent 用浏览器看设计参考这条线的本体。**进程级单例,按 projectId 键。**
5
+ *
6
+ * ## 为什么是常驻,为什么按项目键
7
+ *
8
+ * 在这之前所有浏览器工具都是 launch→用→close 即弃(`screenshot-url.js` 那套),
9
+ * 所以**没有会话**:点不了链接、翻不了子页、登录态留不住、Cookie 同意弹窗每次重新
10
+ * 弹,想看第三层页面只能猜 URL。用户要的正是这条:让 agent 能真的**逛**。
11
+ *
12
+ * 键按 **projectId**(用户拍板):项目 owner 唯一,所以这等于 per-user per-project;
13
+ * 登录态天然跨会话复用 —— 用户的原话是「**此后**从浏览器中获得的可复用内容」,
14
+ * 那个"此后"包含下一次开新对话。
15
+ *
16
+ * ## 这台机器只有 1 vCPU,所以每条约束都是硬的
17
+ *
18
+ * - 常驻浏览器 **≤ 2**:空闲实测 185 MiB PSS / 0.1% CPU,内存不是矛盾,但超了就
19
+ * 按 LRU 关最久没用的**空闲**那个;一个都腾不出来就**拒绝并告诉 agent**,
20
+ * 不静默排队(静默降级是这个仓库的老账)
21
+ * - 同项目的调用走 **mutex 串行**:同一个项目可能同时开着两个会话,两个 agent 抢
22
+ * 一个 page 会互相把对方导航走
23
+ * - 空闲 5 分钟回收(保留 profile)。timer `.unref()` —— 别让它拖着进程不退出
24
+ *
25
+ * ## 跟感知通道刻意分开的两条通道
26
+ *
27
+ * `perception-page.js` 的 `launchPerceptionBrowser` 是**感知通道**:URL 由我们构造
28
+ * (artifact-file loopback)、允许 loopback、用完即弃。这里是**浏览通道**:URL 由
29
+ * agent 自由指定、**loopback 一律禁**(SSRF 闸)、常驻带持久 profile。
30
+ * 两个函数并排放着而不是合成一个带 flag 的 —— 「同一件东西有多个实例」的反面是
31
+ * 「两件不同的东西硬塞进一个函数」,安全语义会被 flag 搅浑。共享的只有
32
+ * `FIDELITY_LAUNCH_ARGS` 那一份常量。
33
+ */
34
+
35
+ import path from 'node:path';
36
+ import { mutex } from 'async-mutex-lite';
37
+ import { getProjectWorkspace } from '../../projects/workspace.js';
38
+ import { FIDELITY_LAUNCH_ARGS } from '../mcp/tools/helpers/perception-page.js';
39
+ import { attachSsrfGuard } from '../../lib/ssrf-guard.js';
40
+ import { startBrowseProxy } from '../../lib/browse-proxy.js';
41
+
42
+ /**
43
+ * 常驻上限(内存)。1 vCPU 上活跃画面流另有 ≤1 的上限,见 screencast 那一层。
44
+ *
45
+ * ⚠️ 写法要 NaN-safe:`Number('yes')` 是 NaN,而 `live.size >= NaN` **恒为 false**
46
+ * → 上限静默消失,chromium 无限起。这台机器 1 核 8G **无 swap**,尖峰直接进
47
+ * 内核 OOM killer。(隔壁 IDLE_MS 用的 `|| 默认` 写法本来就是 NaN-safe 的。)
48
+ */
49
+ const MAX_RESIDENT = Number(process.env.ND_BROWSE_MAX) > 0
50
+ ? Math.floor(Number(process.env.ND_BROWSE_MAX)) : 2;
51
+ /** 空闲多久回收。仿 h3box 的 ControlPersist=600 与 WS grace 的思路。 */
52
+ const IDLE_MS = Number(process.env.ND_BROWSE_IDLE_MS || 0) || 5 * 60 * 1000;
53
+ const NAV_TIMEOUT_MS = 30_000;
54
+
55
+ /**
56
+ * 视口:桌面宽(看设计参考要的是版面不是移动端),**且坐标 1:1**。
57
+ * 08-21 从 1440×900 改 1366×768:改的时候 shot-pipeline 阈值还是 1568/1.15MP,1440×900=1.30MP
58
+ * 会被缩 0.94 倍 → browser_computer 从图上读的坐标和页面像素差 6%。同日阈值抬到 2000px/3.75MP
59
+ * 后 1440×900 其实也不缩了,但 1366×768 留着:便宜(≈1372 token/张)、是文档推荐档、坐标 1:1。
60
+ * 也是 Anthropic computer-use 文档给 web 应用推荐的档位。改它要连 browse-computer.test.js
61
+ * 的"视口在阈值内"断言一起过。
62
+ */
63
+ const VIEWPORT = { width: 1366, height: 768 };
64
+
65
+ /** projectId → entry */
66
+ const live = new Map();
67
+
68
+ /**
69
+ * ⭐ 把 UA 里的 HeadlessChrome 换成 Chrome。
70
+ *
71
+ * 依据是一篇受控实验(arXiv:2606.14525,Tranco Top 10K × 4 万次抓取):在「只有
72
+ * headless 被封」的 784 个站上,**仅仅**把 UA 与 Client Hints 里的 `HeadlessChrome`
73
+ * 换成 `Chrome`,590 个(**75%**)当场转 HTTP 2xx。也就是说 headed 相对 headless
74
+ * 那 8 个百分点的优势,四分之三来自这个头部信号 —— 不是「headed 本身更像人」。
75
+ * 所以我们不装 Xvfb(1 vCPU 付不起一颗核,而且 headed vs **新** headless 全世界
76
+ * 没人测过),改这个字符串就吃满大头。
77
+ *
78
+ * ⚠️ 这不是为了绕强反爬(数据中心 IP 是改不掉的底噪,最凶的墙怎么配都拦 ——
79
+ * 那是「人接手」存在的理由)。这是为了不在最脏的一档上白挨拦。
80
+ */
81
+ function chromeUa(raw) {
82
+ return String(raw).replace(/HeadlessChrome\//g, 'Chrome/');
83
+ }
84
+
85
+ async function launchBrowseBrowser(projectId) {
86
+ const { chromium } = await import('playwright');
87
+ // profile 必须落在 `<pid>/` 下、`shared/` **之外**:shared 是 agent 的 cwd 也是
88
+ // artifact-file 的服务根 —— 放进去等于 (a) cookie jar 能被当文件服出去
89
+ // (b) agent 能 Read 到自己的 cookie (c) 进 per-project git。
90
+ // 放兄弟位就都避开了,而且删项目时 removeProjectWorkspace 删的是整个 `<pid>/`
91
+ // (核过),profile 跟着走,不用额外写清理。
92
+ const userDataDir = path.join(getProjectWorkspace(projectId), '.browser', 'default');
93
+
94
+ // ⭐ 出网闸在**代理层**(2026-08-18 第二遍):`--proxy-server` 之后 chromium 的
95
+ // 每一次连接都从这儿走 —— 包括 CDP 的 Fetch 阶段看不见的那些(WebSocket 握手、
96
+ // `<link rel=prefetch>`、sendBeacon、还没装上闸的弹窗)。理由与实测见
97
+ // lib/browse-proxy.js 文件头。CDP 那道闸保留当纵深,不再是唯一一道。
98
+ // ⚠️ `bypass: ''` 是必须的:默认会放过 loopback,那正好是我们最要拦的。
99
+ const { port: proxyPort } = await startBrowseProxy();
100
+
101
+ const context = await chromium.launchPersistentContext(userDataDir, {
102
+ channel: 'chromium', // ⭐ 不是默认的 headless_shell,见 chromeUa 的注释
103
+ headless: true,
104
+ proxy: { server: `http://127.0.0.1:${proxyPort}`, bypass: '' },
105
+ // ⭐ WebRTC 是代理**看不见**的那条路:它走 UDP,不经 HTTP 代理,能被用来
106
+ // 探内网存活(STUN 打内网地址)。看设计参考完全不需要 WebRTC,所以直接
107
+ // 掐掉非代理 UDP —— 这是 Chromium 官方给的那个开关,不是我们发明的。
108
+ args: [...FIDELITY_LAUNCH_ARGS, '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'],
109
+ viewport: VIEWPORT,
110
+ locale: 'zh-CN', // 面向中文用户;顺带摆脱默认的 en-US@posix 那种怪指纹
111
+ timezoneId: 'Asia/Shanghai',
112
+ acceptDownloads: false, // 下载不是这条线要的,而且是一条额外的写盘面
113
+ });
114
+
115
+ // proxied: true —— 第二道(连上后看对端 IP)在代理下是纯冗余,而且会把每个页面
116
+ // 都误杀(代理自己就是 127.0.0.1)。第一道(Fetch 阶段)保留当纵深。
117
+ const guard = await attachSsrfGuard(context, undefined, { proxied: true });
118
+ // 持久 context 自带一个空白页;没有就造一个
119
+ const page = context.pages()[0] || await context.newPage();
120
+ await guard.armPage(page); // ⭐ 必须 await 完才允许导航(竞态是攻出来的)
121
+
122
+ // UA 与 Client Hints 一起改 —— 只改 UA 字符串的话 sec-ch-ua 还在报 headless
123
+ const cdp = await context.newCDPSession(page);
124
+ const realUa = await page.evaluate(() => navigator.userAgent);
125
+ const ua = chromeUa(realUa);
126
+ const m = realUa.match(/Chrome\/(\d+)/);
127
+ const major = m ? m[1] : '147';
128
+ await cdp.send('Emulation.setUserAgentOverride', {
129
+ userAgent: ua,
130
+ acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
131
+ platform: 'Linux x86_64',
132
+ userAgentMetadata: {
133
+ brands: [
134
+ { brand: 'Chromium', version: major },
135
+ { brand: 'Google Chrome', version: major },
136
+ { brand: 'Not?A_Brand', version: '24' },
137
+ ],
138
+ fullVersion: `${major}.0.0.0`,
139
+ platform: 'Linux',
140
+ platformVersion: '6.1.0',
141
+ architecture: 'x86',
142
+ model: '',
143
+ mobile: false,
144
+ },
145
+ }).catch(() => { /* 覆盖不上就退回只改 UA 字符串那一档,别因此起不来 */ });
146
+
147
+ return { context, page, guard, ua };
148
+ }
149
+
150
+ function touch(entry) {
151
+ entry.lastUsed = Date.now();
152
+ clearTimeout(entry.idleTimer);
153
+ entry.idleTimer = setTimeout(() => { closeFor(entry.projectId, 'idle').catch(() => {}); }, IDLE_MS);
154
+ entry.idleTimer.unref?.();
155
+ }
156
+
157
+ /** 关掉某个项目的浏览器(**保留 profile** —— 登录态是资产) */
158
+ export async function closeFor(projectId, why = 'explicit') {
159
+ const entry = live.get(projectId);
160
+ if (!entry) return false;
161
+ live.delete(projectId);
162
+ clearTimeout(entry.idleTimer);
163
+ // 先让画面流那一层知道(它要通知正在看的人,并且停掉编码),再关浏览器 ——
164
+ // 反过来的话 CDP 会话已经死了,stopScreencast 只会抛一堆没意义的错
165
+ try { (await import('./screencast.js')).forget(projectId); } catch { /* */ }
166
+ try { await entry.context.close(); } catch { /* 已经死了就算了 */ }
167
+ console.log(`[browse] closed ${projectId} (${why})`);
168
+ return true;
169
+ }
170
+
171
+ /** 腾一个位子出来:按 LRU 关最久没用的**空闲**实例。@returns 腾出来了没 */
172
+ async function evictOne() {
173
+ const idle = [...live.values()].filter(e => !e.busy).sort((a, b) => a.lastUsed - b.lastUsed);
174
+ if (!idle.length) return false;
175
+ await closeFor(idle[0].projectId, 'evicted (LRU)');
176
+ return true;
177
+ }
178
+
179
+ /**
180
+ * 拿到某个项目的浏览器(懒启动)。**同项目串行**。
181
+ *
182
+ * @param {string} projectId
183
+ * @param {(h: {page: import('playwright').Page, guard: object, ua: string}) => Promise<any>} fn
184
+ * @returns {Promise<any>} fn 的返回值
185
+ * @throws 超上限时抛错,**不静默排队** —— agent 该看到"满了"这件事
186
+ */
187
+ export async function withBrowser(projectId, fn) {
188
+ return mutex(`browse:${projectId}`, async () => {
189
+ let entry = live.get(projectId);
190
+ // ⚠️ 页可能已经不在了而 entry 还在:出网闸连到坏 IP 时会 `page.close()`
191
+ // (ssrf-guard 的 [connected] 兜底)。任意页面塞一个
192
+ // `<link rel=prefetch href=http://127.0.0.1:4001/>` 就能让常驻页变成一具尸体,
193
+ // 而 5 分钟空闲计时器才会清它 —— 等于给外站开了个远程杀开关。
194
+ // 这里发现就重建(别让 agent 拿着 "Target has been closed" 猜发生了什么)。
195
+ if (entry && entry.page.isClosed()) {
196
+ console.log(`[browse] ${projectId} 的页已被掐(多半是出网闸),重建`);
197
+ await closeFor(projectId, 'page was closed underneath us');
198
+ entry = null;
199
+ }
200
+ if (!entry) {
201
+ if (live.size >= MAX_RESIDENT && !(await evictOne())) {
202
+ throw Object.assign(
203
+ new Error(`浏览器实例已满(${live.size}/${MAX_RESIDENT} 都在用)。`
204
+ + '等一会儿再试,或者让别的会话先收工 —— 这台机器只有 1 个 CPU 核,常驻上限是硬的。'),
205
+ { status: 503 },
206
+ );
207
+ }
208
+ const t0 = Date.now();
209
+ const h = await launchBrowseBrowser(projectId);
210
+ entry = { projectId, ...h, lastUsed: Date.now(), busy: false, idleTimer: null };
211
+ live.set(projectId, entry);
212
+ console.log(`[browse] launched ${projectId} in ${Date.now() - t0}ms (${live.size}/${MAX_RESIDENT} resident)`);
213
+ }
214
+ entry.busy = true;
215
+ try {
216
+ touch(entry);
217
+ return await fn({ page: entry.page, guard: entry.guard, ua: entry.ua, context: entry.context });
218
+ } finally {
219
+ entry.busy = false;
220
+ touch(entry); // 用完重新起计时,别让长调用被自己的旧计时器掐掉
221
+ }
222
+ });
223
+ }
224
+
225
+ // ⚠️ **刻意没有 onSessionEnd。** 计划里写了「会话结束时在 session-loop 的 finally
226
+ // 里关掉该项目的浏览器」,落地时否掉了,理由两条:
227
+ // ① `active-runs` 是按 sessionId 键的,**没有「这个项目还有别的活会话吗」这个
228
+ // 查询**。同一个项目同时开两个会话是常见的(用户经常这么用),照着关就是把
229
+ // 另一个会话正在看的页面关掉。
230
+ // ② 5 分钟空闲回收已经覆盖了"人走了"这件事,而且它按**实际使用**判,不按会话
231
+ // 生命周期判 —— 后者跟"浏览器还有没有人要用"其实不是一回事。
232
+ // 与其造一个用不上的导出摆在这儿(这个仓库有过"全仓无人写入的字段假装第一优先级"
233
+ // 那种账),不如写清为什么没有。
234
+
235
+ /**
236
+ * 把某个项目的浏览器**钉住**:期间它不空闲、不会被 LRU 挤掉、不会被空闲计时器关。
237
+ *
238
+ * 为什么需要(审查抓到的):`browser_request_help` 是在 `withBrowser` **外面**等人的
239
+ * (它必须放开 mutex,否则人在窗里点的每一下都会排在 agent 这次调用后面 = 死锁)。
240
+ * 于是等人的那 120 秒里 `busy` 是 false、`lastUsed` 不动 —— `evictOne` 正好挑
241
+ * "最久没用的空闲实例",它就是那个**人正在过验证码的浏览器**。
242
+ *
243
+ * @returns {() => void} 松手(幂等)
244
+ */
245
+ export function hold(projectId, why = 'held') {
246
+ const entry = live.get(projectId);
247
+ if (!entry) return () => {};
248
+ entry.busy = true;
249
+ entry.holds = (entry.holds || 0) + 1;
250
+ touch(entry);
251
+ let released = false;
252
+ return () => {
253
+ if (released) return;
254
+ released = true;
255
+ const e = live.get(projectId);
256
+ if (!e) return;
257
+ e.holds = Math.max(0, (e.holds || 1) - 1);
258
+ if (!e.holds) e.busy = false;
259
+ touch(e); // 松手时重新起计时,别让它带着旧计时器立刻被回收
260
+ console.log(`[browse] ${projectId} 松手(${why})`);
261
+ };
262
+ }
263
+
264
+ /**
265
+ * 人在窗里动了一下 → 重置空闲计时。
266
+ * 没有这条的话:人安静看五分钟,浏览器在他眼前被关掉(空闲计时只认 agent 的调用)。
267
+ */
268
+ export function touchProject(projectId) {
269
+ const entry = live.get(projectId);
270
+ if (entry) touch(entry);
271
+ return !!entry;
272
+ }
273
+
274
+ /**
275
+ * 刷新页面之后拿回「现在有没有浏览器 / agent 是不是正举着手」。
276
+ * 求助 banner 原来只靠一次性的 WS 事件,刷新即失传 —— agent 在那儿等两分钟没人知道。
277
+ */
278
+ export function browseState(projectId) {
279
+ const e = live.get(projectId);
280
+ return {
281
+ live: !!e,
282
+ url: e ? e.page.url() : null,
283
+ busy: e ? !!e.busy : false,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * 拿到某项目**已经在跑**的浏览器(不懒启动)。
289
+ * 给 WS 画面通道用:用户开窗时如果 agent 还没开始浏览,就该显示"还没开始",
290
+ * 而不是替 agent 起一个浏览器(那会让 1 vCPU 上的常驻名额被看客占掉)。
291
+ */
292
+ export function peek(projectId) {
293
+ const e = live.get(projectId);
294
+ return e ? { page: e.page, context: e.context, guard: e.guard } : null;
295
+ }
296
+
297
+ /** 给体检/日志用 */
298
+ export function status() {
299
+ return [...live.values()].map(e => ({
300
+ projectId: e.projectId,
301
+ url: (() => { try { return e.page.url(); } catch { return '(gone)'; } })(),
302
+ idleMs: Date.now() - e.lastUsed,
303
+ busy: e.busy,
304
+ }));
305
+ }
306
+
307
+ export const _limits = { MAX_RESIDENT, IDLE_MS, NAV_TIMEOUT_MS, VIEWPORT };