geneseed 2.0.0

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 (337) hide show
  1. package/CHANGELOG.md +938 -0
  2. package/DESIGN.md +1423 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +48 -0
  5. package/README.md +336 -0
  6. package/SETUP.md +1027 -0
  7. package/SHIPPED.md +70 -0
  8. package/adapters/bob/README.md +100 -0
  9. package/adapters/claude-code/README.md +101 -0
  10. package/adapters/claude-code/settings.json +47 -0
  11. package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
  12. package/adapters/copilot/README.md +76 -0
  13. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
  14. package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
  15. package/adapters/opencode/README.md +582 -0
  16. package/adapters/opencode/agents/orchestrator.md +25 -0
  17. package/adapters/opencode/opencode.json +44 -0
  18. package/adapters/opencode/plugins/geneseed-activity.js +430 -0
  19. package/adapters/opencode/plugins/geneseed-context.js +876 -0
  20. package/adapters/opencode/plugins/geneseed-guard.js +290 -0
  21. package/adapters/opencode/plugins/geneseed-learn.js +396 -0
  22. package/adapters/opencode/plugins/geneseed-notify.js +142 -0
  23. package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
  24. package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
  25. package/adapters/opencode/workflows/_runtime.js +277 -0
  26. package/adapters/opencode/workflows/council.js +64 -0
  27. package/adapters/opencode/workflows/dispatch.js +89 -0
  28. package/adapters/opencode/workflows/research-plan-implement.js +43 -0
  29. package/adapters/opencode/workflows/review.js +57 -0
  30. package/bin/geneseed-cli.mjs +412 -0
  31. package/bin/geneseed-hook.mjs +129 -0
  32. package/bin/geneseed.mjs +1086 -0
  33. package/docs/declined.md +149 -0
  34. package/docs/limits.md +270 -0
  35. package/docs/opencode-plugin-setup.md +64 -0
  36. package/docs/token-footprint.md +68 -0
  37. package/docs/web/_groups.json +45 -0
  38. package/docs/web/about.md +6 -0
  39. package/docs/web/adapters-claude-code.md +8 -0
  40. package/docs/web/adapters-opencode-loads.md +8 -0
  41. package/docs/web/adapters-opencode-spec.md +8 -0
  42. package/docs/web/adapters-opencode.md +8 -0
  43. package/docs/web/agents.md +8 -0
  44. package/docs/web/authoring.md +11 -0
  45. package/docs/web/autostart.md +9 -0
  46. package/docs/web/claude-hooks.md +36 -0
  47. package/docs/web/cli.md +6 -0
  48. package/docs/web/collaboration.md +27 -0
  49. package/docs/web/configure-wiki.md +9 -0
  50. package/docs/web/design.md +7 -0
  51. package/docs/web/env-knobs.md +9 -0
  52. package/docs/web/exclusions.md +26 -0
  53. package/docs/web/first-session.md +28 -0
  54. package/docs/web/footprint.md +31 -0
  55. package/docs/web/foreman-mode.md +33 -0
  56. package/docs/web/glossary.md +6 -0
  57. package/docs/web/harnesses.md +28 -0
  58. package/docs/web/headless.md +9 -0
  59. package/docs/web/ingest-docs.md +9 -0
  60. package/docs/web/install-paths.md +9 -0
  61. package/docs/web/install-quick.md +35 -0
  62. package/docs/web/lsp-overview.md +37 -0
  63. package/docs/web/mcp-claude-code.md +10 -0
  64. package/docs/web/mcp-filesystem.md +9 -0
  65. package/docs/web/mcp-gitlab.md +9 -0
  66. package/docs/web/mcp-markitdown.md +9 -0
  67. package/docs/web/mcp-overview.md +34 -0
  68. package/docs/web/mcp-trouble.md +9 -0
  69. package/docs/web/mcp-verify.md +9 -0
  70. package/docs/web/memory.md +7 -0
  71. package/docs/web/migrate.md +39 -0
  72. package/docs/web/model.md +20 -0
  73. package/docs/web/notebook.md +7 -0
  74. package/docs/web/pipelines.md +24 -0
  75. package/docs/web/plugin-activity.md +23 -0
  76. package/docs/web/plugin-context.md +26 -0
  77. package/docs/web/plugin-guard.md +24 -0
  78. package/docs/web/plugin-learn.md +27 -0
  79. package/docs/web/plugin-notify.md +24 -0
  80. package/docs/web/plugin-ponytail.md +23 -0
  81. package/docs/web/plugin-setup.md +7 -0
  82. package/docs/web/plugin-workflow.md +24 -0
  83. package/docs/web/plugins.md +17 -0
  84. package/docs/web/project-context.md +9 -0
  85. package/docs/web/rules.md +8 -0
  86. package/docs/web/run-anywhere.md +9 -0
  87. package/docs/web/self-improve.md +8 -0
  88. package/docs/web/setup-choices.md +39 -0
  89. package/docs/web/skills.md +8 -0
  90. package/docs/web/themes-author.md +8 -0
  91. package/docs/web/themes.md +9 -0
  92. package/docs/web/token-footprint.md +7 -0
  93. package/docs/web/trouble.md +38 -0
  94. package/docs/web/upgrade.md +9 -0
  95. package/docs/web/verify.md +30 -0
  96. package/docs/web/wiki.md +7 -0
  97. package/docs/web/worktree.md +10 -0
  98. package/docs/web-ui.md +144 -0
  99. package/docs/wiki.md +43 -0
  100. package/geneseed +39 -0
  101. package/geneseed.cmd +34 -0
  102. package/harness.config.json +6 -0
  103. package/js/anim.mjs +326 -0
  104. package/js/catalog.mjs +112 -0
  105. package/js/checkout.mjs +88 -0
  106. package/js/cli-table.json +1034 -0
  107. package/js/cli.mjs +498 -0
  108. package/js/diff.mjs +384 -0
  109. package/js/doctor.mjs +1364 -0
  110. package/js/emit.mjs +1373 -0
  111. package/js/excludes.mjs +271 -0
  112. package/js/generate.mjs +379 -0
  113. package/js/hooks.mjs +1026 -0
  114. package/js/hosts.mjs +253 -0
  115. package/js/installs.mjs +378 -0
  116. package/js/inventory.mjs +175 -0
  117. package/js/lib/fs.mjs +913 -0
  118. package/js/lib/proc.mjs +28 -0
  119. package/js/lib/udiff.mjs +275 -0
  120. package/js/link.mjs +286 -0
  121. package/js/mcp.mjs +335 -0
  122. package/js/memory.mjs +108 -0
  123. package/js/menu.mjs +118 -0
  124. package/js/migrate.mjs +251 -0
  125. package/js/native.mjs +426 -0
  126. package/js/opencode.mjs +325 -0
  127. package/js/registry.mjs +110 -0
  128. package/js/render.mjs +282 -0
  129. package/js/settings.mjs +924 -0
  130. package/js/setup.mjs +492 -0
  131. package/js/status.mjs +281 -0
  132. package/js/themes.mjs +195 -0
  133. package/js/tui.mjs +444 -0
  134. package/js/uninstall.mjs +1137 -0
  135. package/js/update.mjs +910 -0
  136. package/js/web/actions.mjs +868 -0
  137. package/js/web/activity.mjs +284 -0
  138. package/js/web/api.mjs +873 -0
  139. package/js/web/docs.mjs +531 -0
  140. package/js/web/graph.mjs +0 -0
  141. package/js/web/jobs.mjs +480 -0
  142. package/js/web/server.mjs +1135 -0
  143. package/package.json +54 -0
  144. package/registry.json +474 -0
  145. package/src/AGENT.md.tmpl +390 -0
  146. package/src/agents/_template.md +76 -0
  147. package/src/agents/advocate.md +36 -0
  148. package/src/agents/architect.md +55 -0
  149. package/src/agents/developer.md +66 -0
  150. package/src/agents/docs.md +54 -0
  151. package/src/agents/empiricist.md +39 -0
  152. package/src/agents/explorer.md +54 -0
  153. package/src/agents/framer.md +36 -0
  154. package/src/agents/historian.md +51 -0
  155. package/src/agents/operator.md +37 -0
  156. package/src/agents/pragmatist.md +37 -0
  157. package/src/agents/reviewer.md +52 -0
  158. package/src/agents/security.md +57 -0
  159. package/src/agents/skeptic.md +48 -0
  160. package/src/agents/steward.md +36 -0
  161. package/src/agents/tester.md +54 -0
  162. package/src/agents/user-advocate.md +36 -0
  163. package/src/agents/visionary.md +36 -0
  164. package/src/laws/universal.md +387 -0
  165. package/src/memory/README.md +109 -0
  166. package/src/memory/gitignore +6 -0
  167. package/src/modes/README.md +26 -0
  168. package/src/modes/direct.md +2 -0
  169. package/src/modes/foreman.md +19 -0
  170. package/src/notebook/README.md +39 -0
  171. package/src/notebook/gitignore +7 -0
  172. package/src/postures/README.md +31 -0
  173. package/src/postures/artisan.md +14 -0
  174. package/src/postures/assistant.md +12 -0
  175. package/src/postures/expert.md +11 -0
  176. package/src/postures/mentor.md +11 -0
  177. package/src/postures/peer.md +12 -0
  178. package/src/skills/_template.md +47 -0
  179. package/src/skills/brainstorm.md +28 -0
  180. package/src/skills/clarify.md +28 -0
  181. package/src/skills/codebase-design.md +44 -0
  182. package/src/skills/commit.md +48 -0
  183. package/src/skills/council.md +34 -0
  184. package/src/skills/crash-course.md +28 -0
  185. package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
  186. package/src/skills/daydream/SKILL.md +74 -0
  187. package/src/skills/daydream/VENDOR.md +25 -0
  188. package/src/skills/daydream/critic-prompt.md +38 -0
  189. package/src/skills/daydream/instructions.md +422 -0
  190. package/src/skills/daydream/synthesizer-prompt.md +41 -0
  191. package/src/skills/debug.md +37 -0
  192. package/src/skills/decode.md +28 -0
  193. package/src/skills/develop.md +29 -0
  194. package/src/skills/document-project.md +79 -0
  195. package/src/skills/domain-modeling.md +38 -0
  196. package/src/skills/drill.md +28 -0
  197. package/src/skills/feynman.md +26 -0
  198. package/src/skills/forge-mcp.md +48 -0
  199. package/src/skills/fresh-eyes.md +29 -0
  200. package/src/skills/frontend-design.md +58 -0
  201. package/src/skills/gap-detector.md +27 -0
  202. package/src/skills/geneseed-code-review.md +33 -0
  203. package/src/skills/geneseed.md +29 -0
  204. package/src/skills/git-archaeology.md +47 -0
  205. package/src/skills/git-rescue.md +68 -0
  206. package/src/skills/handoff.md +29 -0
  207. package/src/skills/herdr.md +164 -0
  208. package/src/skills/ingest.md +49 -0
  209. package/src/skills/learning-path.md +26 -0
  210. package/src/skills/migrate.md +45 -0
  211. package/src/skills/opencode-theme.md +52 -0
  212. package/src/skills/parallel-agents.md +28 -0
  213. package/src/skills/pipeline.md +52 -0
  214. package/src/skills/plan.md +45 -0
  215. package/src/skills/ponytail.md +29 -0
  216. package/src/skills/profile.md +58 -0
  217. package/src/skills/prose.md +42 -0
  218. package/src/skills/prototype.md +44 -0
  219. package/src/skills/react-view-transitions/AGENTS.md +955 -0
  220. package/src/skills/react-view-transitions/README.md +42 -0
  221. package/src/skills/react-view-transitions/SKILL.md +320 -0
  222. package/src/skills/react-view-transitions/VENDOR.md +16 -0
  223. package/src/skills/react-view-transitions/metadata.json +12 -0
  224. package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
  225. package/src/skills/react-view-transitions/references/implementation.md +182 -0
  226. package/src/skills/react-view-transitions/references/nextjs.md +176 -0
  227. package/src/skills/react-view-transitions/references/patterns.md +262 -0
  228. package/src/skills/refactor.md +32 -0
  229. package/src/skills/release.md +43 -0
  230. package/src/skills/repo-map.md +36 -0
  231. package/src/skills/research.md +42 -0
  232. package/src/skills/review-response.md +36 -0
  233. package/src/skills/roast-me.md +28 -0
  234. package/src/skills/rule.md +100 -0
  235. package/src/skills/ship.md +49 -0
  236. package/src/skills/tdd.md +31 -0
  237. package/src/skills/tickets.md +44 -0
  238. package/src/skills/token-report/SKILL.md +76 -0
  239. package/src/skills/token-report/VENDOR.md +23 -0
  240. package/src/skills/token-report/scripts/token_report.mjs +1301 -0
  241. package/src/skills/wayfinder.md +49 -0
  242. package/src/skills/wiki.md +68 -0
  243. package/src/skills/workflow.md +38 -0
  244. package/themes/README.md +69 -0
  245. package/themes/_TEMPLATE.json +147 -0
  246. package/themes/biker.json +147 -0
  247. package/themes/commentator.json +147 -0
  248. package/themes/cyberpunk.json +147 -0
  249. package/themes/gamer.json +147 -0
  250. package/themes/imperial.json +147 -0
  251. package/themes/joker.json +147 -0
  252. package/themes/marvin.json +147 -0
  253. package/themes/mean.json +147 -0
  254. package/themes/military.json +147 -0
  255. package/themes/neutral.json +147 -0
  256. package/themes/opencode/README.md +58 -0
  257. package/themes/opencode/catppuccin.json +27 -0
  258. package/themes/opencode/dracula.json +27 -0
  259. package/themes/opencode/everforest.json +27 -0
  260. package/themes/opencode/gruvbox.json +27 -0
  261. package/themes/opencode/kanagawa.json +27 -0
  262. package/themes/opencode/neon.json +27 -0
  263. package/themes/opencode/nord.json +27 -0
  264. package/themes/opencode/rosepine.json +27 -0
  265. package/themes/opencode/tokyonight.json +27 -0
  266. package/themes/pirate.json +147 -0
  267. package/themes/sports.json +147 -0
  268. package/themes/verstappen.json +147 -0
  269. package/themes/wizard.json +147 -0
  270. package/web/dist/assets/About-DX1-VTBP.js +1 -0
  271. package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
  272. package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
  273. package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
  274. package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
  275. package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
  276. package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
  277. package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
  278. package/web/dist/assets/Library-BEZscyaQ.js +1 -0
  279. package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
  280. package/web/dist/assets/Profile-DydukTxP.js +1 -0
  281. package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
  282. package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
  283. package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
  284. package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
  285. package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
  286. package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
  287. package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
  288. package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
  289. package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
  290. package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
  291. package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
  292. package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
  293. package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
  294. package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
  295. package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
  296. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  297. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  298. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  299. package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
  300. package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
  301. package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
  302. package/web/dist/assets/index-B1PIx7RM.js +1 -0
  303. package/web/dist/assets/index-BnTQUSUz.js +12 -0
  304. package/web/dist/assets/index-Ck2iCdaC.js +2 -0
  305. package/web/dist/assets/index-DUCsDv7z.css +1 -0
  306. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  307. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  308. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  309. package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  310. package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  311. package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  312. package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  313. package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
  314. package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
  315. package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
  316. package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
  317. package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
  318. package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
  319. package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
  320. package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
  321. package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
  322. package/web/dist/assets/react-DOpiTn5v.js +32 -0
  323. package/web/dist/assets/roman-DkYN0LX9.js +1 -0
  324. package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
  325. package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
  326. package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
  327. package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
  328. package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
  329. package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
  330. package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
  331. package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
  332. package/web/dist/icon-maskable.svg +10 -0
  333. package/web/dist/icon.svg +8 -0
  334. package/web/dist/index.html +21 -0
  335. package/web/dist/manifest.webmanifest +14 -0
  336. package/web/dist/sw.js +40 -0
  337. package/web/src/pages/Laws.jsx +230 -0
@@ -0,0 +1,1135 @@
1
+ /**
2
+ * The web console's HTTP shell — `rituals/_web_server.py`'s handler, static route and
3
+ * `serve()` entry. P6a: the shell and `/api/ping`, and nothing that answers a real
4
+ * endpoint.
5
+ *
6
+ * WHY THE SHELL FIRST, AND ALONE. `_web_server.py` is 654 lines and none of it is an API
7
+ * function: it is routing, two security guards, gzip negotiation, the static route with
8
+ * its per-request token injection, and the daemon record. Every one of those is invisible
9
+ * to a test that calls `api_X(state)` in process — which is how `tests/test_web.py`
10
+ * reaches all 136 of its assertions — so the shell is both the part with no existing
11
+ * coverage and the part every later sub-phase builds on. `tests/web_golden.py` is its
12
+ * gate and was written before this file.
13
+ *
14
+ * THE SERIALISER, WHICH TOUCHES ALL 29 PATHS. `_send_json` is a bare `json.dumps(obj)`:
15
+ * Python's DEFAULT separators, `(', ', ': ')`, and `ensure_ascii=True`. That is exactly
16
+ * `jsonDumpsCompact` — whose name says "compact" meaning "no indent", not "no spaces";
17
+ * read its body, it joins on `', '`. So the twin needs no new helper and the response
18
+ * bodies stay byte-comparable.
19
+ *
20
+ * Its one condition is the int/float distinction. `pyStr` refuses a BARE JS number
21
+ * because `json.loads` tells `20` from `1.0` and `JSON.parse` does not — but the numbers
22
+ * in a response body are not parsed from JSON, they are computed here (counts, a port, a
23
+ * pid, a unix second), and every one of them is a Python `int`, which renders identically
24
+ * in both languages. `bareInts` says that out loud rather than wrapping several dozen
25
+ * call sites in a factory. A Python float would be the counter-example — `1.0` against
26
+ * JS's `1` — and the byte gate over every endpoint body is what would catch one.
27
+ *
28
+ * P6h FINISHED IT, and the file grew a second half rather than a second module. The
29
+ * reference keeps the handler and the daemon lifecycle in ONE file under a
30
+ * `# ---- daemon mode` banner, and splitting them here would have made `serve()` (which
31
+ * needs `npmBuild`) and the launcher (which needs `readDaemon`) import each other. So
32
+ * `_probe`, `_live_daemon`, `_spawn_detached`, `start|stop|status|restart_daemon`,
33
+ * `_npm_build` and `cmd_web` all live below, and `bin/geneseed-cli.mjs` is finally a
34
+ * caller — which is what put this tree on the CLI's import graph and moved
35
+ * `js/web/jobs.mjs`'s spawn row to `entry: "cli"`.
36
+ *
37
+ * WHAT IS STILL NOT HERE, SINCE P6i: `/api/pick-folder` alone, and it is DECLINED rather than
38
+ * deferred — an OS-native folder chooser has no Node twin that is not a new GUI dependency.
39
+ * `NOT_PORTED_POST` is empty and stays declared, because an empty half of a partition is the
40
+ * partition asserting there is nothing left; `DECLINED_POST` is what still has a member.
41
+ */
42
+ import { spawn, spawnSync } from 'node:child_process';
43
+ import { randomBytes } from 'node:crypto';
44
+ import {
45
+ existsSync, readFileSync, statSync, unlinkSync, chmodSync, mkdirSync, openSync,
46
+ } from 'node:fs';
47
+ import { createServer, request as httpRequest } from 'node:http';
48
+ import { join, resolve as pathResolve, extname, basename, sep } from 'node:path';
49
+ import { fileURLToPath } from 'node:url';
50
+ import { gzipSync } from 'node:zlib';
51
+
52
+ import { GLOBAL_MANIFEST, pyResolve } from '../hosts.mjs';
53
+ import {
54
+ jsonDumpsCompact, parseJson, pyInt, pyPrint, pyStr, pyTruthy, pyUnquote, pyWhich, writeText,
55
+ } from '../lib/fs.mjs';
56
+ import { promptLine } from '../setup.mjs';
57
+ // P8c. The reference imports `_update` LAZILY inside the handler, to keep `build` and its
58
+ // `sys.path` side effect out of web-import time; ESM has no such side effect and the import is
59
+ // static. `js/update.mjs` imports `restartDaemon` from THIS module, so the two form a cycle —
60
+ // which resolves because everything either side needs of the other is a hoisted function
61
+ // declaration, not a value read at module-evaluation time.
62
+ import { preflight } from '../update.mjs';
63
+ import { NotFound, PREFIX_ROUTES, STATE_ROUTES, webState } from './api.mjs';
64
+ import {
65
+ apiDeployCmd, apiExcludesMutate, apiInstallCmd, apiInstallToggle, apiMcpToggle,
66
+ apiMemoryDelete, apiProfileSave,
67
+ apiRestore, apiRulesMutate, apiRulesPromote, apiSelectView, buildOverride,
68
+ } from './actions.mjs';
69
+ import { apiActivityToggle } from './activity.mjs';
70
+ import { apiDocs, apiDocsPage } from './docs.mjs';
71
+ import { JobManager, NOT_PORTED_ACTIONS, actionCommands } from './jobs.mjs';
72
+
73
+ export { webState };
74
+
75
+ const ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
76
+
77
+ /**
78
+ * DNS-rebinding guard. The reference's own comment carries the why; the shape is the
79
+ * part a port gets wrong. `h.split(":", 1)[0]` keeps everything before the FIRST colon,
80
+ * so `localhost:4747` passes and a bare IPv6 literal would not — which is what the
81
+ * `[::1]` prefix test above it is for.
82
+ */
83
+ export function localHost(host) {
84
+ const h = (host || '').trim().toLowerCase();
85
+ if (h.startsWith('[::1]')) return true;
86
+ return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
87
+ }
88
+
89
+ const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
90
+ 'application/manifest+json', 'application/javascript'];
91
+ const GZIP_MIN = 1024;
92
+
93
+ const CTYPES = {
94
+ '.html': 'text/html', '.js': 'text/javascript',
95
+ '.css': 'text/css', '.json': 'application/json',
96
+ '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
97
+ '.woff2': 'font/woff2', '.woff': 'font/woff',
98
+ '.webmanifest': 'application/manifest+json',
99
+ '.png': 'image/png',
100
+ };
101
+
102
+ /**
103
+ * The API paths this phase does not answer yet.
104
+ *
105
+ * Without it every one of them would fall through to `serveStatic`, which answers ANY
106
+ * unknown path with index.html and a 200 — the SPA fallback. An unported endpoint would
107
+ * therefore return a plausible HTML page instead of failing, and the first cell written
108
+ * against it in P6b would be the thing that discovered the route was missing. A 501
109
+ * naming the phase costs one lookup and cannot be mistaken for a working endpoint.
110
+ *
111
+ * It shrank to empty in P6g and the DECLARATION STAYS. Every GET the reference answers is now
112
+ * answered here, so the two sets hold nothing — but they are half of a partition
113
+ * (`test_every_get_route_is_either_ported_or_declared_unported`), and deleting them would let
114
+ * the next GET route added to the reference fall through to the SPA with nothing to say so.
115
+ * An empty declaration is the partition asserting that there is nothing left to declare.
116
+ */
117
+ /**
118
+ * SPLIT BY VERB SINCE P6b, and the split is the point rather than a tidy-up. Five paths
119
+ * answer BOTH verbs with different bodies — `/api/mcp`, `/api/excludes`, `/api/rules`,
120
+ * `/api/profile`, `/api/activity` — and P6b ports the GET half of two of them. One set
121
+ * keyed on path alone would have taken `/api/excludes` out of the unported list the
122
+ * moment its GET landed, and its POST would then have answered `{"error": "not found"}`
123
+ * with a 404: a plausible-looking response where the reference returns 200 or 409, which
124
+ * is exactly the failure the 501 exists to prevent.
125
+ */
126
+ /** EMPTY SINCE P6g — every GET the reference answers is answered here. */
127
+ export const NOT_PORTED = new Set([]);
128
+ export const NOT_PORTED_PREFIXES = [];
129
+
130
+ /**
131
+ * The POSTs that have not crossed YET — and EMPTY SINCE P6i, which took `/api/install`.
132
+ *
133
+ * IT STAYS DECLARED, for the same reason the GET pair above stays and the prefix lists have
134
+ * stayed since P6g: this set is half of a partition
135
+ * (`test_every_post_route_is_either_ported_or_declared_unported`), so an empty set is the
136
+ * partition ASSERTING that there is nothing left to declare. Delete it and the next POST route
137
+ * added to the reference falls through to `{"error": "not found"}` at 404 — a plausible-looking
138
+ * answer where the reference returns 200 or 409 — with nothing to say so.
139
+ *
140
+ * THE DISPATCHER PROBE NEEDS A TARGET, which an empty set no longer supplies.
141
+ * `tests/test_web_server.py` retired its `unportedGet` probe when `NOT_PORTED` emptied in P6g;
142
+ * `unportedPost` is retired here for the same reason, and `declinedPost` (`/api/pick-folder`,
143
+ * which never crosses) is what keeps the 501 arm of the dispatcher under a live probe.
144
+ */
145
+ export const NOT_PORTED_POST = new Set([]);
146
+ export const NOT_PORTED_POST_PREFIXES = [];
147
+
148
+ /**
149
+ * The POSTs that will NEVER cross, which is a different claim from the set above.
150
+ *
151
+ * `/api/pick-folder` opens an OS-NATIVE folder chooser on the daemon host — `osascript` on
152
+ * macOS, a one-shot `tkinter` subprocess elsewhere. There is no Node twin that is not a new
153
+ * GUI dependency, and reaching for one would also be a sixth `_ALLOWED_SPAWNS` row for a
154
+ * modal dialog. The UI already falls back to its editable path field when this endpoint
155
+ * fails, which is what a 501 gives it.
156
+ *
157
+ * SEPARATE FROM `NOT_PORTED_POST` ON PURPOSE. That set is a to-do list every later phase
158
+ * shrinks, and folding a permanent decline into it would make the list wrong in the one
159
+ * direction nobody checks: a phase that emptied it would have to either port a GUI dialog
160
+ * or quietly delete a declaration. Both sets are unioned into the partition the reference's
161
+ * routes are cross-checked against, so neither can drift.
162
+ */
163
+ export const DECLINED_POST = new Set([
164
+ '/api/pick-folder',
165
+ ]);
166
+
167
+ /**
168
+ * The POSTs P6f answers — and this table IS the dispatch, not a declaration beside it.
169
+ *
170
+ * M23's lesson, applied one verb over: `test_the_declared_partition_is_the_one_the_dispatcher_uses`
171
+ * exists because a hand-kept list can agree with the reference while the running handler
172
+ * does something else. `doPost` looks routes up HERE, and `PORTED_POST` is this table's own
173
+ * keys, so the two cannot drift — the same reason `STATE_ROUTES` is a table on the GET side.
174
+ *
175
+ * THE SECOND COLUMN IS THE 409 CONVENTION, and it is a column because it is not uniform.
176
+ * `ok: false` means the write did not happen — a stale fingerprint, a body that never
177
+ * parsed, nothing to remove — and the client must re-fetch rather than retry, which is what
178
+ * 409 says and 200 does not. Five routes map it. `/api/activity` does NOT: its flag write
179
+ * answers 200 whatever `ok` says, and a port that unified the rule would be wrong exactly
180
+ * there. `/api/memory/delete` carries no `ok` at all — it deletes, or it raises `NotFound`
181
+ * and the outer catch answers 404 — so it is `false` here for a third reason again.
182
+ *
183
+ * `NotFound` IS NOT CAUGHT PER ROUTE. The reference wraps six of these calls in their own
184
+ * `except NotFound` AND wraps the whole of `_post_routes` in another inside `do_POST`. The
185
+ * inner six are redundant — every one produces the same `{"error": "not found: …"}` at 404
186
+ * — so the twin keeps the outer one only, in `handler`, where `do_POST`'s is.
187
+ */
188
+ const POST_ROUTES = new Map([
189
+ ['/api/mcp', [apiMcpToggle, true]],
190
+ // P6i. `_post_routes` wraps this one in its own `except NotFound` and then maps
191
+ // `ok: false` to 409 — the same shape as `/api/mcp` beside it, so it fits the table's
192
+ // second column exactly and needs no third inline dispatch. The inner catch is redundant
193
+ // (see this table's docblock); `handler`'s outer one answers identically.
194
+ ['/api/install', [apiInstallToggle, true]],
195
+ ['/api/excludes', [apiExcludesMutate, true]],
196
+ ['/api/view', [apiSelectView, true]],
197
+ ['/api/activity', [apiActivityToggle, false]],
198
+ // `(self._read_json_body().get("name") or "")` — the SHELL resolves the name, and the
199
+ // endpoint takes a bare slug rather than a body.
200
+ ['/api/memory/delete',
201
+ [(state, b) => apiMemoryDelete(state, pyTruthy(b.name) ? pyStr(b.name) : ''), false]],
202
+ ['/api/rules', [apiRulesMutate, true]],
203
+ ['/api/rules/promote', [apiRulesPromote, true]],
204
+ ['/api/profile', [apiProfileSave, true]],
205
+ ]);
206
+
207
+ export const PORTED_POST = [...POST_ROUTES.keys()];
208
+
209
+ /**
210
+ * The 409 column, exported so it can be cross-checked against the reference's own `200 if
211
+ * res.get("ok") else 409` conditionals by `ast`.
212
+ *
213
+ * A MUTATION IS WHY THIS EXISTS. Giving `/api/activity` the 409 treatment survived the whole
214
+ * cell matrix: every toggle a cell can perform succeeds, and reaching the `ok: false` arm
215
+ * needs the flag write to raise — which the two runtimes word differently, so no byte
216
+ * comparison could hold it. The column is the thing under test and no cell can see it, so
217
+ * the gate reads it out of both implementations instead.
218
+ */
219
+ export const POST_ROUTES_CONVENTION = Object.fromEntries(
220
+ [...POST_ROUTES].map(([p, [, okIs409]]) => [p, okIs409]),
221
+ );
222
+
223
+ /**
224
+ * The GET paths the dispatcher answers OUTSIDE the two tables, declared so the partition
225
+ * cross-check can see them: `/api/ping` is the shell's own, and the two Docs routes take
226
+ * the `?harness=` query param, which a `(state)` table entry has no way to receive.
227
+ *
228
+ * A declaration is not a dispatcher — M23 — so
229
+ * `test_the_declared_partition_is_the_one_the_dispatcher_uses` probes all three against
230
+ * the running handler as well.
231
+ */
232
+ export const PORTED_INLINE = ['/api/ping', '/api/docs', '/api/docs/page/',
233
+ '/api/jobs', '/api/jobs/'];
234
+
235
+ /**
236
+ * The POSTs P6g answers outside `POST_ROUTES`, and they are outside it for a reason the
237
+ * table's SECOND COLUMN makes plain.
238
+ *
239
+ * `POST_ROUTES` maps a route to `(fn, okIs409)` — one handler taking `(state, body)`, one
240
+ * boolean deciding 200 against 409. The jobs routes fit neither half. `/api/jobs/<id>/cancel`
241
+ * answers 200 or 404 on a lookup, not on an `ok` field; `/api/actions/<x>` answers 202 with a
242
+ * job id, 409 when the runner is busy, 404 for an action the table does not name, 501 for one
243
+ * that has not crossed, 400 or 409 for a resolver's refusal, and 200 for `restore` — six
244
+ * statuses over one prefix, none of them read off `ok`. Bending the column to fit would make
245
+ * it lie about the five routes it currently describes exactly, so these dispatch beside the
246
+ * table and `tests/test_web_jobs.py` reads them out of `PORTED_POST_INLINE` instead.
247
+ */
248
+ export const PORTED_POST_INLINE = ['/api/shutdown', '/api/restart', '/api/jobs/',
249
+ '/api/actions/'];
250
+
251
+ function notPorted(path) {
252
+ return NOT_PORTED.has(path) || NOT_PORTED_PREFIXES.some((p) => path.startsWith(p));
253
+ }
254
+
255
+ function notPortedPost(path) {
256
+ return NOT_PORTED_POST.has(path) || DECLINED_POST.has(path)
257
+ || NOT_PORTED_POST_PREFIXES.some((p) => path.startsWith(p));
258
+ }
259
+
260
+ /**
261
+ * `Handler._read_json_body` — the drained body as a dict, `{}` for ANYTHING else.
262
+ *
263
+ * P6a retained the drain with a note that it was measured indistinguishable and stayed
264
+ * because "`_read_json_body` needs the bytes from P6f on". This is that caller.
265
+ *
266
+ * `{}` AND NEVER A RAISE is the contract, and it is what makes a malformed body a 409 or a
267
+ * 404 from the endpoint's own validation rather than a 500 from a parser. That matters
268
+ * beyond tidiness: P5a's one un-portable line is a `json` error message, because CPython
269
+ * and V8 word them differently AND report different offsets — so a 500 carrying a parse
270
+ * error is a response the two implementations can never agree on.
271
+ *
272
+ * `parseJson`, not `JSON.parse`: a body value can be written straight into a user's file
273
+ * (`api_profile_save`'s `text`, `api_rules_mutate`'s `title`) and `str(1.0)` is `"1.0"`
274
+ * where `String(1)` is `"1"`. A non-object body — a bare list, a number, `null` — takes the
275
+ * same `{}` arm as garbage, because `isinstance(obj, dict)` is what the reference asks.
276
+ */
277
+ function readJsonBody(buf) {
278
+ let obj;
279
+ try {
280
+ obj = parseJson((buf && buf.length ? buf : Buffer.from('{}')).toString('utf-8'));
281
+ } catch {
282
+ return {};
283
+ }
284
+ return (obj !== null && typeof obj === 'object' && obj.constructor === Object) ? obj : {};
285
+ }
286
+
287
+ // ---- the request handler ---------------------------------------------------
288
+
289
+ /**
290
+ * `make_handler(state, jm, token, dist, holder)` — `jm` is the SECOND argument here as it is
291
+ * there. P6a shipped this without it because there was no `JobManager` to pass; every caller
292
+ * moved in the same commit that added one.
293
+ */
294
+ export function makeHandler(state, jm, token, dist, holder = null) {
295
+ const staticCache = new Map();
296
+
297
+ function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '') {
298
+ const headers = { ...(extra || {}) };
299
+ if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
300
+ && (acceptEncoding || '').includes('gzip')) {
301
+ body = gzipSync(body, { level: 6 });
302
+ headers['Content-Encoding'] = 'gzip';
303
+ headers.Vary = 'Accept-Encoding';
304
+ }
305
+ res.writeHead(code, {
306
+ 'Content-Type': ctype,
307
+ 'Content-Length': String(body.length),
308
+ ...headers,
309
+ });
310
+ res.end(body);
311
+ }
312
+
313
+ function sendJson(res, obj, code = 200, acceptEncoding = '') {
314
+ sendBytes(res, Buffer.from(jsonDumpsCompact(obj, { bareInts: true }), 'utf-8'),
315
+ 'application/json', code, null, acceptEncoding);
316
+ }
317
+
318
+ function serveStatic(res, path, acceptEncoding) {
319
+ const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
320
+ const index = pyResolve(join(dist, 'index.html'));
321
+ let fp = pyResolve(join(dist, rel));
322
+ // `dist not in fp.parents`: STRICTLY under dist. `Path.parents` never contains the
323
+ // path itself, so `fp == dist` takes the fallback too.
324
+ if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
325
+ if (!isFile(fp)) fp = join(dist, 'index.html');
326
+ if (!isFile(fp)) {
327
+ return sendJson(res, { error: 'web/dist missing — run the UI build' }, 500, acceptEncoding);
328
+ }
329
+ let data = staticCache.get(fp);
330
+ if (data === undefined) {
331
+ data = readFileSync(fp);
332
+ staticCache.set(fp, data);
333
+ }
334
+ let extra = null;
335
+ if (basename(fp) === 'index.html') {
336
+ const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
337
+ data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
338
+ extra = { 'Cache-Control': 'no-store' };
339
+ } else if (path.includes('/assets/')) {
340
+ extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
341
+ }
342
+ return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
343
+ 200, extra, acceptEncoding);
344
+ }
345
+
346
+ function doGet(req, res, path, ae) {
347
+ // The reference's own table first, then the routes that parse the path — and `ping`
348
+ // sits between them there too, so the order here is its order.
349
+ const route = STATE_ROUTES[path];
350
+ if (route !== undefined) return sendJson(res, route(state), 200, ae);
351
+ if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
352
+ for (const [prefix, handler] of PREFIX_ROUTES) {
353
+ if (path.startsWith(prefix)) return sendJson(res, handler(state, path), 200, ae);
354
+ }
355
+ if (path === '/api/docs') {
356
+ return sendJson(res, apiDocs(state, harnessParam(req.url)), 200, ae);
357
+ }
358
+ if (path.startsWith('/api/docs/page/')) {
359
+ const pid = pyUnquote(path.slice('/api/docs/page/'.length));
360
+ try {
361
+ return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
362
+ } catch (e) {
363
+ // A kind P6d does not answer says so, rather than falling through to the
364
+ // `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
365
+ if (e && e.notPorted) return sendJson(res, { error: e.message }, 501, ae);
366
+ throw e;
367
+ }
368
+ }
369
+ if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
370
+ if (path.startsWith('/api/jobs/')) {
371
+ // `path.rsplit("/", 1)[1]` — the LAST segment, so `/api/jobs/a/b` looks up `b` and
372
+ // misses, rather than 404ing on the shape.
373
+ const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
374
+ return j ? sendJson(res, j, 200, ae)
375
+ : sendJson(res, { error: 'no such job' }, 404, ae);
376
+ }
377
+ if (notPorted(path)) {
378
+ return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
379
+ }
380
+ return serveStatic(res, path, ae);
381
+ }
382
+
383
+ function doPost(req, res, path, ae, body) {
384
+ if (!localHost(req.headers.host)) {
385
+ return sendJson(res, { error: 'forbidden host' }, 403, ae);
386
+ }
387
+ if (req.headers['x-geneseed-token'] !== token) {
388
+ return sendJson(res, { error: 'forbidden' }, 403, ae);
389
+ }
390
+ if (path === '/api/shutdown') {
391
+ // The one POST route the SHELL owns. It must answer BEFORE it stops, which is why
392
+ // the reference runs `srv.shutdown()` on its own thread — called inline it would
393
+ // deadlock against `serve_forever`. Here the equivalent is closing after the
394
+ // response has flushed. `closeAllConnections` is the other half: `close()` alone
395
+ // waits for idle keep-alive sockets, and the client that just sent this request is
396
+ // holding one open. The reference drops them by exiting.
397
+ res.on('finish', () => {
398
+ holder.srv.close();
399
+ holder.srv.closeAllConnections();
400
+ });
401
+ return sendJson(res, { stopping: true }, 200, ae);
402
+ }
403
+ if (path === '/api/restart') {
404
+ // THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
405
+ // `requestRestart` spawns a DETACHED `web restart`, which stops whatever daemon the
406
+ // record names and starts a fresh one that outlives the caller. Every way of asking it
407
+ // a question runs it: `tests/web_golden.py` would have its own server stopped and a
408
+ // replacement orphaned into a sandbox about to be deleted, and
409
+ // `tests/test_web_server.py`'s dispatcher probe runs in the DEVELOPER'S environment,
410
+ // where the replacement would bind 4747 and serve the checkout forever. That is the
411
+ // same reasoning P6g used to ban a job cell that starts real work, one step further:
412
+ // there the child was merely orphaned, here it is a second daemon.
413
+ //
414
+ // So the gate is `tests/test_web_daemon.py`, which reads both implementations and
415
+ // asserts they dispatch this path to `request_restart(state.theme)` and answer the
416
+ // same body. Rule 7 says a declaration is not a dispatcher — this is the one route
417
+ // where probing the dispatcher costs more than the assurance is worth, and saying so
418
+ // beats a silently missing probe.
419
+ requestRestart(state.theme);
420
+ return sendJson(res, { restarting: true }, 200, ae);
421
+ }
422
+ const route = POST_ROUTES.get(path);
423
+ if (route !== undefined) {
424
+ const [fn, okIs409] = route;
425
+ const obj = fn(state, readJsonBody(body));
426
+ return sendJson(res, obj, (okIs409 && !pyTruthy(obj.ok)) ? 409 : 200, ae);
427
+ }
428
+ if (path.startsWith('/api/jobs/') && path.endsWith('/cancel')) {
429
+ // `path.split("/")[3]`, positional — so `/api/jobs/<id>/cancel` takes `<id>` and a
430
+ // deeper path takes its third segment, which is what the reference looks up.
431
+ const jid = path.split('/')[3];
432
+ return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
433
+ : sendJson(res, { error: 'no running job by that id' }, 404, ae);
434
+ }
435
+ if (path.startsWith('/api/actions/')) {
436
+ return doAction(res, path.slice(path.lastIndexOf('/') + 1), readJsonBody(body), ae);
437
+ }
438
+ if (notPortedPost(path)) {
439
+ return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
440
+ }
441
+ return sendJson(res, { error: 'not found' }, 404, ae);
442
+ }
443
+
444
+ /**
445
+ * `/api/actions/<x>` — the reference's own order, which is the part a port gets wrong.
446
+ *
447
+ * `restore` is answered BEFORE anything else because it is synchronous and returns a result
448
+ * rather than a job id. `install` and `deploy` resolve their argv from the body, and their
449
+ * refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
450
+ * means the world changed under the client) and 400 for deploy (its path came from the body,
451
+ * so a refusal means the body was wrong). That asymmetry is the reference's; a port that
452
+ * unified them would be wrong on one of the two and no cell would say which.
453
+ *
454
+ * `update` COMES NEXT AND IS INLINE, since P8c: it is gated on a LOCAL-ONLY preflight before
455
+ * a job is ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI
456
+ * renders as an info popup instead of a job that spawns and then fails. Its finish handler is
457
+ * its own too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run`), so
458
+ * the restart happens HERE, after the job is saved as finished, and only when it succeeded: a
459
+ * failed update changed nothing worth reloading and bouncing would disconnect the PWA for no
460
+ * reason.
461
+ *
462
+ * THEN the unported rows, and only then the table. Order matters: `link`/`unlink` are REAL
463
+ * actions whose verb has not crossed, and letting them fall through to `actionCommands` would
464
+ * answer `{"error": "unknown action link"}` — the same thing a typo gets.
465
+ */
466
+ function doAction(res, action, body, ae) {
467
+ if (action === 'restore') {
468
+ return sendJson(res, apiRestore(state, pyTruthy(body.files) ? body.files : []), 200, ae);
469
+ }
470
+ if (action === 'install') {
471
+ const plan = apiInstallCmd(state, body);
472
+ if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 409, ae);
473
+ return startJob(res, 'install', [plan.cmd], ae);
474
+ }
475
+ if (action === 'deploy') {
476
+ const plan = apiDeployCmd(state, body);
477
+ if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 400, ae);
478
+ return startJob(res, 'deploy', [plan.cmd], ae);
479
+ }
480
+ if (action === 'update') {
481
+ const pre = preflight();
482
+ if (!pre.ok) {
483
+ return sendJson(res,
484
+ { precondition: pre.code, kind: pre.kind, message: pre.message }, 422, ae);
485
+ }
486
+ const jid = jm.start('update', actionCommands('update'), (rc) => {
487
+ state.refresh();
488
+ if (rc === 0) requestRestart(state.theme);
489
+ });
490
+ if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
491
+ return sendJson(res, { job_id: jid }, 202, ae);
492
+ }
493
+ if (NOT_PORTED_ACTIONS.has(action)) {
494
+ return sendJson(res, { error: `not ported yet: /api/actions/${action}` }, 501, ae);
495
+ }
496
+ // Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
497
+ // the deployed theme downstream. Footprint, posture and mode always follow the current
498
+ // install, so a re-theme preserves lean/full, the register and the operating mode.
499
+ const [theme, emit] = action === 'build'
500
+ ? buildOverride(state, body) : [state.theme, state.emit];
501
+ const cmds = actionCommands(action, {
502
+ theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
503
+ });
504
+ if (!pyTruthy(cmds)) {
505
+ return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
506
+ }
507
+ return startJob(res, action, cmds, ae);
508
+ }
509
+
510
+ /**
511
+ * The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
512
+ * Build may re-theme the install, and the re-detect must read the new `.geneseed-emit` or
513
+ * the console keeps serving the old mode (the line P6b dropped and P6f restored).
514
+ */
515
+ function startJob(res, action, cmds, ae) {
516
+ const jid = jm.start(action, cmds, () => state.refresh());
517
+ if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
518
+ return sendJson(res, { job_id: jid }, 202, ae);
519
+ }
520
+
521
+ return function handler(req, res) {
522
+ const ae = req.headers['accept-encoding'] || '';
523
+ const path = (req.url || '').split('?', 1)[0];
524
+ try {
525
+ if (req.method === 'GET') {
526
+ if (!localHost(req.headers.host)) {
527
+ return sendJson(res, { error: 'forbidden host' }, 403, ae);
528
+ }
529
+ try {
530
+ return doGet(req, res, path, ae);
531
+ } catch (e) {
532
+ // The `NotFound` → 404 convention, which P6c is the first phase to raise. It
533
+ // wraps the ROUTE DISPATCH and not each handler, exactly as the reference's
534
+ // `try` around `do_GET`'s body does — an endpoint several frames down raises and
535
+ // the shell decides the status. Anything else is still the outer 500.
536
+ if (e instanceof NotFound) {
537
+ return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
538
+ }
539
+ throw e;
540
+ }
541
+ }
542
+ if (req.method === 'POST') {
543
+ // Drain the body BEFORE routing, as the reference does and for its stated
544
+ // reason — under keep-alive an unread body is parsed as the next request line,
545
+ // and both guards answer without ever reaching a route that would read it.
546
+ //
547
+ // THIS HALF OF IT IS INDISTINGUISHABLE HERE, and measured rather than assumed:
548
+ // two mutations — reading zero bytes, then neither reading nor resuming the
549
+ // stream — both survived the keep-alive cell, which the reference needs the
550
+ // drain to pass. Node's HTTP parser owns the message boundary, so unread body
551
+ // bytes are discarded when the response ends instead of being re-parsed. It
552
+ // stays because `_read_json_body` needs the bytes from P6f on, and because a
553
+ // shell whose two implementations diverged only under load would be the worst
554
+ // kind of divergence to carry. UNREACHABLE is not the word for it: the branch
555
+ // runs on every POST, it simply cannot be observed to matter on this runtime.
556
+ const length = pyInt(String(req.headers['content-length'] ?? '')) ?? 0;
557
+ return readBody(req, length, (buf) => {
558
+ req._body = buf;
559
+ try {
560
+ return doPost(req, res, path, ae, buf);
561
+ } catch (e) {
562
+ // `do_POST`'s own two-armed catch, and P6f is the phase that needs the first
563
+ // arm: `api_mcp_toggle`, `api_select_view`, `api_memory_delete`,
564
+ // `api_rules_mutate` and `api_rules_promote` all raise `NotFound` for an
565
+ // unknown target, and the reference answers 404. The 500 arm below it is what
566
+ // carries `_rule_fields`' ValueError message, which is a 500 there too.
567
+ if (e instanceof NotFound) {
568
+ return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
569
+ }
570
+ return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
571
+ }
572
+ });
573
+ }
574
+ return sendJson(res, { error: 'not found' }, 404, ae);
575
+ } catch (e) {
576
+ return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
577
+ }
578
+ };
579
+ }
580
+
581
+ /**
582
+ * `Handler._harness` — the `?harness=` query param, `null` when absent.
583
+ *
584
+ * `urllib.parse.parse_qs` with its defaults, reproduced only as far as this one caller
585
+ * needs and no further: pairs split on `&` then `=`, values unquoted with `+` meaning a
586
+ * space, and a BLANK value dropped (`keep_blank_values=False`), so `?harness=` resolves to
587
+ * the installed default rather than to the empty string. The first occurrence wins,
588
+ * because `parse_qs` returns a list and the reference takes `[0]`.
589
+ *
590
+ * THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
591
+ * mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
592
+ * fall through to the installed default. It stays for the reason `parse_qs`' own default
593
+ * exists — the next parameter read through this function may be one whose empty string
594
+ * means something — and because a shell whose two implementations agreed only by accident
595
+ * is the kind of agreement that stops holding when a caller is added. UNREACHABLE is not
596
+ * the word: the branch runs on every `?harness=`, it simply cannot be observed to matter
597
+ * through the one consumer it has.
598
+ */
599
+ function harnessParam(url) {
600
+ const at = (url || '').indexOf('?');
601
+ if (at < 0) return null;
602
+ for (const pair of url.slice(at + 1).split('&')) {
603
+ if (!pair) continue;
604
+ const eq = pair.indexOf('=');
605
+ const k = pyUnquote((eq < 0 ? pair : pair.slice(0, eq)).replace(/\+/g, ' '));
606
+ const v = eq < 0 ? '' : pyUnquote(pair.slice(eq + 1).replace(/\+/g, ' '));
607
+ if (k === 'harness' && v !== '') return v;
608
+ }
609
+ return null;
610
+ }
611
+
612
+ function readBody(req, length, done) {
613
+ if (!(length > 0)) {
614
+ req.resume();
615
+ return done(Buffer.alloc(0));
616
+ }
617
+ const chunks = [];
618
+ let seen = 0;
619
+ req.on('data', (c) => {
620
+ // Exactly `Content-Length` bytes, as `rfile.read(length)` takes: a longer body is
621
+ // the next request's bytes and belongs to the parser, not to this handler.
622
+ if (seen >= length) return;
623
+ chunks.push(c.subarray(0, length - seen));
624
+ seen += c.length;
625
+ });
626
+ req.on('end', () => done(Buffer.concat(chunks)));
627
+ }
628
+
629
+ function isFile(p) {
630
+ try {
631
+ return statSync(p).isFile();
632
+ } catch {
633
+ return false;
634
+ }
635
+ }
636
+
637
+ /** `bytes.replace(old, new, 1)` — the first occurrence only. */
638
+ function replaceOnce(buf, find, repl) {
639
+ const at = buf.indexOf(find);
640
+ if (at < 0) return buf;
641
+ return Buffer.concat([buf.subarray(0, at), repl, buf.subarray(at + find.length)]);
642
+ }
643
+
644
+ // ---- the daemon record -----------------------------------------------------
645
+
646
+ const statePath = (target) => join(target, '.geneseed-web.json');
647
+
648
+ export function readDaemon(target) {
649
+ try {
650
+ return JSON.parse(readFileSync(statePath(target), 'utf-8'));
651
+ } catch {
652
+ return null;
653
+ }
654
+ }
655
+
656
+ export function writeDaemon(target, data) {
657
+ try {
658
+ mkdirSync(target, { recursive: true });
659
+ const p = statePath(target);
660
+ writeText(p, jsonDumpsCompact(data, { bareInts: true }));
661
+ // Owner-only: the record carries the API token. A no-op on Windows, where the
662
+ // mode only maps to the read-only attribute — same as the reference's os.chmod.
663
+ chmodSync(p, 0o600);
664
+ } catch { /* the reference swallows OSError here too */ }
665
+ }
666
+
667
+ export function clearDaemon(target) {
668
+ try {
669
+ unlinkSync(statePath(target));
670
+ } catch { /* already gone */ }
671
+ }
672
+
673
+ // ---- daemon mode -----------------------------------------------------------
674
+ //
675
+ // `geneseed web start|stop|status|restart`. The reference's own banner explains the design
676
+ // — the running server records pid/port/token/url in a small JSON file and control is over
677
+ // HTTP, so nothing here needs OS process-kill semantics, only a localhost request with the
678
+ // token. Two things about the TWIN, neither of which is in that banner:
679
+ //
680
+ // * `_probe` and `_post_shutdown` are `urllib.request.urlopen` with a timeout, which is
681
+ // synchronous. Node has no synchronous HTTP client, so every function below that
682
+ // reaches one is `async` and `cmdWeb` awaits it — which is why `bin/geneseed-cli.mjs`
683
+ // awaits its dispatch. Nothing about the ORDER of the reference's steps changes.
684
+ // * `agent: false` on both requests. Node's global agent has kept sockets alive by
685
+ // default since v19, and a pooled idle socket keeps the event loop referenced — a
686
+ // `web status` that answered correctly would then hang for the keep-alive timeout
687
+ // instead of exiting. Python's `urlopen` opens and closes a connection per call.
688
+
689
+ /** `f"{url}/api/ping"` answered 200 — the cheap liveness probe both `status` and the
690
+ * launcher's wait loop are built on. Every failure mode is False, as the reference's
691
+ * `(URLError, OSError, ValueError)` catch is. */
692
+ export function probe(url, timeout = 1.5) {
693
+ return new Promise((done) => {
694
+ let settled = false;
695
+ const finish = (v) => { if (!settled) { settled = true; done(v); } };
696
+ let req;
697
+ try {
698
+ req = httpRequest(`${url}/api/ping`, { method: 'GET', agent: false }, (res) => {
699
+ res.resume(); // drain, or the socket never closes
700
+ finish(res.statusCode === 200);
701
+ });
702
+ } catch {
703
+ return finish(false); // a malformed url is `ValueError` there and a throw here
704
+ }
705
+ req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
706
+ req.on('error', () => finish(false));
707
+ req.end();
708
+ return undefined;
709
+ });
710
+ }
711
+
712
+ /** The token-carrying POST `geneseed web stop` and the in-page Stop button both send. */
713
+ export function postShutdown(url, token, timeout = 3.0) {
714
+ return new Promise((done) => {
715
+ let settled = false;
716
+ const finish = (v) => { if (!settled) { settled = true; done(v); } };
717
+ let req;
718
+ try {
719
+ req = httpRequest(`${url}/api/shutdown`, {
720
+ method: 'POST',
721
+ agent: false,
722
+ headers: { 'X-Geneseed-Token': token, 'Content-Type': 'application/json' },
723
+ }, (res) => {
724
+ res.resume();
725
+ finish(res.statusCode === 200);
726
+ });
727
+ } catch {
728
+ return finish(false);
729
+ }
730
+ req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
731
+ req.on('error', () => finish(false));
732
+ req.end('{}');
733
+ return undefined;
734
+ });
735
+ }
736
+
737
+ /**
738
+ * The record ONLY if a server is answering; otherwise the stale file is deleted.
739
+ *
740
+ * The deletion is the part a port drops, and it is what stops a machine that crashed with
741
+ * a daemon running from re-probing a dead URL on every `status` forever. Both of its arms
742
+ * are gated by a `harness_golden` cell — a record whose url nothing answers, and a record
743
+ * carrying no url at all, which skips the probe and still clears.
744
+ */
745
+ export async function liveDaemon(target) {
746
+ const st = readDaemon(target);
747
+ if (st && pyTruthy(st.url) && await probe(st.url)) return st;
748
+ if (pyTruthy(st)) clearDaemon(target);
749
+ return null;
750
+ }
751
+
752
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
753
+
754
+ /** `st.get(k)` rendered as Python renders it — a missing key prints `None`, not
755
+ * `undefined`. Only reachable through a hand-edited record, and one character of
756
+ * divergence in a message is still a divergence. */
757
+ const none = (v) => (v === undefined || v === null ? 'None' : String(v));
758
+
759
+ /**
760
+ * `_spawn_detached` — the daemon launcher, and the second `_ALLOWED_SPAWNS` entry this
761
+ * module owns.
762
+ *
763
+ * WHY THERE IS NO IN-PROCESS EQUIVALENT: the whole contract is that the server OUTLIVES
764
+ * the process that started it. `geneseed web start` prints a URL and returns to the shell;
765
+ * the server it started must still be there. A thread, a `worker_thread` or an in-process
766
+ * `serve()` all die with the launcher.
767
+ *
768
+ * WHAT IT STARTS IS THIS PROGRAM, named as `process.execPath` + `bin/geneseed-cli.mjs` —
769
+ * the running interpreter by absolute path and this repo's own CLI. The reference names
770
+ * `sys.executable` + `rituals/harness.py`; each side re-executes ITSELF, which is what
771
+ * makes this not the passthrough the port exists to remove, and
772
+ * `tests/test_web_daemon.py` asserts the twin's argv contains no `python`, no `harness.py`
773
+ * and no `build.py` at all.
774
+ */
775
+ function spawnDetached(webArgs, log) {
776
+ const cmd = [process.execPath, join(ROOT, 'bin', 'geneseed-cli.mjs'), 'web', ...webArgs];
777
+ let out = 'ignore';
778
+ try {
779
+ out = openSync(log, 'a'); // `open(log, "ab")`; DEVNULL on OSError, as there
780
+ } catch { /* the reference falls back to DEVNULL on exactly this failure */ }
781
+ const child = spawn(cmd[0], cmd.slice(1), {
782
+ // `DETACHED_PROCESS | NEW_PROCESS_GROUP` on Windows and `start_new_session` on POSIX
783
+ // are both `detached` here; `unref()` is what lets the launcher's event loop end while
784
+ // the child runs on, which is `Popen`'s default and Node's is not.
785
+ detached: true, windowsHide: true, stdio: ['ignore', out, out],
786
+ });
787
+ child.unref();
788
+ }
789
+
790
+ /** The `web` argv the launcher passes. Pure, and compared against `_daemon_args` by the
791
+ * corpus: the binary differs per side by design, the FLAGS may not. */
792
+ export function daemonArgs(port, theme) {
793
+ const args = ['--daemon-internal', '--port', String(port), '--no-browser'];
794
+ if (pyTruthy(theme)) args.push('--theme', theme);
795
+ return args;
796
+ }
797
+
798
+ /** The same for the out-of-band restart — `_restart_args`. */
799
+ export function restartArgs(theme) {
800
+ const args = ['restart', '--no-browser'];
801
+ if (pyTruthy(theme)) args.push('--theme', theme);
802
+ return args;
803
+ }
804
+
805
+ /** `request_restart` — a detached `web restart`, so the new server survives the exit of
806
+ * the very process asking for it. Called by POST `/api/restart`. */
807
+ export function requestRestart(theme) {
808
+ const target = webState(theme).target;
809
+ spawnDetached(restartArgs(theme), join(target, '.geneseed-web.log'));
810
+ }
811
+
812
+ /**
813
+ * `webbrowser.open(url)`, which has no Node equivalent at all — there is no stdlib module
814
+ * that knows what a desktop's default browser is.
815
+ *
816
+ * IT IS A MACHINE PRIMITIVE, the same class as `taskkill` and `java -version` and the
817
+ * opposite of a passthrough: it asks the OS to open a URL and hands it nothing of this
818
+ * program's work. Declining it instead would have been a silent regression in the one verb
819
+ * whose job is to put a UI on screen — `geneseed web` would print an address and do
820
+ * nothing, and no cell would say so because every cell passes `--no-browser`.
821
+ *
822
+ * The reference swallows every failure here (`contextlib.suppress(Exception)` in two call
823
+ * sites and a bare `except` in the third), and so does this.
824
+ */
825
+ function openUrl(url) {
826
+ const [file, args, extra] = process.platform === 'win32'
827
+ // `start` is a cmd BUILTIN, not a program. The empty `""` is its title argument —
828
+ // without it `start "http://…"` treats the quoted URL as the window title and opens
829
+ // nothing. Verbatim args because Node would otherwise re-quote the whole string.
830
+ ? [process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
831
+ { windowsVerbatimArguments: true }]
832
+ : (process.platform === 'darwin' ? ['open', [url], {}] : ['xdg-open', [url], {}]);
833
+ try {
834
+ const child = spawn(file, args, {
835
+ detached: true, stdio: 'ignore', windowsHide: true, ...extra,
836
+ });
837
+ // ⚠ ENOENT ARRIVES AS AN EVENT, NOT AS A THROW, AND THE `try` ABOVE CANNOT SEE IT.
838
+ // This is where the reference's suppression failed to survive translation: Python's
839
+ // `subprocess` raises `FileNotFoundError` synchronously, so `contextlib.suppress(Exception)`
840
+ // really did swallow a missing opener. Node's `spawn` reports the same condition on the
841
+ // child's `error` event, and an 'error' with no listener is re-thrown as an uncaught
842
+ // exception. So the port's `try/catch` read as equivalent and suppressed nothing.
843
+ //
844
+ // MEASURED, in the packaged no-python container: `geneseed web` on a headless Linux box
845
+ // printed `Error: spawn xdg-open ENOENT`. Found by the acceptance gate rather than by the
846
+ // 690-cell corpus, and the docblock above says why the corpus never could — every cell
847
+ // passes `--no-browser`, so no recorded cell has ever executed this line.
848
+ child.on('error', () => { /* no opener on this machine; the daemon is still serving */ });
849
+ child.unref();
850
+ } catch { /* the reference suppresses every exception from this call too */ }
851
+ }
852
+
853
+ export async function startDaemon(theme, port, openBrowser = true) {
854
+ const { target } = webState(theme);
855
+ const st = await liveDaemon(target);
856
+ if (st) {
857
+ pyPrint(`[web] already running on ${st.url} (pid ${none(st.pid)})\n`);
858
+ if (openBrowser) openUrl(st.url);
859
+ return 0;
860
+ }
861
+ clearDaemon(target);
862
+ const log = join(target, '.geneseed-web.log');
863
+ spawnDetached(daemonArgs(port, theme), log);
864
+ // `for _ in range(60): ... time.sleep(0.2)` — twelve seconds for the child to bind and
865
+ // write its record. The probe timeout is the reference's shorter 0.5 here, not 1.5.
866
+ for (let i = 0; i < 60; i += 1) {
867
+ const rec = readDaemon(target);
868
+ // eslint-disable-next-line no-await-in-loop
869
+ if (rec && pyTruthy(rec.url) && await probe(rec.url, 0.5)) {
870
+ pyPrint(`[web] Geneseed UI on ${rec.url} (theme: ${none(rec.theme)}, `
871
+ + `pid ${none(rec.pid)})\n`);
872
+ pyPrint('[web] running in the background — `geneseed web stop` to stop it.\n');
873
+ if (openBrowser) openUrl(rec.url);
874
+ return 0;
875
+ }
876
+ // eslint-disable-next-line no-await-in-loop
877
+ await sleep(200);
878
+ }
879
+ pyPrint('[web] daemon did not come up in time — check the log:\n');
880
+ pyPrint(` ${log}\n`);
881
+ return 1;
882
+ }
883
+
884
+ export async function stopDaemon(theme = null) {
885
+ const { target } = webState(theme);
886
+ const st = readDaemon(target);
887
+ if (!pyTruthy(st) || !pyTruthy(st.url)) {
888
+ // NO `clearDaemon` HERE, and the asymmetry with `liveDaemon` is the reference's. A
889
+ // record that names no url is left exactly where it is; `web status` on the same file
890
+ // deletes it. Both directions are cells, because a port that unified them would delete
891
+ // a file the reference keeps and stdout would look identical.
892
+ pyPrint('[web] no running server recorded.\n');
893
+ return 0;
894
+ }
895
+ if (await postShutdown(st.url, pyTruthy(st.token) ? st.token : '')) {
896
+ clearDaemon(target);
897
+ pyPrint(`[web] stopped (pid ${none(st.pid)}).\n`);
898
+ return 0;
899
+ }
900
+ clearDaemon(target);
901
+ pyPrint('[web] no live server (cleared a stale record).\n');
902
+ return 0;
903
+ }
904
+
905
+ export async function statusDaemon(theme = null) {
906
+ const { target } = webState(theme);
907
+ const st = await liveDaemon(target);
908
+ if (st) {
909
+ pyPrint(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
910
+ return 0;
911
+ }
912
+ // EXIT 1, which is the verb's whole contract for a script. The snapshot's `<exit>`
913
+ // column is what gates it.
914
+ pyPrint('[web] not running.\n');
915
+ return 1;
916
+ }
917
+
918
+ export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
919
+ onlyIfRunning = false) {
920
+ const { target } = webState(theme);
921
+ const st = readDaemon(target);
922
+ const live = (await liveDaemon(target)) !== null;
923
+ if (onlyIfRunning && !live) return 0;
924
+ const usePort = (st && pyTruthy(st.port) ? st.port : null) || port;
925
+ let open = openBrowser;
926
+ if (live) {
927
+ // A live daemon means a tab is already open on this (preserved) port and will
928
+ // reconnect on its own — the reference's `ponytail:` note, kept.
929
+ open = false;
930
+ await stopDaemon(theme);
931
+ for (let i = 0; i < 50; i += 1) {
932
+ // eslint-disable-next-line no-await-in-loop
933
+ if (!await probe(`http://127.0.0.1:${usePort}`, 0.2)) break;
934
+ // eslint-disable-next-line no-await-in-loop
935
+ await sleep(100);
936
+ }
937
+ }
938
+ return startDaemon(theme, usePort, open);
939
+ }
940
+
941
+ // ---- serve -----------------------------------------------------------------
942
+
943
+ /**
944
+ * Pure: what `serve()` should do about the UI bundle. Ported ahead of the rest of the
945
+ * verb because it decides whether a server starts at all, and because it is the second
946
+ * interactive prompt in this port — `interactive` is an ARGUMENT, which is what makes it
947
+ * gateable by a corpus rather than only by a cell.
948
+ *
949
+ * P6h GAVE IT BOTH KINDS OF GATE AND FOUND THE CLAIM ABOVE HAD NEVER BEEN PAID. It had a
950
+ * Python unit test (`tests/test_web.py`) and nothing cross-implementation until now: the
951
+ * corpus in `tests/test_pure_function_parity.py` covers all five answers, and two
952
+ * `harness_golden` cells cover the two arms that TERMINATE — which are also the only two
953
+ * that let a `web` cell exist at all, since every other arm of `serve` binds a socket.
954
+ */
955
+ export function buildPlan(dist, webDir, npm, interactive) {
956
+ if (isFile(join(dist, 'index.html'))) return 'serve';
957
+ if (!isFile(join(webDir, 'package.json'))) return 'no-source';
958
+ if (!npm) return 'no-npm';
959
+ if (!interactive) return 'no-tty';
960
+ return 'ask';
961
+ }
962
+
963
+ /**
964
+ * `npm install` then `npm run build` in `web/`, streams inherited — the third spawn this
965
+ * module declares, and the one NO test reaches.
966
+ *
967
+ * THAT IS A DECLARED PARTITION, not an omission. `web/dist/index.html` is TRACKED (asserted
968
+ * by `tests/test_web_daemon.py` against `git ls-files`, so the claim cannot rot), which
969
+ * means `buildPlan` answers `serve` in every checkout a cell can build and the `ask` arm is
970
+ * reachable only from a partial checkout on an interactive terminal. There is no npm
971
+ * library to call instead; a Node twin that reimplemented `npm install` is not a thing that
972
+ * exists.
973
+ *
974
+ * `shell` ON WINDOWS, and it is required rather than stylistic: `pyWhich('npm')` resolves
975
+ * to `npm.CMD` through PATHEXT, and Node refuses to `spawn` a `.cmd` directly. The path is
976
+ * quoted because Node wraps the whole command in one more pair and `cmd /s` strips only
977
+ * the outermost — `C:\Program Files\…` would otherwise split at the space.
978
+ */
979
+ export function npmBuild(npm, webDir) {
980
+ const win = process.platform === 'win32';
981
+ for (const step of [['install'], ['run', 'build']]) {
982
+ pyPrint(`[web] npm ${step.join(' ')} ...\n`);
983
+ const r = spawnSync(win ? `"${npm}"` : npm, step, {
984
+ cwd: webDir, stdio: 'inherit', shell: win,
985
+ });
986
+ const code = r.status === null || r.status === undefined ? 1 : r.status;
987
+ if (code) {
988
+ pyPrint(`[web] npm ${step.join(' ')} failed (exit ${code}).\n`);
989
+ return code;
990
+ }
991
+ }
992
+ return 0;
993
+ }
994
+
995
+ export async function serve({ theme = null, port = 4747, openBrowser = true,
996
+ daemon = false } = {}) {
997
+ const dist = join(ROOT, 'web', 'dist');
998
+ const webDir = join(ROOT, 'web');
999
+ const manual = ' cd web && npm install && npm run build';
1000
+ // `sys.stdin.isatty()`. `process.stdin.isTTY` is `undefined` rather than false on a pipe,
1001
+ // which is the same trap `cmdSetup` documents.
1002
+ const plan = buildPlan(dist, webDir, pyWhich('npm'), Boolean(process.stdin.isTTY));
1003
+ if (plan === 'no-source') {
1004
+ pyPrint(`[web] web/ sources are missing from ${ROOT}.\n`);
1005
+ pyPrint(' Run `geneseed upgrade` to fetch them (twice on installs whose\n');
1006
+ pyPrint(' updater predates web/ in the sync list).\n');
1007
+ return 1;
1008
+ }
1009
+ if (plan === 'no-npm') {
1010
+ pyPrint('[web] web/dist is missing and npm was not found. Install Node.js,\n');
1011
+ pyPrint(' then build the UI:\n');
1012
+ pyPrint(`${manual}\n`);
1013
+ return 1;
1014
+ }
1015
+ if (plan === 'no-tty') {
1016
+ pyPrint('[web] web/dist is missing. Build the UI first:\n');
1017
+ pyPrint(`${manual}\n`);
1018
+ return 1;
1019
+ }
1020
+ if (plan === 'ask') {
1021
+ // `except (EOFError, KeyboardInterrupt): answer = "n"`. EOF is the arm that matters and
1022
+ // it is the one a naive port gets backwards: `input()` RAISES at EOF, so the reference
1023
+ // reads it as "no", while a read that returned `''` would fall into the empty-answer
1024
+ // arm and start an npm install nobody asked for. `promptLine` returns null for exactly
1025
+ // that case — see its docblock in js/setup.mjs.
1026
+ const line = promptLine('[web] UI not built — run npm install && npm run build now? [Y/n] ');
1027
+ const answer = line === null ? 'n' : line;
1028
+ if (['', 'y', 'yes'].includes(answer.trim().toLowerCase())) {
1029
+ const code = npmBuild(pyWhich('npm'), webDir);
1030
+ if (code) return code;
1031
+ } else {
1032
+ pyPrint('[web] skipped. Build the UI manually:\n');
1033
+ pyPrint(`${manual}\n`);
1034
+ return 0;
1035
+ }
1036
+ }
1037
+ const state = webState(theme);
1038
+ if (!existsSync(join(state.target, GLOBAL_MANIFEST))) {
1039
+ pyPrint(`[web] no deployed harness at ${state.target}.\n`);
1040
+ pyPrint(' Run `geneseed setup` first — serving anyway (read-only UI).\n');
1041
+ }
1042
+ const token = randomBytes(24).toString('base64url');
1043
+ const holder = {};
1044
+ // `JobManager(history_path=state.target / ".geneseed-web-runs.json")` — the console's job
1045
+ // list survives a reload and a restart because it is a FILE, not a process's memory.
1046
+ const jm = new JobManager(join(state.target, '.geneseed-web-runs.json'));
1047
+ const srv = createServer(makeHandler(state, jm, token, dist, holder));
1048
+ holder.srv = srv;
1049
+ return new Promise((done) => {
1050
+ srv.on('error', () => {
1051
+ // The reference retries on port 0; the same fallback, one level in.
1052
+ srv.listen(0, '127.0.0.1', () => ready());
1053
+ });
1054
+ srv.listen(port, '127.0.0.1', () => ready());
1055
+
1056
+ function ready() {
1057
+ const hostPort = srv.address().port;
1058
+ const url = `http://127.0.0.1:${hostPort}`;
1059
+ if (daemon) {
1060
+ writeDaemon(state.target, {
1061
+ pid: process.pid, port: hostPort, url,
1062
+ token, theme: state.theme, started: Math.floor(Date.now() / 1000),
1063
+ });
1064
+ }
1065
+ pyPrint(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
1066
+ pyPrint(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
1067
+ if (openBrowser) openUrl(url);
1068
+ // `except KeyboardInterrupt: print("\n[web] stopped.")` around `serve_forever`. Node
1069
+ // kills on SIGINT by default, which would skip the message AND the record cleanup in
1070
+ // the `finally` below — a foreground `--daemon-internal` server stopped with Ctrl-C
1071
+ // would leave a record pointing at a dead port.
1072
+ process.on('SIGINT', () => {
1073
+ pyPrint('\n[web] stopped.\n');
1074
+ srv.close();
1075
+ srv.closeAllConnections();
1076
+ });
1077
+ srv.on('close', () => {
1078
+ if (daemon) {
1079
+ const st = readDaemon(state.target);
1080
+ if (!st || st.pid === process.pid) clearDaemon(state.target);
1081
+ }
1082
+ done(0);
1083
+ });
1084
+ }
1085
+ });
1086
+ }
1087
+
1088
+ /**
1089
+ * `cmd_web` — the verb `bin/geneseed-cli.mjs` dispatches to, and the reason this module is
1090
+ * on the CLI's import graph at all (which is what moved `js/web/jobs.mjs`'s
1091
+ * `_ALLOWED_SPAWNS` row from `entry: "web"` to `entry: "cli"`).
1092
+ *
1093
+ * The reference's `cmd_web` does `import web` INSIDE the function; the ESM equivalent would
1094
+ * be a dynamic `import()`, and it is deliberately NOT used — a static import is what lets
1095
+ * `test_the_cli_reaches_child_process_only_where_it_is_declared` walk the CLI's graph and
1096
+ * find the two spawning modules under `js/web/`. The cost is parsing the web tree on every
1097
+ * CLI invocation; `bin/geneseed-cli.mjs` is user-invoked and carries no latency budget (the
1098
+ * hook entry, which does, is a different binary for exactly this reason).
1099
+ */
1100
+ export async function cmdWeb(args) {
1101
+ // `--port` is `type=int` on the reference, and `bin/geneseed-cli.mjs`'s `ints` column
1102
+ // refuses a non-integer before this runs — so `pyInt` cannot be null here.
1103
+ const port = args.port === null ? 4747 : pyInt(args.port);
1104
+ const openBrowser = !args.noBrowser;
1105
+ if (args.action === 'start') return startDaemon(args.theme, port, openBrowser);
1106
+ if (args.action === 'stop') return stopDaemon(args.theme);
1107
+ if (args.action === 'restart') return restartDaemon(args.theme, port, openBrowser);
1108
+ if (args.action === 'status') return statusDaemon(args.theme);
1109
+ return serve({ theme: args.theme, port, openBrowser, daemon: args.daemonInternal });
1110
+ }
1111
+
1112
+ // ---- entry -----------------------------------------------------------------
1113
+ //
1114
+ // KEPT AFTER P6h, and it is not a duplicate of the verb above. `tests/web_golden.py` drove
1115
+ // this module directly for all of P6a–P6g, and its 95 cells are what license the MOVE: the
1116
+ // same matrix now runs against `node bin/geneseed-cli.mjs web` as well, and a byte gate
1117
+ // that passed against one command proves nothing about the other until it is re-aimed. The
1118
+ // module entry is also what a debugging session reaches for when the CLI's argument layer
1119
+ // is the thing under suspicion.
1120
+
1121
+ async function main(argv) {
1122
+ let theme = null;
1123
+ let port = 4747;
1124
+ let daemon = false;
1125
+ let openBrowser = true;
1126
+ for (let i = 0; i < argv.length; i += 1) {
1127
+ if (argv[i] === '--theme') { theme = argv[i + 1]; i += 1; } else if (argv[i] === '--port') { port = pyInt(String(argv[i + 1])) ?? 4747; i += 1; } else if (argv[i] === '--daemon-internal') daemon = true;
1128
+ else if (argv[i] === '--no-browser') openBrowser = false;
1129
+ }
1130
+ return serve({ theme, port, openBrowser, daemon });
1131
+ }
1132
+
1133
+ if (process.argv[1] && pyResolve(process.argv[1]) === pyResolve(fileURLToPath(import.meta.url))) {
1134
+ process.exitCode = await main(process.argv.slice(2));
1135
+ }