xonecode 0.3.0 → 0.5.1

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 (408) hide show
  1. package/README.md +50 -8
  2. package/THIRD_PARTY_NOTICES.md +160 -0
  3. package/apps/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/apps/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/apps/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/apps/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/apps/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/apps/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/apps/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/apps/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/apps/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/apps/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/apps/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/apps/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/apps/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/apps/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/apps/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/apps/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/apps/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/apps/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/apps/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/apps/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/apps/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/apps/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/apps/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/apps/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/apps/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/apps/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/apps/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/apps/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/apps/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/apps/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/apps/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/apps/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/apps/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/apps/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/apps/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/apps/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/apps/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/apps/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/apps/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/apps/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/apps/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/apps/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/apps/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/apps/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/apps/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/apps/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/apps/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/apps/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/apps/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/apps/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/apps/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/apps/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/apps/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/apps/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/apps/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/apps/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/apps/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/apps/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/apps/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/apps/web/dist/assets/c-BIGW1oBm.js +1 -0
  63. package/apps/web/dist/assets/cpp-DddhVWhK.js +1 -0
  64. package/apps/web/dist/assets/csharp-DSvCPggb.js +1 -0
  65. package/apps/web/dist/assets/css-CLj8gQPS.js +1 -0
  66. package/apps/web/dist/assets/go-C27-OAKa.js +1 -0
  67. package/apps/web/dist/assets/html-CfGypltT.js +1 -0
  68. package/apps/web/dist/assets/index-BB3SamhR.css +1 -0
  69. package/apps/web/dist/assets/index-CFYAoHdw.js +477 -0
  70. package/apps/web/dist/assets/ini-BEwlwnbL.js +1 -0
  71. package/apps/web/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  72. package/apps/web/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  73. package/apps/web/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  74. package/apps/web/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  75. package/apps/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  76. package/apps/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  77. package/apps/web/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  78. package/apps/web/dist/assets/java-CylS5w8V.js +1 -0
  79. package/apps/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  80. package/apps/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  81. package/apps/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  82. package/apps/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  83. package/apps/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  84. package/apps/web/dist/assets/kotlin-BdnUsdx6.js +1 -0
  85. package/apps/web/dist/assets/less-B1dDrJ26.js +1 -0
  86. package/apps/web/dist/assets/lua-BaeVxFsk.js +1 -0
  87. package/apps/web/dist/assets/markdown-Cvjx9yec.js +1 -0
  88. package/apps/web/dist/assets/mdx-Cmh6b_Ma.js +1 -0
  89. package/apps/web/dist/assets/php-D_2--9WK.js +1 -0
  90. package/apps/web/dist/assets/python-B6aJPvgy.js +1 -0
  91. package/apps/web/dist/assets/ruby-DSHSDbk1.js +1 -0
  92. package/apps/web/dist/assets/rust-B1yitclQ.js +1 -0
  93. package/apps/web/dist/assets/scss-D5BDwBP9.js +1 -0
  94. package/apps/web/dist/assets/sql-CRqJ_cUM.js +1 -0
  95. package/apps/web/dist/assets/swift-C2oV4EkX.js +1 -0
  96. package/apps/web/dist/assets/toml-vGWfd6FD.js +1 -0
  97. package/apps/web/dist/assets/xml-sdJ4AIDG.js +1 -0
  98. package/apps/web/dist/assets/yaml-Buea-lGh.js +1 -0
  99. package/apps/web/dist/iconos/xonecode.png +0 -0
  100. package/apps/web/dist/index.html +20 -0
  101. package/dist/agent/agentesEnDisco.js +539 -0
  102. package/dist/agent/agentesEnDisco.js.map +1 -0
  103. package/dist/agent/arbolDeProyecto.js +245 -0
  104. package/dist/agent/arbolDeProyecto.js.map +1 -0
  105. package/dist/agent/artefactosEnDisco.js +139 -0
  106. package/dist/agent/artefactosEnDisco.js.map +1 -0
  107. package/dist/agent/aumentador.js +175 -0
  108. package/dist/agent/aumentador.js.map +1 -0
  109. package/dist/agent/authEnDisco.js +76 -1
  110. package/dist/agent/authEnDisco.js.map +1 -1
  111. package/dist/agent/busquedaRegex.js +92 -0
  112. package/dist/agent/busquedaRegex.js.map +1 -0
  113. package/dist/agent/catalogoModelos.js +345 -0
  114. package/dist/agent/catalogoModelos.js.map +1 -0
  115. package/dist/agent/checkpointer.js +127 -0
  116. package/dist/agent/checkpointer.js.map +1 -0
  117. package/dist/agent/cloudstudioClient.js +194 -0
  118. package/dist/agent/cloudstudioClient.js.map +1 -0
  119. package/dist/agent/cloudstudioMcp.js +722 -0
  120. package/dist/agent/cloudstudioMcp.js.map +1 -0
  121. package/dist/agent/configEnDisco.js +309 -11
  122. package/dist/agent/configEnDisco.js.map +1 -1
  123. package/dist/agent/consumoExterno.js +99 -0
  124. package/dist/agent/consumoExterno.js.map +1 -0
  125. package/dist/agent/descarga.js +149 -0
  126. package/dist/agent/descarga.js.map +1 -0
  127. package/dist/agent/diagnosticoDeTools.js +51 -0
  128. package/dist/agent/diagnosticoDeTools.js.map +1 -0
  129. package/dist/agent/dispositivosEnMaquina.js +596 -0
  130. package/dist/agent/dispositivosEnMaquina.js.map +1 -0
  131. package/dist/agent/escrituraDeCodex.js +134 -0
  132. package/dist/agent/escrituraDeCodex.js.map +1 -0
  133. package/dist/agent/escrituraDeOpencode.js +80 -0
  134. package/dist/agent/escrituraDeOpencode.js.map +1 -0
  135. package/dist/agent/escrituraExterna.js +742 -0
  136. package/dist/agent/escrituraExterna.js.map +1 -0
  137. package/dist/agent/gemini.js +81 -0
  138. package/dist/agent/gemini.js.map +1 -0
  139. package/dist/agent/git.js +83 -0
  140. package/dist/agent/git.js.map +1 -0
  141. package/dist/agent/gitSync.js +441 -0
  142. package/dist/agent/gitSync.js.map +1 -0
  143. package/dist/agent/hotswap.js +448 -0
  144. package/dist/agent/hotswap.js.map +1 -0
  145. package/dist/agent/instalacionEnMaquina.js +252 -0
  146. package/dist/agent/instalacionEnMaquina.js.map +1 -0
  147. package/dist/agent/instantanea.js +7 -10
  148. package/dist/agent/instantanea.js.map +1 -1
  149. package/dist/agent/interrupts.js +11 -2
  150. package/dist/agent/interrupts.js.map +1 -1
  151. package/dist/agent/juezDeTarea.js +345 -0
  152. package/dist/agent/juezDeTarea.js.map +1 -0
  153. package/dist/agent/lanzamientoEnMaquina.js +377 -0
  154. package/dist/agent/lanzamientoEnMaquina.js.map +1 -0
  155. package/dist/agent/manifiesto.js +59 -0
  156. package/dist/agent/manifiesto.js.map +1 -0
  157. package/dist/agent/memoriaDeProyecto.js +60 -0
  158. package/dist/agent/memoriaDeProyecto.js.map +1 -0
  159. package/dist/agent/modelos.js +105 -17
  160. package/dist/agent/modelos.js.map +1 -1
  161. package/dist/agent/modelosDeMotor.js +204 -0
  162. package/dist/agent/modelosDeMotor.js.map +1 -0
  163. package/dist/agent/paqueteDelProyecto.js +181 -0
  164. package/dist/agent/paqueteDelProyecto.js.map +1 -0
  165. package/dist/agent/perfiles.js +92 -31
  166. package/dist/agent/perfiles.js.map +1 -1
  167. package/dist/agent/persona.js +40 -0
  168. package/dist/agent/persona.js.map +1 -0
  169. package/dist/agent/procesosEnMaquina.js +204 -0
  170. package/dist/agent/procesosEnMaquina.js.map +1 -0
  171. package/dist/agent/proyecto.js +311 -13
  172. package/dist/agent/proyecto.js.map +1 -1
  173. package/dist/agent/puente.js +63 -6
  174. package/dist/agent/puente.js.map +1 -1
  175. package/dist/agent/resumenDeContexto.js +30 -0
  176. package/dist/agent/resumenDeContexto.js.map +1 -0
  177. package/dist/agent/resumenDeTool.js +64 -18
  178. package/dist/agent/resumenDeTool.js.map +1 -1
  179. package/dist/agent/sesionGit.js +481 -0
  180. package/dist/agent/sesionGit.js.map +1 -0
  181. package/dist/agent/settingsEnDisco.js +189 -0
  182. package/dist/agent/settingsEnDisco.js.map +1 -0
  183. package/dist/agent/subagenteCodex.js +368 -0
  184. package/dist/agent/subagenteCodex.js.map +1 -0
  185. package/dist/agent/subagenteExterno.js +423 -0
  186. package/dist/agent/subagenteExterno.js.map +1 -0
  187. package/dist/agent/subagenteOpencode.js +389 -0
  188. package/dist/agent/subagenteOpencode.js.map +1 -0
  189. package/dist/agent/subida.js +202 -0
  190. package/dist/agent/subida.js.map +1 -0
  191. package/dist/agent/tareasEnDisco.js +466 -0
  192. package/dist/agent/tareasEnDisco.js.map +1 -0
  193. package/dist/agent/turnoReal.js +663 -50
  194. package/dist/agent/turnoReal.js.map +1 -1
  195. package/dist/agent/versionEnDisco.js +48 -0
  196. package/dist/agent/versionEnDisco.js.map +1 -0
  197. package/dist/agent/xoneAgent.js +290 -49
  198. package/dist/agent/xoneAgent.js.map +1 -1
  199. package/dist/agent/zip.js +52 -0
  200. package/dist/agent/zip.js.map +1 -0
  201. package/dist/cli/acuseDeModelo.js +30 -0
  202. package/dist/cli/acuseDeModelo.js.map +1 -0
  203. package/dist/cli/aprobar.js +55 -2
  204. package/dist/cli/aprobar.js.map +1 -1
  205. package/dist/cli/config.js +9 -13
  206. package/dist/cli/config.js.map +1 -1
  207. package/dist/cli/consola.js +750 -19
  208. package/dist/cli/consola.js.map +1 -1
  209. package/dist/cli/main.js +740 -81
  210. package/dist/cli/main.js.map +1 -1
  211. package/dist/cli/markdown.js +188 -2
  212. package/dist/cli/markdown.js.map +1 -1
  213. package/dist/cli/run.js +32 -4
  214. package/dist/cli/run.js.map +1 -1
  215. package/dist/cli/stdio.js +46 -19
  216. package/dist/cli/stdio.js.map +1 -1
  217. package/dist/cli/tema.js +96 -8
  218. package/dist/cli/tema.js.map +1 -1
  219. package/dist/cli/tokens.js +29 -0
  220. package/dist/cli/tokens.js.map +1 -0
  221. package/dist/cli/tui/app.js +171 -0
  222. package/dist/cli/tui/app.js.map +1 -0
  223. package/dist/cli/tui/aprobarTui.js +80 -0
  224. package/dist/cli/tui/aprobarTui.js.map +1 -0
  225. package/dist/cli/tui/barra.js +37 -0
  226. package/dist/cli/tui/barra.js.map +1 -0
  227. package/dist/cli/tui/correrTui.js +478 -0
  228. package/dist/cli/tui/correrTui.js.map +1 -0
  229. package/dist/cli/tui/entrada.js +166 -0
  230. package/dist/cli/tui/entrada.js.map +1 -0
  231. package/dist/cli/tui/filas.js +24 -0
  232. package/dist/cli/tui/filas.js.map +1 -0
  233. package/dist/cli/tui/logo.js +49 -0
  234. package/dist/cli/tui/logo.js.map +1 -0
  235. package/dist/cli/tui/pielTui.js +14 -0
  236. package/dist/cli/tui/pielTui.js.map +1 -0
  237. package/dist/cli/tui/raton.js +126 -0
  238. package/dist/cli/tui/raton.js.map +1 -0
  239. package/dist/cli/tui/sidebar.js +86 -0
  240. package/dist/cli/tui/sidebar.js.map +1 -0
  241. package/dist/cli/tui/store.js +143 -0
  242. package/dist/cli/tui/store.js.map +1 -0
  243. package/dist/cli/tui/tarjeta.js +19 -0
  244. package/dist/cli/tui/tarjeta.js.map +1 -0
  245. package/dist/cli/tui/temaInk.js +114 -0
  246. package/dist/cli/tui/temaInk.js.map +1 -0
  247. package/dist/cli/tui/transcript.js +254 -0
  248. package/dist/cli/tui/transcript.js.map +1 -0
  249. package/dist/cli/wizardInicial.js +240 -0
  250. package/dist/cli/wizardInicial.js.map +1 -0
  251. package/dist/core/actos.js +113 -0
  252. package/dist/core/actos.js.map +1 -0
  253. package/dist/core/adjuntos.js +97 -0
  254. package/dist/core/adjuntos.js.map +1 -0
  255. package/dist/core/agentes.js +227 -0
  256. package/dist/core/agentes.js.map +1 -0
  257. package/dist/core/artefactos.js +149 -0
  258. package/dist/core/artefactos.js.map +1 -0
  259. package/dist/core/cloudstudio.js +7 -0
  260. package/dist/core/cloudstudio.js.map +1 -0
  261. package/dist/core/config.js +212 -5
  262. package/dist/core/config.js.map +1 -1
  263. package/dist/core/contextos.js +53 -1
  264. package/dist/core/contextos.js.map +1 -1
  265. package/dist/core/descargas.js +106 -0
  266. package/dist/core/descargas.js.map +1 -0
  267. package/dist/core/descriptoresDeApp.js +279 -0
  268. package/dist/core/descriptoresDeApp.js.map +1 -0
  269. package/dist/core/dispositivos.js +466 -0
  270. package/dist/core/dispositivos.js.map +1 -0
  271. package/dist/core/entrega.js +141 -0
  272. package/dist/core/entrega.js.map +1 -0
  273. package/dist/core/entrelazar.js +90 -0
  274. package/dist/core/entrelazar.js.map +1 -0
  275. package/dist/core/esqueleto.js +10 -0
  276. package/dist/core/esqueleto.js.map +1 -1
  277. package/dist/core/modelos.js +235 -3
  278. package/dist/core/modelos.js.map +1 -1
  279. package/dist/core/notify.js +27 -9
  280. package/dist/core/notify.js.map +1 -1
  281. package/dist/core/planDeSubida.js +127 -0
  282. package/dist/core/planDeSubida.js.map +1 -0
  283. package/dist/core/ports.js +189 -1
  284. package/dist/core/ports.js.map +1 -1
  285. package/dist/core/puedeLanzarse.js +239 -0
  286. package/dist/core/puedeLanzarse.js.map +1 -0
  287. package/dist/core/rutaVirtual.js +42 -0
  288. package/dist/core/rutaVirtual.js.map +1 -0
  289. package/dist/core/settings.js +246 -0
  290. package/dist/core/settings.js.map +1 -0
  291. package/dist/core/tareas.js +230 -0
  292. package/dist/core/tareas.js.map +1 -0
  293. package/dist/core/textos.js +36 -0
  294. package/dist/core/textos.js.map +1 -0
  295. package/dist/core/turno.js +56 -8
  296. package/dist/core/turno.js.map +1 -1
  297. package/dist/core/version.js +31 -0
  298. package/dist/core/version.js.map +1 -0
  299. package/dist/vendor/sqliteSaver.js +389 -0
  300. package/dist/vendor/sqliteSaver.js.map +1 -0
  301. package/dist/vendor/tokenTracking.js +22 -1
  302. package/dist/vendor/tokenTracking.js.map +1 -1
  303. package/dist/web/servidor/arranque.js +3964 -0
  304. package/dist/web/servidor/arranque.js.map +1 -0
  305. package/dist/web/servidor/consolaDeTarea.js +161 -0
  306. package/dist/web/servidor/consolaDeTarea.js.map +1 -0
  307. package/dist/web/servidor/consolaWeb.js +389 -0
  308. package/dist/web/servidor/consolaWeb.js.map +1 -0
  309. package/dist/web/servidor/corredorDeTareas.js +1119 -0
  310. package/dist/web/servidor/corredorDeTareas.js.map +1 -0
  311. package/dist/web/servidor/pielWeb.js +269 -0
  312. package/dist/web/servidor/pielWeb.js.map +1 -0
  313. package/dist/web/servidor/servidor.js +257 -0
  314. package/dist/web/servidor/servidor.js.map +1 -0
  315. package/dist/web/servidor/sesiones.js +440 -0
  316. package/dist/web/servidor/sesiones.js.map +1 -0
  317. package/dist/web/servidor/transporte.js +169 -0
  318. package/dist/web/servidor/transporte.js.map +1 -0
  319. package/dist/web/servidor/vestibulo.js +1276 -0
  320. package/dist/web/servidor/vestibulo.js.map +1 -0
  321. package/package.json +33 -9
  322. package/skills/archify/LICENSE +22 -0
  323. package/skills/archify/SKILL.md +109 -0
  324. package/skills/archify/THIRD_PARTY_NOTICES.md +56 -0
  325. package/skills/archify/assets/template.html +14787 -0
  326. package/skills/archify/bin/archify.mjs +1990 -0
  327. package/skills/archify/bin/open-artifact.mjs +86 -0
  328. package/skills/archify/bin/preview.mjs +648 -0
  329. package/skills/archify/bin/visual-check.mjs +829 -0
  330. package/skills/archify/brand-marks/README.md +31 -0
  331. package/skills/archify/brand-marks/catalog.json +131 -0
  332. package/skills/archify/delta/architecture-delta.mjs +1214 -0
  333. package/skills/archify/examples/agent-run.lifecycle.json +71 -0
  334. package/skills/archify/examples/agent-tool-call.workflow.json +94 -0
  335. package/skills/archify/examples/async-job-roundtrip.sequence.json +61 -0
  336. package/skills/archify/examples/brand-aware-delivery.architecture.json +47 -0
  337. package/skills/archify/examples/cache-miss-request.sequence.json +81 -0
  338. package/skills/archify/examples/checkout-platform.base.architecture.json +31 -0
  339. package/skills/archify/examples/checkout-platform.head.architecture.json +31 -0
  340. package/skills/archify/examples/dataflow-product-analytics.html +14898 -0
  341. package/skills/archify/examples/deployment-release.lifecycle.json +49 -0
  342. package/skills/archify/examples/event-stream.dataflow.json +57 -0
  343. package/skills/archify/examples/incident-response.workflow.json +64 -0
  344. package/skills/archify/examples/lifecycle-agent-run.html +14847 -0
  345. package/skills/archify/examples/product-analytics.dataflow.json +76 -0
  346. package/skills/archify/examples/production-deployment.architecture.json +71 -0
  347. package/skills/archify/examples/release-delivery.workflow.json +62 -0
  348. package/skills/archify/examples/sequence-cache-miss-request.html +14913 -0
  349. package/skills/archify/examples/web-app-rendered.html +14862 -0
  350. package/skills/archify/examples/web-app.architecture.json +46 -0
  351. package/skills/archify/examples/workflow-agent-tool-call-rendered.html +14904 -0
  352. package/skills/archify/migrations/workflow-v2.mjs +279 -0
  353. package/skills/archify/package.json +17 -0
  354. package/skills/archify/recipes/scenarios.mjs +391 -0
  355. package/skills/archify/references/authoring-contract.md +198 -0
  356. package/skills/archify/references/brand-marks.md +69 -0
  357. package/skills/archify/references/delivery-contract.md +116 -0
  358. package/skills/archify/references/viewer-runtime.md +45 -0
  359. package/skills/archify/renderers/architecture/grid.mjs +62 -0
  360. package/skills/archify/renderers/architecture/render-architecture.mjs +1078 -0
  361. package/skills/archify/renderers/dataflow/README.md +104 -0
  362. package/skills/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  363. package/skills/archify/renderers/lifecycle/README.md +115 -0
  364. package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  365. package/skills/archify/renderers/sequence/README.md +114 -0
  366. package/skills/archify/renderers/sequence/render-sequence.mjs +453 -0
  367. package/skills/archify/renderers/shared/brand-marks.mjs +563 -0
  368. package/skills/archify/renderers/shared/cli.mjs +218 -0
  369. package/skills/archify/renderers/shared/desktop-readability.mjs +26 -0
  370. package/skills/archify/renderers/shared/diagnostics.mjs +127 -0
  371. package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
  372. package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  373. package/skills/archify/renderers/shared/generated-validators.mjs +13 -0
  374. package/skills/archify/renderers/shared/geometry.mjs +1423 -0
  375. package/skills/archify/renderers/shared/i18n.mjs +594 -0
  376. package/skills/archify/renderers/shared/layout-report.mjs +40 -0
  377. package/skills/archify/renderers/shared/legend.mjs +217 -0
  378. package/skills/archify/renderers/shared/output-path.mjs +321 -0
  379. package/skills/archify/renderers/shared/repository-evidence.mjs +235 -0
  380. package/skills/archify/renderers/shared/text-fit.mjs +49 -0
  381. package/skills/archify/renderers/shared/utils.mjs +232 -0
  382. package/skills/archify/renderers/shared/validator.mjs +86 -0
  383. package/skills/archify/renderers/workflow/README.md +223 -0
  384. package/skills/archify/renderers/workflow/render-workflow.mjs +35 -0
  385. package/skills/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  386. package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  387. package/skills/archify/schemas/README.md +211 -0
  388. package/skills/archify/schemas/architecture.schema.json +176 -0
  389. package/skills/archify/schemas/common.schema.json +115 -0
  390. package/skills/archify/schemas/dataflow.schema.json +243 -0
  391. package/skills/archify/schemas/lifecycle.schema.json +266 -0
  392. package/skills/archify/schemas/sequence.schema.json +223 -0
  393. package/skills/archify/schemas/workflow.schema.json +428 -0
  394. package/skills/archify/scripts/check-render-output.mjs +835 -0
  395. package/skills/archify/scripts/check-update.mjs +1667 -0
  396. package/skills/archify/scripts/render-examples.mjs +26 -0
  397. package/skills/archify/scripts/update-contract.mjs +182 -0
  398. package/skills/archify/skill-release.json +10 -0
  399. package/skills/artifacts-builder/SKILL.md +120 -0
  400. package/skills/artifacts-builder/reference/diagramas.md +123 -0
  401. package/skills/artifacts-builder/reference/estilo.md +123 -0
  402. package/skills/artifacts-builder/reference/exportar-pdf.md +56 -0
  403. package/skills/artifacts-builder/reference/graficas.md +22 -0
  404. package/skills/artifacts-builder/scripts/bundle_artifact.py +109 -0
  405. package/skills/artifacts-builder/scripts/validate_artifact.py +345 -0
  406. package/skills/xone-hotswap/SKILL.md +185 -0
  407. package/skills/xone-hotswap/references/comandos.md +706 -0
  408. package/skills/xone-hotswap/references/conexion-y-despliegue.md +226 -0
@@ -0,0 +1,3964 @@
1
+ /**
2
+ * El arranque de la consola web: las comprobaciones, el servidor, las RUTAS y el navegador.
3
+ *
4
+ * Vive aquí y no en `cli/main.ts` —que ya pasa de mil líneas— por lo mismo que
5
+ * `cli/tui/correrTui.ts`: el despachador decide QUÉ piel arranca, y la piel se monta en su
6
+ * propia casa. `main.ts` lo carga con un import dinámico, así que `run`, `describe` y
7
+ * cualquier tubería no pagan el vestíbulo entero.
8
+ *
9
+ * **Aquí es donde `registrarRuta` deja de ser código muerto.** `web/servidor/servidor.ts`
10
+ * la exportaba desde la Task 5 y hasta ahora no la llamaba nadie fuera de sus tests: los
11
+ * dos extremos del cable estaban escritos y sin conectar, de modo que la consola web no se
12
+ * podía abrir aunque `decidirPiel` devolviera «web». Las dos rutas son las que el cliente
13
+ * ya pedía (`apps/web/src/conexion.ts`): el SSE de `/eventos` y el `POST /accion`.
14
+ *
15
+ * **A qué consola habla el cable, y por qué se re-adjunta.** El vestíbulo tiene DOS
16
+ * consolas: la suya (sin raíz, la del alta) y la del proyecto abierto. `consolaWeb.eof()`
17
+ * es `!transporte.conectado()`, y sin cliente conectado toda aprobación sale rechazada y
18
+ * todo `preguntar` responde cadena vacía. Si el SSE se quedara enganchado a la consola del
19
+ * vestíbulo después de abrir un proyecto, el usuario vería su transcript y NADA de lo que
20
+ * decidiera llegaría: fail-closed mudo. Por eso al abrir proyecto se desconecta la anterior
21
+ * y se conecta la nueva, con reemisión entera del transcript.
22
+ *
23
+ * **La clave de API no pasa por el mensaje de alta.** El paso de cuenta lo conduce
24
+ * `vestibulo.pasoDeCuenta()` —o sea `cli/wizardInicial.ts#asistenteDeModelo` sin tocar—
25
+ * sobre `seleccionar` y `leerSecreto`, que el cliente ya pinta. Así la clave sigue viajando
26
+ * por el único mensaje del cable que la lleva y, de propina, el asistente elige TAMBIÉN el
27
+ * modelo y lo guarda: un paso de cuenta que solo guardara la credencial dejaría `trabajo`
28
+ * en la omisión (Ollama local) con una clave de Anthropic recién escrita al lado.
29
+ */
30
+ import { existsSync, readFileSync } from "node:fs";
31
+ import { lineaDeVersion } from "../../core/version.js";
32
+ import { basename, dirname, join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { randomUUID } from "node:crypto";
35
+ import { homedir } from "node:os";
36
+ import { escribirAgente, leerAgente } from "../../core/agentes.js";
37
+ import { borrarAgente, cargarAgentes, guardarAgente } from "../../agent/agentesEnDisco.js";
38
+ import { detectarDispositivos, frameworkEnDispositivo } from "../../agent/dispositivosEnMaquina.js";
39
+ /**
40
+ * El veredicto de «¿se puede lanzar?» y el lanzamiento entero.
41
+ *
42
+ * `lanzarEnDispositivo` se pasa al cable como FUNCIÓN y no como dependencias: construye las
43
+ * suyas por dentro —el `https`, el ZIP en memoria, `frameworkEnDispositivo`, `process.kill(-pid)`
44
+ * y el WebSocket del hotswap—, así que repetir ese montaje en el cableado sería un segundo sitio
45
+ * donde puede dejar de estar montado, que es el patrón de fallo de esta casa. Lo único que este
46
+ * lado aporta es `alFase`, que es cómo se cuenta el recorrido.
47
+ */
48
+ import { lanzarEnDispositivo, } from "../../agent/lanzamientoEnMaquina.js";
49
+ import { motivoDeBloqueo, puedeLanzarse, } from "../../core/puedeLanzarse.js";
50
+ import { PLATAFORMAS_DE_DISPOSITIVO } from "../../core/settings.js";
51
+ import { instalarHerramientaDeDispositivos, verificarDispositivo } from "../../agent/dispositivosEnMaquina.js";
52
+ import { correrPasoDeReceta } from "../../agent/instalacionEnMaquina.js";
53
+ import { modelosDeMotor } from "../../agent/modelosDeMotor.js";
54
+ import { parsear, compatibleConOpenAi, esProveedorPersonalizado, idDeProveedorPersonalizado, motivoDeEndpointInaceptable, motivoDeSlugInaceptable, nombreDeProveedor, PROVEEDORES, slugDesdeNombre, resolver, SIN_CREDENCIAL, PAPELES, } from "../../core/modelos.js";
55
+ import { hayCredencial } from "../../cli/consola.js";
56
+ import { motivoDeClaveInaceptable } from "../../core/config.js";
57
+ import { aplicarAuth, aplicarCredencialAlProceso, cargar, guardarModeloGlobal, } from "../../agent/configEnDisco.js";
58
+ import { borrarCredencial, guardarCredencial } from "../../agent/authEnDisco.js";
59
+ import { borrarProveedorPersonalizado, guardarProveedorPersonalizado, proveedoresPersonalizados, } from "../../agent/configEnDisco.js";
60
+ import { crearCheckpointerDeProyecto, hayCheckpoint, olvidarHilo, } from "../../agent/checkpointer.js";
61
+ import { cargarSettings, guardarConcurrenciaDeTareas, guardarDispositivos, guardarEntorno as guardarEntornoEnDisco, } from "../../agent/settingsEnDisco.js";
62
+ import { dentroDelWorkspace, seAplicaSinAprobacion } from "../../core/settings.js";
63
+ import { cloudstudioDelProyecto } from "../../agent/configEnDisco.js";
64
+ import { abrirEnSistema } from "../../agent/cloudstudioMcp.js";
65
+ import { nombreDePersona } from "../../agent/persona.js";
66
+ import { cambiosDeSesion, fotoDeApertura, olvidarSesion, parcheDeSesion } from "../../agent/sesionGit.js";
67
+ import { commitDeTurno, cambiosPendientes, trabajoSinCommitear } from "../../agent/gitSync.js";
68
+ import { marcarTareaDeSesion, sembrarConsumosPendientes } from "./sesiones.js";
69
+ import { RUTA_ARTEFACTOS, esRutaDeArtefacto } from "../../core/artefactos.js";
70
+ import { arbolDeProyecto, leerFicheroDeProyecto, motivoDeRutaInaceptable, } from "../../agent/arbolDeProyecto.js";
71
+ import { leerArtefactoCrudo, leerArtefactoDeSesion, } from "../../agent/artefactosEnDisco.js";
72
+ import { CatalogoModelos } from "../../agent/catalogoModelos.js";
73
+ import { Modelos } from "../../agent/modelos.js";
74
+ import { crearJuezDeTarea, invocarConModelos } from "../../agent/juezDeTarea.js";
75
+ import { AumentadorGuionizado } from "../../core/ports.js";
76
+ import { arrancarServidor } from "./servidor.js";
77
+ import { consolaParaTarea, crearCorredorDeTareas, revisionConGit, } from "./corredorDeTareas.js";
78
+ import { CONCURRENCIA_POR_OMISION, conEstado, darPorBuenaAMano, tituloDeTarea, } from "../../core/tareas.js";
79
+ import { aplicarFeedback, TOPE_DE_ADJUNTO } from "../../agent/tareasEnDisco.js";
80
+ import { nombreDeAdjuntoAceptable } from "../../core/adjuntos.js";
81
+ import { rutaMemoriaDeProyecto } from "../../agent/memoriaDeProyecto.js";
82
+ import { crearAumentador, invocarParaAumentar } from "../../agent/aumentador.js";
83
+ import { baseDeWorkspacePorOmision, conexionDeVestibulo, crearVestibulo, escribirProyectoEnDisco, esProyectoEnDisco, } from "./vestibulo.js";
84
+ // Valor y no tipo: la traducción de una `Tarea` a lo que viaja vive JUNTO al tipo que
85
+ // produce y no aquí. Estuvo en este cierre, y ahí se cayó `veredicto` sin que nada se
86
+ // pusiera rojo (F1 de la revisión final) — el patrón de fallo que este repo ya tiene
87
+ // documentado cinco veces: una composición de producción dentro de algo que los tests
88
+ // doblan.
89
+ import { filaDeTarea } from "./transporte.js";
90
+ /** Las dos rutas del cable. El cliente las tiene escritas en `apps/web/src/conexion.ts`. */
91
+ /**
92
+ * Cuánto se espera al aviso de «lo que ya había sin commitear» antes de anunciar sin él.
93
+ *
94
+ * Dos segundos porque esto va en el camino de ABRIR una sesión y ahí lo que se nota es el
95
+ * indicador: un anuncio sin ese campo es «no consta» —lo que ya significaba ausente— y el
96
+ * siguiente flanco de turno lo trae. Bloquear la apertura por un `git status` lento es la
97
+ * definición de que el aviso frena, que es justo lo que su propia regla prohíbe.
98
+ */
99
+ export const MS_DE_TRABAJO_AL_ABRIR = 2000;
100
+ export const RUTA_EVENTOS = "/eventos";
101
+ export const RUTA_ACCION = "/accion";
102
+ /**
103
+ * El documento de un ARTEFACTO, para el iframe y para la descarga.
104
+ *
105
+ * Tiene que ser HTTP y no cable: un iframe pinta un DOCUMENTO, y por `/eventos` viajan
106
+ * mensajes. El nombre va en la query y no en la ruta porque `registrarRuta` casa por
107
+ * coincidencia EXACTA (`servidor.ts`), así que `/artefacto/<nombre>` no encontraría
108
+ * manejador. Consecuencia asumida: las URLs relativas de dentro del HTML no resuelven —
109
+ * el contrato de las skills que los escriben es «autocontenido».
110
+ */
111
+ export const RUTA_ARTEFACTO = "/artefacto";
112
+ /**
113
+ * `POST /adjunto?tarea=<id>&nombre=<fichero>` — los BYTES de un adjunto.
114
+ *
115
+ * Por HTTP y no por el cable: el SSE lleva JSON y esto son bytes. El nombre va en la QUERY
116
+ * y no en la ruta por lo mismo que el del artefacto: `registrarRuta` casa por coincidencia
117
+ * EXACTA, así que `/adjunto/<nombre>` no encontraría manejador.
118
+ */
119
+ export const RUTA_ADJUNTO = "/adjunto";
120
+ /** Cuántas líneas del log de una instalación viajan: la COLA, lo último. */
121
+ export const LINEAS_DE_LOG = 12;
122
+ /** Cada cuánto se emite el progreso. Ver `atenderReceta`: cada emisión manda la cola entera. */
123
+ export const MS_ENTRE_PROGRESOS = 250;
124
+ /** Lo que se sirve: el build del cliente. Tres niveles arriba tanto desde `src/web/servidor/`
125
+ * como desde `dist/web/servidor/`, que es la disposición que se publica en npm. */
126
+ export function raizDelClientePorOmision() {
127
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "apps", "web", "dist");
128
+ }
129
+ export const FALTA_EL_BUILD = "falta el build del cliente: ejecuta «npm run build:web»";
130
+ /** Tope del cuerpo de `POST /accion`. Generoso para una prosa larga, finito porque el
131
+ * cuerpo se acumula en memoria y un cliente roto no puede llenarla. */
132
+ const TOPE_DE_CUERPO = 1_000_000;
133
+ /** El `Content-Type` con el que se sirve un artefacto inline: al texto se le pone el juego
134
+ * de caracteres, porque sin él el navegador lo adivina de los bytes y un HTML en UTF-8 sale
135
+ * con la acentuación rota. A una imagen no se le pone: no lo lleva. */
136
+ function tipoServido(mime) {
137
+ return mime.startsWith("text/") || mime === "image/svg+xml" || mime === "application/json"
138
+ ? `${mime}; charset=utf-8`
139
+ : mime;
140
+ }
141
+ /**
142
+ * Monta `/eventos` y `/accion` sobre un servidor ya levantado.
143
+ *
144
+ * Separada de `arrancarConsolaWeb` para poder probar el cable entero —conexión, registro
145
+ * de comandos, alta, cambio de proyecto— sin puerto, sin disco y sin navegador: los
146
+ * manejadores se invocan con dobles de petición y respuesta.
147
+ *
148
+ * Devuelve `emitirTareas`: el corredor de tareas NACE antes que este cable (lo necesita
149
+ * `arrancarConsolaWeb` para pasarle `corredorDeTareas`), así que su `alCambiar` —un cambio
150
+ * de estado que nace DENTRO del lazo, no de una petición del cliente— no tiene manera de
151
+ * alcanzar el `emitir` de aquí salvo que se lo devuelva. Es la misma costura que
152
+ * `vestibulo.alCambiarEstadoDeSesion`, solo que el emisor nace después que quien lo
153
+ * necesita en vez de antes.
154
+ */
155
+ export function montarRutas(servidor, vestibulo, opciones = {}) {
156
+ const informar = opciones.informar ?? (() => { });
157
+ const hayCredencialDe = opciones.hayCredencial ?? (() => false);
158
+ /** Los personalizados dados de alta AHORA: se relee en cada mensaje, porque esta misma
159
+ * ventana los da de alta y de baja sin reiniciar nada. */
160
+ const personalizados = () => opciones.proveedoresPersonalizados?.() ?? [];
161
+ const idsPersonalizados = () => personalizados().map((d) => idDeProveedorPersonalizado(d.slug));
162
+ const baseUrlDe = (proveedor) => compatibleConOpenAi(proveedor, personalizados())?.baseUrl;
163
+ /**
164
+ * ¿Existe ese proveedor? De serie, o personalizado DADO DE ALTA. Una sola función porque
165
+ * había dos puertas con el mismo criterio y una se quedó atrás: la del catálogo devolvía
166
+ * en silencio para un `custom:…`, así que pulsar un proveedor recién dado de alta en la
167
+ * pastilla se quedaba en «consultando…» para siempre — el botón muerto de siempre, y con
168
+ * el modelo pulsándolo.
169
+ */
170
+ const proveedorConocido = (id) => PROVEEDORES.includes(id)
171
+ || personalizados().some((d) => idDeProveedorPersonalizado(d.slug) === id);
172
+ /** Los tres estados que se pueden AFIRMAR de una credencial. Ver `ProveedorDeModelos`. */
173
+ const credencialDe = (proveedor) => SIN_CREDENCIAL.has(proveedor) ? "nativa" : hayCredencialDe(proveedor) ? "puesta" : "falta";
174
+ /**
175
+ * Los clientes vivos. Fue una sola ranura y era un fallo: el último en conectar dejaba
176
+ * mudos a los anteriores sin decírselo —medido con una pestaña local y otra por un túnel—.
177
+ * Emitir es escribirle a todos; el transporte hace lo mismo por su lado.
178
+ */
179
+ const clientes = new Set();
180
+ /**
181
+ * Los clientes por su IDENTIFICADOR, y qué tareas está mirando cada uno.
182
+ *
183
+ * **Existe porque el SSE y el `POST /accion` son dos peticiones distintas.** El único
184
+ * sitio con un sumidero en la mano es la ruta del SSE, así que sin un identificador que
185
+ * el cliente repita en su `{clase:"mirar"}` el servidor no sabría a qué pestaña
186
+ * engancharle la mirada — y tendría que emitirle el transcript de una tarea de fondo a
187
+ * TODO el mundo, que es justo lo que no puede pasar (los actos de una tarea no aparecen
188
+ * en el chat de nadie). Lo elige el cliente, una vez por conexión, igual que elige el id
189
+ * de una tarea al subirle un adjunto por `POST /adjunto`.
190
+ *
191
+ * El id **nunca es una ruta ni un nombre de fichero**: es solo la clave de este mapa, y
192
+ * las entradas nacen y mueren con la conexión del SSE — no crece con los mensajes.
193
+ *
194
+ * `mirando` guarda el ENVOLTORIO que se enganchó al corredor (no el sumidero pelado),
195
+ * porque es el que hay que pasarle a `dejarDeMirar` para quitar ESE y no los demás.
196
+ */
197
+ const porIdDeCliente = new Map();
198
+ /**
199
+ * La consola a la que está ENGANCHADO el cable ahora mismo. No se recalcula al cerrar:
200
+ * hay que desconectar la que se conectó, no la que sea la actual en ese momento — entre
201
+ * medias puede haberse abierto un proyecto.
202
+ */
203
+ let adjunto;
204
+ /** Lo elegido en el wizard hasta ahora. Ninguno se inventa: sin elección, no hay lista. */
205
+ let entornoElegido;
206
+ let proyectoElegido;
207
+ let proyectos = [];
208
+ let ramas = [];
209
+ /**
210
+ * El paso de cuenta ya conducido en ESTE proceso. Hace falta porque `origenDeTrabajo` se
211
+ * congela al construir el vestíbulo: `pasosPendientes()` seguiría diciendo «cuenta»
212
+ * después de haberla dado, y el wizard volvería a pedirla en cada reconexión.
213
+ *
214
+ * Se marca cuando `pasoDeCuenta()` TERMINA, no cuando se lanza — medido: marcarlo antes
215
+ * de esperar significaba que recargar a mitad del selector (o de teclear la clave) daba
216
+ * por «hecho» un paso que en realidad ni se había contestado ni se iba a volver a
217
+ * ofrecer, y el usuario se quedaba con el modelo por omisión sin que nada se lo dijera.
218
+ */
219
+ /**
220
+ * Si hay turno en vuelo AHORA en la sesión que se está mirando. Hace falta porque quien
221
+ * conecta a mitad de turno tiene que enterarse: el mensaje que lo anunció ya pasó.
222
+ *
223
+ * Se DERIVA de la consola en foco, no se cachea en una variable que un flanco actualiza.
224
+ * Era lo segundo, y desde que cambiar de sesión no mata el turno anterior una copia se
225
+ * queda vieja en el caso que más importa: volver a una sesión que está trabajando no es un
226
+ * flanco de turno —el turno no empezó ni acabó—, así que la variable habría dicho «no hay
227
+ * nada corriendo» y `adjuntar` habría encendido el compositor delante de un agente que
228
+ * escribe.
229
+ */
230
+ const turnoEnVuelo = () => vestibulo.proyectoAbierto()?.turnoEnVuelo === true;
231
+ let cuentaHecha = false;
232
+ /**
233
+ * El propio `pasoDeCuenta()` en vuelo, compartido entre conexiones. Sin esto, dos
234
+ * conexiones solapadas (dos pestañas, o la reconexión que llega antes de que la vieja
235
+ * se haya desenganchado del todo) verían las dos `cuentaHecha` en `false` y llamarían
236
+ * a `pasoDeCuenta()` cada una la suya — dos `asistenteDeModelo` a la vez apilando DOS
237
+ * resolutores en la MISMA cola FIFO de `consolaWeb.ts#seleccionar`, de forma que la
238
+ * respuesta de una pestaña resolvería la pregunta de la OTRA. Una sola llamada real,
239
+ * y la segunda conexión se limita a esperar la que ya está en marcha.
240
+ */
241
+ let cuentaEnCurso;
242
+ /**
243
+ * Lo que falló en el último paso del alta. Viaja en el propio mensaje del alta porque el
244
+ * fallo pertenece al paso que lo produjo: el acto de sistema que `informar` deja aterriza
245
+ * en la Trayectoria —la otra pestaña—, y el wizard repintaba el mismo paso sin decir nada.
246
+ */
247
+ let aviso;
248
+ const destinoActual = () => vestibulo.proyectoAbierto() ?? vestibulo.consola;
249
+ const emitir = (mensaje) => {
250
+ for (const cliente of clientes)
251
+ cliente(mensaje);
252
+ };
253
+ /**
254
+ * Lo que valga esa promesa, o nada si tarda demasiado. **No la cancela**: sigue viva y su
255
+ * valor se usará en el siguiente anuncio — lo que se acota es la ESPERA, no el trabajo.
256
+ */
257
+ const conPlazo = async (promesa, ms) => {
258
+ let reloj;
259
+ try {
260
+ return await Promise.race([
261
+ promesa,
262
+ new Promise((resuelto) => {
263
+ reloj = setTimeout(() => resuelto(undefined), ms);
264
+ }),
265
+ ]);
266
+ }
267
+ finally {
268
+ // Sin esto el temporizador mantiene vivo el proceso hasta que vence, y son segundos
269
+ // en cada anuncio: el alta se emite dos veces por turno.
270
+ if (reloj !== undefined)
271
+ clearTimeout(reloj);
272
+ }
273
+ };
274
+ /**
275
+ * El mensaje de alta: qué falta, y con qué elegirlo.
276
+ *
277
+ * Con proyecto YA abierto no falta nada, y se dice con `pasos` vacío SIN mirar
278
+ * `pasosPendientes()` — es el comportamiento de siempre (el atajo de `--guion` sobre un
279
+ * proyecto offline, por ejemplo, abre directo y nunca debería enseñar el alta, tenga o
280
+ * no cuenta/entorno resueltos) y no algo que este cambio deba tocar.
281
+ *
282
+ * Cambio de rumbo del usuario, para cuando NO hay proyecto abierto: el paso de proyecto
283
+ * salió del alta, así que `pasos` sale DIRECTO de `pasosPendientes()` —que ya nunca
284
+ * incluye «proyecto»— en vez de esperar a que se abra uno. Antes de este cambio
285
+ * `pasos: []` solo pasaba con un proyecto abierto, y el cliente usaba esa implicación
286
+ * para pintar la maqueta completa; ahora también pasa sin proyecto (con cuenta y
287
+ * entorno resueltos), así que la implicación ya no basta y `proyectoAbierto` viaja
288
+ * aparte (`transporte.ts` lo documenta) para que el cliente sepa si esperar una
289
+ * elección en la barra o pintar la sesión de verdad.
290
+ *
291
+ * «cuenta» NO se anuncia nunca al wizard: ese paso lo conduce `conducirCuenta` sobre el
292
+ * selector y el secreto, que es lo que mantiene la clave en su único mensaje y lo que
293
+ * hace que se elija TAMBIÉN el modelo. El wizard sigue sabiendo pintarlo por si otra
294
+ * piel se lo manda; esta no.
295
+ */
296
+ const anunciarAlta = async () => {
297
+ const abierto = vestibulo.proyectoAbierto();
298
+ const proyectoAbierto = abierto !== undefined;
299
+ // Se lee del disco EN CADA anuncio y no se cachea al abrir: `configurarModoInicial`
300
+ // puede escribirlo después de abrir (el alta de un proyecto cloud), y una copia
301
+ // tomada antes se quedaría diciendo lo de antes.
302
+ const modo = abierto === undefined ? undefined : modoDeProyecto(abierto.raiz);
303
+ // CUÁL es el abierto, deducido de su raíz: la que le tocaría a cada proyecto de la
304
+ // lista se calcula con la misma función que la creó. Guardar el id aparte al abrirlo
305
+ // sería una segunda fuente de verdad que se queda vieja el día que alguien abra por
306
+ // otro camino.
307
+ // El `entorno` se copia a una constante porque TypeScript no puede saber que
308
+ // `entornoElegido` —una variable del cierre, que otro mensaje puede cambiar— sigue
309
+ // definida dentro del callback.
310
+ const entorno = entornoElegido;
311
+ const activo = abierto === undefined || entorno === undefined
312
+ ? undefined
313
+ : proyectos.find((p) => {
314
+ try {
315
+ return vestibulo.raizDeProyecto(entorno, p.nombre) === abierto.raiz;
316
+ }
317
+ catch {
318
+ return false;
319
+ }
320
+ })?.id;
321
+ const pendientes = proyectoAbierto ? [] : await vestibulo.pasosPendientes();
322
+ // Lo que ya había sin commitear al ABRIR. `abierto` se capturó arriba, antes de este
323
+ // `await`: `anunciarAlta` no va en la cola del vestíbulo, así que entre medias puede
324
+ // haberse abierto otro proyecto y el dato tiene que ser del que se anuncia. La promesa
325
+ // está cacheada en la consola y no rechaza nunca.
326
+ /**
327
+ * **Ese aviso AVISA, no FRENA — y esperarlo sin plazo lo convertía en un freno.**
328
+ * Detrás hay un `git status --untracked-files=all` sobre el proyecto del usuario, que en
329
+ * una copia grande con muchos ficheros sin rastrear puede tardar lo que quiera. Y este
330
+ * `await` está en el `finally` de abrir una sesión, justo antes de apagar el indicador:
331
+ * mientras no resolviera, la barra se quedaba en «abriendo…» PARA SIEMPRE y la consola
332
+ * no dejaba abrir nada más (medido en la pantalla del usuario).
333
+ *
334
+ * Con plazo, lo que se pierde es el aviso de esta pasada y nada más: el alta se reanuncia
335
+ * en los DOS flancos de cada turno, así que en cuanto la promesa esté lista el dato sale
336
+ * en el siguiente anuncio. Ausente ya significaba «no consta» en las cuatro capas, que es
337
+ * exactamente lo que hay que decir mientras no se sabe.
338
+ */
339
+ const trabajo = abierto === undefined ? undefined : await conPlazo(abierto.trabajoAlAbrir, MS_DE_TRABAJO_AL_ABRIR);
340
+ const pasos = pendientes.includes("entorno") ? ["entorno"] : [];
341
+ // Las raíces que trabajan, UNA vez para todos los proyectos del anuncio.
342
+ const trabajandoAqui = raicesTrabajando();
343
+ /** La raíz que le tocaría a ese proyecto, o nada si no se puede calcular (sin entorno
344
+ * elegido, o un nombre que `rutaDeWorkspace` rechaza). No se afirma sobre lo que no se
345
+ * puede nombrar. */
346
+ const raizDeProyectoOSilencio = (nombre) => {
347
+ if (entorno === undefined)
348
+ return undefined;
349
+ try {
350
+ return vestibulo.raizDeProyecto(entorno, nombre);
351
+ }
352
+ catch {
353
+ return undefined;
354
+ }
355
+ };
356
+ emitir({
357
+ clase: "alta",
358
+ pasos,
359
+ proveedores: PROVEEDORES.map((p) => ({ id: p, nombre: nombreDeProveedor(p) })),
360
+ entornos: [...vestibulo.opcionesDeEntorno()],
361
+ // Los registrados de verdad, además de los ofrecidos: la ventana de ajustes los
362
+ // lista, y la barra lateral llevaba enseñando la lista OFRECIDA como si fuera ésta.
363
+ registrados: vestibulo.entornosRegistrados().map((e) => ({
364
+ id: e.id,
365
+ nombre: e.nombre,
366
+ url: e.url,
367
+ // Solo si el entorno lo dice: ausente es «no lo he elegido», y el cliente aplica su
368
+ // omisión. Mandar `[]` en su lugar sería decir «ninguno», que es otra cosa.
369
+ ...(e.proyectos === undefined ? {} : { proyectos: [...e.proyectos] }),
370
+ })),
371
+ // Cada proyecto con las sesiones de su copia local. Se recalcula en cada anuncio: una
372
+ // sesión nueva aparece en cuanto se abre, sin que nadie recargue.
373
+ proyectos: proyectos.map((p) => {
374
+ const sesiones = entornoElegido === undefined ? [] : sesionesDelProyecto(p.nombre);
375
+ return {
376
+ ...p,
377
+ ...(sesiones.length === 0 ? {} : { sesiones }),
378
+ /**
379
+ * Y la marca del PROYECTO solo cuando no hay fila que marcar.
380
+ *
381
+ * La deducción es la misma por raíz que `activo`, con la misma función que la creó
382
+ * —un id guardado aparte se queda viejo el día que alguien abra por otro camino—,
383
+ * pero se calla si alguna de sus sesiones ya lleva la marca: decirlo en los dos
384
+ * niveles a la vez es la duplicación que el usuario señaló, y la que importa es la
385
+ * fila, que es la que se abre. Queda para lo que la fila NO puede decir: una
386
+ * sesión que todavía no está en el índice, o sea la de una TAREA de fondo antes de
387
+ * su primer volcado — las de persona entran ya con el mensaje.
388
+ */
389
+ ...(raizDeProyectoOSilencio(p.nombre) !== undefined &&
390
+ trabajandoAqui.has(raizDeProyectoOSilencio(p.nombre)) &&
391
+ !sesiones.some((se) => se.trabajando === true)
392
+ ? { trabajando: true }
393
+ : {}),
394
+ // Si ya está bajado, abrirlo no necesita ni rama ni descarga: es lo que decide
395
+ // qué enseña la ventana de sesión nueva, y decidirlo en el cliente exigiría
396
+ // que supiera dónde vive la copia local.
397
+ ...(hayCopiaLocal(p.nombre) ? { local: true } : {}),
398
+ };
399
+ }),
400
+ ramas,
401
+ proyectoAbierto,
402
+ ...(modo === undefined ? {} : { modo }),
403
+ ...(entornoElegido === undefined ? {} : { entornoActivo: entornoElegido }),
404
+ ...(activo === undefined ? {} : { proyectoActivo: activo }),
405
+ ...(abierto?.sesion === undefined ? {} : { sesionActiva: abierto.sesion }),
406
+ ...(abierto?.dispositivo === undefined ? {} : { dispositivoActivo: abierto.dispositivo }),
407
+ ...(abierto?.historica === true ? { historica: true } : {}),
408
+ // Este proyecto escribe sin preguntar. Viaja en el ALTA y no solo en el aviso del
409
+ // turno porque la decisión se tomó una vez, quizá hace meses, y quien se sienta hoy
410
+ // tiene que saberlo ANTES de pedir nada — no después, con los ficheros ya cambiados.
411
+ // Se calcula aquí y no se guarda: las tres condiciones incluyen si hay alguien
412
+ // delante, y eso cambia con la conexión.
413
+ ...(abierto !== undefined &&
414
+ seAplicaSinAprobacion({
415
+ raiz: abierto.estadoDeSesion.raiz,
416
+ sinAprobacion: cargarSettings().settings.sinAprobacion,
417
+ cloudstudio: cloudstudioDelProyecto(abierto.estadoDeSesion.raiz),
418
+ // `true` sin más, y hay que decir por qué no es una simplificación: el alta se
419
+ // EMITE, o sea que solo llega a un cliente conectado, y la consola web declara
420
+ // `interactivo: true` por la misma razón (`consolaWeb.ts`). Quien decide de verdad
421
+ // en cada ronda es el ejecutor, que vuelve a preguntar las tres condiciones.
422
+ interactivo: true,
423
+ })
424
+ ? { sinAprobacion: true }
425
+ : {}),
426
+ // Solo si SE MIRÓ y había algo. Las otras tres respuestas —limpio, sin git, no se
427
+ // pudo— se callan: un mensaje en cada apertura limpia es ruido en casi todas, y el
428
+ // aviso dejaría de leerse justo el día que importa.
429
+ ...(trabajo?.via === "git" && trabajo.ficheros !== undefined && trabajo.ficheros.length > 0
430
+ ? {
431
+ trabajoAlAbrir: {
432
+ ficheros: trabajo.ficheros.slice(0, FICHEROS_DEL_AVISO),
433
+ total: trabajo.ficheros.length,
434
+ },
435
+ }
436
+ : {}),
437
+ ...(vestibulo.nombre === undefined ? {} : { nombre: vestibulo.nombre }),
438
+ ...(aviso === undefined ? {} : { aviso }),
439
+ });
440
+ };
441
+ /**
442
+ * Los catálogos ya consultados en ESTE proceso, por proveedor. Se guardan porque cada
443
+ * uno es una llamada de red: abrir el menú dos veces no la repite. Un fallo también se
444
+ * recuerda —como fallo— hasta que alguien lo vuelva a pedir a propósito.
445
+ */
446
+ const catalogos = new Map();
447
+ /**
448
+ * El estado de modelos: qué está en vigor y qué se puede elegir.
449
+ *
450
+ * `actual` sale del estado de sesión de la consola ABIERTA y de ningún sitio más. Sin
451
+ * proyecto abierto no hay sesión y por tanto no hay modelo que afirmar: el campo se va
452
+ * y el cliente pinta «Elige modelo» en vez de una fila muerta.
453
+ */
454
+ const emitirModelos = () => emitir(mensajeDeModelos());
455
+ /**
456
+ * Los subagentes en vigor, compuestos pero sin mandar.
457
+ *
458
+ * `cargarAgentes` cada vez y no una lista cacheada: los `.md` se pueden editar a mano con
459
+ * la consola abierta, y una lista congelada al arrancar haría que el usuario creyera que
460
+ * su cambio no se aplicó — que es exactamente lo que pasa con el agente, que también los
461
+ * relee en cada construcción.
462
+ */
463
+ const mensajeDeAgentes = () => {
464
+ const abierto = vestibulo.proyectoAbierto();
465
+ const { agentes, problemas } = cargarAgentes(abierto?.raiz);
466
+ return {
467
+ clase: "agentes",
468
+ agentes: agentes.map((a) => ({
469
+ nombre: a.nombre,
470
+ descripcion: a.descripcion,
471
+ motor: a.motor,
472
+ ...(a.modelo === undefined ? {} : { modelo: a.modelo }),
473
+ soloLectura: a.soloLectura,
474
+ skills: a.skills,
475
+ instrucciones: a.instrucciones,
476
+ origen: a.origen,
477
+ })),
478
+ problemas,
479
+ };
480
+ };
481
+ const emitirAgentes = () => emitir(mensajeDeAgentes());
482
+ /**
483
+ * Alta, cambio y borrado de un subagente.
484
+ *
485
+ * El ÁMBITO decide la carpeta, y viene del cliente en vez de deducirse: con un proyecto
486
+ * abierto valen las dos, y adivinar cuál quiere el usuario es cómo un «revisor» pensado
487
+ * para todos los proyectos acaba escondido en uno. Sin proyecto abierto solo cabe el
488
+ * global, y pedir el de proyecto se dice en vez de escribirlo en cualquier sitio.
489
+ */
490
+ const atenderAgente = (mensaje) => {
491
+ const abierto = vestibulo.proyectoAbierto();
492
+ if (mensaje.ambito === "proyecto" && abierto === undefined) {
493
+ informar("no hay ningún proyecto abierto: ese subagente solo se puede guardar como global");
494
+ return;
495
+ }
496
+ const base = mensaje.ambito === "proyecto" ? abierto.raiz : homedir();
497
+ try {
498
+ if (mensaje.accion === "borrar") {
499
+ informar(borrarAgente(base, mensaje.agente.nombre)
500
+ ? `subagente «${mensaje.agente.nombre}» borrado`
501
+ : `«${mensaje.agente.nombre}» no existía en ${mensaje.ambito}`);
502
+ }
503
+ else {
504
+ // Se valida escribiendo Y VOLVIENDO A LEER, no confiando en el formulario: es el
505
+ // mismo fichero que se puede editar a mano, así que el cargador es la única
506
+ // autoridad sobre si vale. Si no pasara, el agente desaparecería al siguiente
507
+ // arranque sin que nadie hubiera hecho nada raro.
508
+ const candidato = {
509
+ ...mensaje.agente,
510
+ motor: mensaje.agente.motor,
511
+ origen: mensaje.ambito,
512
+ };
513
+ const comprobado = leerAgente(candidato.nombre, escribirAgente(candidato), mensaje.ambito);
514
+ if ("error" in comprobado) {
515
+ informar(`no se guarda «${candidato.nombre}»: ${comprobado.error}`);
516
+ return;
517
+ }
518
+ guardarAgente(base, candidato);
519
+ informar(`subagente «${candidato.nombre}» guardado en ${mensaje.ambito}`);
520
+ }
521
+ }
522
+ catch (error) {
523
+ informar(error instanceof Error ? error.message : String(error));
524
+ }
525
+ emitirAgentes();
526
+ };
527
+ /** El mensaje de modelos, compuesto pero sin mandar: `adjuntar` se lo da SOLO al cliente
528
+ * que acaba de llegar, y el resto de sitios lo emite a todos. */
529
+ const mensajeDeModelos = () => {
530
+ const abierto = vestibulo.proyectoAbierto();
531
+ const trabajo = abierto === undefined ? undefined : resolver(abierto.estadoDeSesion.fuentes).trabajo;
532
+ // Dos preguntas, dos campos. `actual` es la SESIÓN —y sin sesión no hay nada que
533
+ // afirmar—, y `porDefecto` es lo que usarán las nuevas, que existe SIEMPRE que se
534
+ // pueda leer el config global. Sin el segundo, Ajustes enseñaría «sin elegir» sobre una
535
+ // máquina con un defecto escrito, justo en la pantalla donde se configura.
536
+ const porDefecto = opciones.modeloPorDefecto?.();
537
+ return {
538
+ clase: "modelos",
539
+ ...(trabajo === undefined ? {} : { actual: `${trabajo.proveedor}/${trabajo.modelo}` }),
540
+ ...(porDefecto === undefined ? {} : { porDefecto }),
541
+ proveedores: [...PROVEEDORES, ...idsPersonalizados()].map((p) => ({
542
+ id: p,
543
+ nombre: nombreDeProveedor(p, personalizados()),
544
+ credencial: credencialDe(p),
545
+ // Solo se marca lo que se puede afirmar: sin puerto para mirarlo, no se dice que
546
+ // esté en el fichero (y la interfaz no ofrecerá borrarla).
547
+ ...(opciones.credencialEnFichero?.(p) === true && opciones.borrarCredencial !== undefined
548
+ ? { enFichero: true }
549
+ : {}),
550
+ // La URL viaja SOLO en los personalizados, y no es una ruta de la máquina: es lo
551
+ // que tecleó el usuario, y es justo lo que hay que ver al lado de su clave para
552
+ // saber a dónde va. Los de serie no la llevan: su URL está en el repo.
553
+ ...(esProveedorPersonalizado(p)
554
+ ? { personalizado: true, ...(baseUrlDe(p) === undefined ? {} : { baseUrl: baseUrlDe(p) }) }
555
+ : {}),
556
+ ...(catalogos.get(p) ?? {}),
557
+ })),
558
+ };
559
+ };
560
+ /**
561
+ * Engancha el cable a la consola que toque, con el transcript entero por delante.
562
+ *
563
+ * **La invariante es que TODOS los clientes vivos están registrados en `adjunto`**, y por
564
+ * eso hay dos casos y no uno:
565
+ *
566
+ * - Llega un cliente nuevo (`recien`): se registra ÉL en la consola actual y la ráfaga
567
+ * —transcript, comandos, modelos— es suya sola. Mandársela a todos repetiría el
568
+ * transcript en las pestañas que ya lo tienen.
569
+ * - Cambia la consola (se abre un proyecto): se registran TODOS los clientes en la nueva
570
+ * y la ráfaga va a todos, porque todos cambian de transcript.
571
+ *
572
+ * Registrar solo al recién llegado en el primer caso y a nadie en el segundo fue un fallo
573
+ * MEDIDO: al abrir un proyecto, la consola nueva se quedaba sin ningún sumidero, así que
574
+ * el turno corría —el agente trabajaba de verdad— y no salía nada por pantalla. Escribir
575
+ * y que no pasara nada.
576
+ */
577
+ const adjuntar = (recien) => {
578
+ if (recien !== undefined && informeDeDispositivos === undefined)
579
+ void atenderDispositivos().catch(contar);
580
+ if (recien !== undefined)
581
+ comprobarLosSinClave();
582
+ const destino = destinoActual();
583
+ const cambiaDeConsola = adjunto !== destino;
584
+ /**
585
+ * `soltar` y NO `desconectar`, y es la pieza que hace que cambiar de sesión no rompa la
586
+ * que se deja atrás. `desconectar()` significa «se ha ido el humano»: rechaza la
587
+ * aprobación que hubiera delante y contesta cadena vacía a todo el que esperara. Aquí
588
+ * el humano no se ha ido — está mirando otra sesión, y la de antes puede tener un turno
589
+ * corriendo. Ver `Transporte.soltar`.
590
+ */
591
+ if (adjunto !== undefined && cambiaDeConsola)
592
+ adjunto.soltar();
593
+ adjunto = destino;
594
+ // A quién hay que registrar y a quién hay que darle la ráfaga: al recién llegado, o a
595
+ // todos si lo que cambió fue la consola.
596
+ const destinatarios = recien !== undefined ? [recien] : cambiaDeConsola ? [...clientes] : [];
597
+ let actos = destino.conectar();
598
+ for (const cliente of destinatarios)
599
+ actos = destino.conectar(cliente);
600
+ const modelos = mensajeDeModelos();
601
+ // Los subagentes van en la ráfaga por lo mismo que los modelos: la ventana de ajustes
602
+ // se puede abrir en cuanto conecta, y sin esto enseñaría una lista vacía hasta que algo
603
+ // los cambiara — que es indistinguible de «no tienes ninguno».
604
+ const agentes = mensajeDeAgentes();
605
+ // La cola de tareas, si esta ejecución las tiene: la misma regla que `agentes`, sin
606
+ // esto la pestaña de tareas se quedaría vacía hasta el primer cambio de la cola.
607
+ const tareas = mensajeDeTareas();
608
+ /**
609
+ * Lo consumido por la sesión que se va a pintar. Va en la ráfaga por lo mismo que los
610
+ * modelos: una pestaña que conecta a mitad de sesión no vio los cambios anteriores, y
611
+ * sin esto su contador se quedaría a cero hasta el siguiente token — que en un turno
612
+ * largo son minutos. `undefined` es «no consta» y entonces no se manda nada.
613
+ */
614
+ const consumoDeLaSesion = vestibulo.consumoDeSesion();
615
+ for (const cliente of destinatarios) {
616
+ // El orden importa: primero el transcript y luego el estado que pinta cada
617
+ // disparador. Al reconectar se manda entero: el cliente tira sus proyecciones al
618
+ // caerse el SSE (`store.ts#marcarDesconectado`), así que hay que repoblarlas.
619
+ cliente({ clase: "reemision", actos: [...actos] });
620
+ cliente(modelos);
621
+ cliente(agentes);
622
+ if (tareas !== undefined)
623
+ cliente(tareas);
624
+ if (consumoDeLaSesion !== undefined) {
625
+ cliente({
626
+ clase: "consumo",
627
+ modelo: consumoDeLaSesion.modelo,
628
+ externo: consumoDeLaSesion.externo,
629
+ ventana: ventanaDeAhora(consumoDeLaSesion.contexto),
630
+ });
631
+ }
632
+ // La foto de la máquina, si ya se tomó. Si no, se dispara abajo UNA vez y llega a
633
+ // todos por el SSE cuando termine: no se espera aquí, que son varios procesos.
634
+ if (informeDeDispositivos !== undefined) {
635
+ cliente({ clase: "dispositivos", informe: informeDeDispositivos, ajustes: ajustesDeDispositivos() });
636
+ }
637
+ // Y si hay turno corriendo, se dice: quien conecta a mitad no vio el mensaje que lo
638
+ // anunció, y sin esto vería el compositor encendido y sin borde —«no pasa nada»—
639
+ // mientras lo que escribiera se quedaba en la cola.
640
+ cliente({ clase: "turno", activo: turnoEnVuelo() });
641
+ /**
642
+ * Y la aprobación que esta consola tenga EN VUELO. Hace falta desde que volver a una
643
+ * sesión de segundo plano es posible: su turno pudo pararse en un modal mientras
644
+ * nadie miraba, y ese mensaje se emitió una vez —no está en la traza, es el único que
645
+ * lleva contenido de fichero— así que sin reemitirlo el compositor se quedaría
646
+ * apagado delante de un turno que espera una decisión que no se puede dar.
647
+ */
648
+ for (const pendiente of destino.mensajesDeAprobacion())
649
+ cliente(pendiente);
650
+ }
651
+ };
652
+ /**
653
+ * El paso de cuenta, conducido por el asistente de siempre sobre esta consola. Se lanza
654
+ * suelto (no se espera) porque el manejador del SSE tiene que devolver para que el
655
+ * navegador reciba las preguntas que este asistente va a emitir.
656
+ */
657
+ const conducirCuenta = async (porPeticion = false) => {
658
+ const pendientes = await vestibulo.pasosPendientes();
659
+ // Que este alta NO conduzca el modelo es distinto de que ya esté conducido: lo primero
660
+ // pasa cuando el modelo viene de fuera (una bandera, la config global) y entonces no
661
+ // hay nada que volver a preguntar. Callarlo dejaría un botón «Modelo ✓» que al pulsarlo
662
+ // no hace nada — el fallo mudo de siempre.
663
+ const noProcede = !pendientes.includes("cuenta") || vestibulo.proyectoAbierto() !== undefined;
664
+ if (noProcede && porPeticion) {
665
+ aviso =
666
+ "el modelo de esta sesión no lo decide el alta: viene de una bandera o de la configuración. Cámbialo con «/modelo» cuando entres.";
667
+ informar(aviso);
668
+ }
669
+ if (noProcede || cuentaHecha)
670
+ return;
671
+ if (cuentaEnCurso === undefined) {
672
+ cuentaEnCurso = vestibulo
673
+ .pasoDeCuenta()
674
+ .then((resultado) => {
675
+ // «Hecho» solo si de verdad se resolvió. `cancelado` es lo que devuelve el
676
+ // asistente cuando ya no queda nadie a quien preguntar —con `exigirEleccion`
677
+ // no sale por cancelar, solo por `eof()`—, o sea el silencio de una pestaña
678
+ // que se fue a mitad del selector: la SIGUIENTE conexión tiene que poder
679
+ // intentarlo de verdad, no heredar un «ya se preguntó» que nadie contestó.
680
+ //
681
+ // `sin-preguntar` SÍ cuenta como hecho: significa que no había nada que
682
+ // preguntar (la piel no tiene selector, o ya había elección). Tratarlo como
683
+ // pendiente dejaría fuera para siempre a quien no puede contestar.
684
+ if (resultado !== "cancelado")
685
+ cuentaHecha = true;
686
+ })
687
+ .finally(() => {
688
+ cuentaEnCurso = undefined;
689
+ });
690
+ }
691
+ await cuentaEnCurso;
692
+ };
693
+ const contar = (error) => {
694
+ informar(error instanceof Error ? error.message : String(error));
695
+ };
696
+ /**
697
+ * Quien entra DIRECTO al Dashboard (las tres condiciones ya cumplidas) se salta el paso
698
+ * "entorno" del wizard entero, y con él la única línea que hasta ahora rellenaba
699
+ * `proyectos` (`atenderAlta`, más abajo). Sin esto la barra se quedaba con "Sin
700
+ * proyectos que enseñar aquí todavía" aunque el entorno estuviera registrado de sobra —
701
+ * la puerta que se acaba de abrir dejaba al usuario dentro y sin nada que hacer, peor
702
+ * que el wizard que se quitó. Se resuelve el PRIMERO de `entornosRegistrados()`: la
703
+ * misma asunción que ya hace `App.tsx` del lado cliente para `entornoActivo`, con el
704
+ * mismo motivo — no hay señal de «cuál es el activo» cuando hay más de uno registrado.
705
+ *
706
+ * `entornoElegido` SOLO se fija si `proyectosDe` sale bien: con un token muerto sin
707
+ * refresco o la red caída, la siguiente reconexión tiene que poder reintentarlo, no
708
+ * heredar un «ya se intentó» que se quedó en `proyectos: []` para siempre. Esto puede
709
+ * abrir el navegador de verdad si el token necesita reautenticar —`conectarCloudStudio`
710
+ * ya lo hace así—, y es lo correcto: un token vivo no toca el puerto de callback en
711
+ * absoluto (arreglado en `agent/cloudstudioMcp.ts#abrirCliente`), así que dos conexiones
712
+ * seguidas no chocan por intentarlo cada una.
713
+ */
714
+ const poblarProyectosSiProcede = async () => {
715
+ if (vestibulo.proyectoAbierto() !== undefined)
716
+ return;
717
+ if (entornoElegido !== undefined)
718
+ return;
719
+ const [primero] = vestibulo.entornosRegistrados();
720
+ if (primero === undefined)
721
+ return;
722
+ try {
723
+ proyectos = await vestibulo.proyectosDe(primero.id);
724
+ entornoElegido = primero.id;
725
+ }
726
+ catch (error) {
727
+ aviso = error instanceof Error ? error.message : String(error);
728
+ contar(error);
729
+ }
730
+ };
731
+ /**
732
+ * «Dime qué sirve este proveedor.» Una llamada de red por proveedor, cacheada, y el
733
+ * fallo de uno se guarda como suyo: el menú lo lista inservible y los demás siguen
734
+ * elegibles. Nunca lanza — quien pide un catálogo no puede tumbar el cable.
735
+ */
736
+ /**
737
+ * La foto de la máquina y UNA detección en vuelo como mucho. Dos pestañas que conectan a
738
+ * la vez no lanzan dos rondas de adb y xcrun: la segunda se engancha a la promesa de la
739
+ * primera. Y la respuesta va a TODOS —la máquina es la misma para todos—.
740
+ */
741
+ let informeDeDispositivos;
742
+ /** La ruta de cada herramienta es una ruta del home del usuario: no sale por el cable. */
743
+ const sinRutas = (informe) => ({
744
+ ...informe,
745
+ herramientas: informe.herramientas.map(({ ruta: _ruta, ...resto }) => resto),
746
+ });
747
+ /** Los cuatro nombres conocidos y nada más: lo que llega por el cable no elige binario. */
748
+ const esNombreDeHerramienta = (v) => v === "adb" || v === "emulator" || v === "xcrun" || v === "devicectl";
749
+ const mensajeDeTareas = () => {
750
+ if (opciones.colaDeTareas === undefined)
751
+ return undefined;
752
+ const otro = opciones.corredorDeTareas?.ejecutaOtroProceso?.();
753
+ return {
754
+ clase: "tareas",
755
+ lista: opciones.colaDeTareas.listar().map(filaDeTarea),
756
+ concurrencia: opciones.concurrenciaDeTareas?.() ?? CONCURRENCIA_POR_OMISION,
757
+ corriendoAqui: opciones.corredorDeTareas?.corriendoAqui() ?? false,
758
+ // Ausente = no se sabe, y no se sintetiza: con `false` a la mínima, la pantalla diría
759
+ // «nadie las ejecuta» de una máquina donde sí las ejecuta otro proceso.
760
+ ...(otro === undefined ? {} : { ejecutaOtroProceso: otro }),
761
+ };
762
+ };
763
+ const emitirTareas = () => {
764
+ const m = mensajeDeTareas();
765
+ if (m !== undefined)
766
+ emitir(m);
767
+ };
768
+ /**
769
+ * Un id de proyecto a su tripleta completa, o nada si no se puede resolver.
770
+ *
771
+ * La MISMA resolución que `atenderSesion` ya hace para abrir un proyecto por id
772
+ * (`proyectos.find` + `vestibulo.raizDeProyecto`): crear una tarea necesita la raíz para
773
+ * poder ejecutarla algún día, y esa raíz es una ruta de la máquina que el cliente nunca
774
+ * ha mandado — solo manda el id. Sin entorno elegido, o con un id que no está en la
775
+ * lista vigente de `proyectos`, no hay tripleta que resolver: falla CERRADO, nunca se
776
+ * inventa una raíz.
777
+ */
778
+ const proyectoParaTarea = (id) => {
779
+ if (entornoElegido === undefined)
780
+ return undefined;
781
+ const identidad = proyectos.find((p) => p.id === id);
782
+ if (identidad === undefined)
783
+ return undefined;
784
+ try {
785
+ return { id: identidad.id, raiz: vestibulo.raizDeProyecto(entornoElegido, identidad.nombre), nombre: identidad.nombre };
786
+ }
787
+ catch {
788
+ // `entornoElegido` dejó de estar registrado entre medias: no se sabe la raíz.
789
+ return undefined;
790
+ }
791
+ };
792
+ /**
793
+ * Crea una tarea `nueva`. Vive aquí y no en una opción de fuera por lo que documenta
794
+ * `OpcionesDeMontaje.colaDeTareas`: la resolución de `proyecto` necesita el estado de
795
+ * este cierre. Sin proyecto resoluble no se escribe nada, y se DICE — la misma regla que
796
+ * una transición imposible: no se falla en silencio.
797
+ */
798
+ const atenderCrearTarea = (proyectoId, peticion, encargo,
799
+ /**
800
+ * El id del BORRADOR bajo el que el navegador subió los adjuntos, si subió alguno.
801
+ *
802
+ * Los bytes viajan por `POST /adjunto` ANTES de que la tarea exista —hay que tenerlos en
803
+ * disco antes de encolarla, porque crear dispara `revisarTareas()` y el corredor puede
804
+ * arrancarla en el acto—, así que el cliente elige un id y este es el momento de
805
+ * ADOPTARLO. Lo que hace que eso sea seguro son las dos guardas de abajo, y las mismas
806
+ * que la propia ruta de subida aplica: forma de segmento llano, y que no sea ya una
807
+ * tarea.
808
+ */
809
+ borrador) => {
810
+ if (opciones.colaDeTareas === undefined)
811
+ return undefined;
812
+ const proyecto = proyectoParaTarea(proyectoId);
813
+ if (proyecto === undefined) {
814
+ informar(`no se pudo crear la tarea: el proyecto «${proyectoId}» no se pudo resolver`);
815
+ return undefined;
816
+ }
817
+ const lista = opciones.colaDeTareas.listar();
818
+ if (borrador !== undefined && !esBorradorLibre(borrador, lista)) {
819
+ // **No se cae a un id nuevo en silencio**, y esa es la decisión: seguir con un uuid
820
+ // dejaría los adjuntos que la persona acaba de subir colgando de una carpeta que
821
+ // ninguna tarea nombra — encolaría el trabajo sin ellos y sin decirlo.
822
+ informar("no se pudo crear la tarea: ese borrador de adjuntos no vale (o ya es una tarea)");
823
+ return undefined;
824
+ }
825
+ const id = borrador ?? randomUUID();
826
+ const nueva = {
827
+ id,
828
+ proyecto,
829
+ titulo: tituloDeTarea(peticion),
830
+ peticion,
831
+ encargo,
832
+ // Del DISCO y no de lo que diga el cliente: es la única fuente que sabe qué llegó de
833
+ // verdad y cuánto pesa. Ver `TareasEnDisco.listarAdjuntos`.
834
+ adjuntos: opciones.colaDeTareas.listarAdjuntos(id),
835
+ estado: "nuevo",
836
+ creada: new Date().toISOString(),
837
+ };
838
+ opciones.colaDeTareas.guardar([...lista, nueva]);
839
+ return { id: nueva.id };
840
+ };
841
+ /**
842
+ * ¿Se puede adoptar ese id de borrador? Segmento llano Y que no sea ya una tarea.
843
+ *
844
+ * Las dos mitades son la misma regla que la ruta de subida: el id lo elige el CLIENTE, así
845
+ * que la forma es lo que evita que se salga de la carpeta de la cola, y la segunda evita
846
+ * que un borrador aterrice sobre una tarea viva — una que el corredor puede estar
847
+ * ejecutando ahora mismo con su `/adjuntos/` montada.
848
+ */
849
+ const esBorradorLibre = (id, lista) => nombreDeAdjuntoAceptable(id) && !lista.some((t) => t.id === id);
850
+ /**
851
+ * La augmentación: una llamada al modelo, bajo demanda, con el proyecto RESUELTO.
852
+ *
853
+ * Vive aquí y no en el despachador de mensajes por lo mismo que `atenderCrearTarea`: la
854
+ * resolución de `{id, raiz, nombre}` necesita el estado de este cierre, y el cliente solo
855
+ * manda el id. Sin proyecto resoluble no se pregunta a nadie: se contesta el error, que es
856
+ * lo que la ventana pinta — nunca se inventa una raíz.
857
+ */
858
+ const atenderAugmentar = async (augmentar, proyectoId, texto, borrador) => {
859
+ const proyecto = proyectoParaTarea(proyectoId);
860
+ if (proyecto === undefined) {
861
+ emitir({
862
+ clase: "tarea",
863
+ accion: "augmentado",
864
+ error: `el proyecto «${proyectoId}» no se pudo resolver`,
865
+ });
866
+ return;
867
+ }
868
+ // Los adjuntos ya subidos, por NOMBRE y tipo. Del disco, igual que al crear.
869
+ const adjuntos = borrador === undefined || opciones.colaDeTareas === undefined || !nombreDeAdjuntoAceptable(borrador)
870
+ ? []
871
+ : opciones.colaDeTareas
872
+ .listarAdjuntos(borrador)
873
+ .map((a) => ({ nombre: a.nombre, ...(a.mime === undefined ? {} : { mime: a.mime }) }));
874
+ try {
875
+ const encargo = await augmentar({ texto, proyecto, adjuntos });
876
+ emitir({ clase: "tarea", accion: "augmentado", encargo });
877
+ }
878
+ catch (error) {
879
+ // **El MENSAJE si lo escribimos nosotros, el CÓDIGO si lo escribió el sistema**, que
880
+ // es la regla de `corredorDeTareas.ts#sinRutas`. Esto era `codigoDe(error)` a secas, y
881
+ // para un `ErrorDelAumentador` eso devuelve su `name`: la ventana enseñaba «No se pudo
882
+ // preparar el encargo (ErrorDelAumentador)», que no dice nada de lo que hay que
883
+ // arreglar. El mensaje de los nuestros está escrito para leerse y no lleva rutas; el de
884
+ // un error de Node sí las lleva, y de ese solo sale el `code`.
885
+ emitir({ clase: "tarea", accion: "augmentado", error: motivoLegible(error) });
886
+ }
887
+ };
888
+ /**
889
+ * Reintentar, descartar o terminar una tarea existente.
890
+ *
891
+ * **Descartar BORRA** y no comprueba el estado —no hay «cancelada» en `core/tareas.ts`—,
892
+ * ni siquiera si está `en-proceso`. Reintentar y terminar pasan por `conEstado`, que LANZA
893
+ * ante una transición imposible; aquí se atrapa y se DICE con `informar` — nunca se
894
+ * propaga al cliente, y nunca se escribe nada a medias.
895
+ *
896
+ * **Descartar CORTA el turno en vuelo ANTES de borrar (Task 14).** Antes de esto, el
897
+ * comentario de aquí decía que «el corredor ya cuenta con que una tarea corriendo se
898
+ * descarte por debajo» — y era cierto que CONTABA con ello (`corredorDeTareas.ts#revisar`
899
+ * no se rompía), pero contarlo no es lo mismo que MANEJARLO: el turno seguía corriendo de
900
+ * verdad, escribiendo en el proyecto de alguien sin que ninguna pantalla lo dijera, y su
901
+ * carpeta de adjuntos —montada viva como `/adjuntos/` para ese turno— se borraba en el
902
+ * acto por debajo suyo. `Corredor.cortar(id)` (medido y cableado en `corredorDeTareas.ts`,
903
+ * reusando `entrada.cortar`, lo mismo que ya usa `parar()`) se espera ANTES de
904
+ * `borrarTarea`, así que la carpeta de adjuntos solo se toca DESPUÉS de que la consola
905
+ * haya soltado su montaje — nunca antes: quitarle el suelo a un turno vivo es un fallo por
906
+ * sí solo, con independencia de todo lo demás.
907
+ *
908
+ * **Y si no se puede cortar a tiempo, NO se borra.** `Corredor.cortar` devuelve `false`
909
+ * cuando la tarea SÍ corría aquí y no soltó el proyecto dentro del plazo — la misma
910
+ * situación que `parar()` ya sabe contar sin mentir. Borrar de todos modos dejaría el
911
+ * turno huérfano exactamente igual que antes de este arreglo, solo que con menos excusa:
912
+ * se prefiere el aviso honesto («sigue en marcha, reinténtalo») a un corte que promete
913
+ * haber parado algo que no paró.
914
+ *
915
+ * **Y si corre en OTRO proceso, tampoco.** `corredorDeTareas?.corriendoAqui() === false`
916
+ * con la tarea `en-proceso` en disco solo puede significar eso —ESTE proceso solo sirve el
917
+ * dashboard, y el que de verdad la ejecuta no está aquí para preguntarle—; `Corredor.cortar`
918
+ * no tiene con qué alcanzarlo (el límite lo pone el sistema operativo, no esta función), así
919
+ * que forzar el borrado sería la misma orfandad de antes, disfrazada de arreglada. Es la
920
+ * MISMA regla que ya sigue `Vestibulo.borrarSesion` con la sesión de una tarea en curso:
921
+ * declinar con el motivo, no matar un turno ajeno porque alguien limpió una fila.
922
+ */
923
+ /**
924
+ * El transcript de una tarea, etiquetado con SU id.
925
+ *
926
+ * La etiqueta no es decoración: por este mismo cable llega el transcript de la sesión
927
+ * propia (`acto`, `sustitucion`, `reemision`), así que sin ella los actos de una tarea de
928
+ * fondo se mezclarían con la conversación de quien mira. Con `mirada` delante, el cliente
929
+ * los pinta en su panel y en ningún otro sitio.
930
+ *
931
+ * Y es una lista BLANCA por segunda vez: el transporte solo le manda a un mirón el
932
+ * transcript (`Transporte.mirar`), y aquí solo se traduce ese transcript. Una clase nueva
933
+ * del cable no llega a esta pantalla hasta que alguien la nombre en los dos sitios.
934
+ */
935
+ const etiquetarComoMirada = (tarea, enviar) => (mensaje) => {
936
+ if (mensaje.clase === "acto") {
937
+ enviar({ clase: "mirada", tarea, via: "alta", actos: [mensaje.acto] });
938
+ return;
939
+ }
940
+ if (mensaje.clase === "sustitucion") {
941
+ enviar({ clase: "mirada", tarea, via: "sustitucion", actos: [mensaje.acto] });
942
+ return;
943
+ }
944
+ if (mensaje.clase === "reemision") {
945
+ enviar({ clase: "mirada", tarea, via: "todos", actos: mensaje.actos });
946
+ }
947
+ };
948
+ /**
949
+ * Empezar o dejar de mirar en vivo lo que hace una tarea.
950
+ *
951
+ * **Es OPT-IN y de SOLO lectura, y las dos cosas son estructurales aquí.** No se abre
952
+ * ningún proyecto, no se muda el cable y no se le pasa NADA a la consola de la tarea: lo
953
+ * único que ocurre es que un sumidero se engancha a su transporte para recibir el
954
+ * transcript que ya se estaba guardando. El mensaje se ataja en `POST /accion` antes del
955
+ * `recibir` de la consola precisamente por eso — si cayera ahí, `correrConsola` podría
956
+ * acabar corriendo un turno sobre la consola de una tarea, y con un mirón enganchado su
957
+ * `eof()` diría que hay alguien a quien preguntar. Medido: `conectar` volvía ese `eof()`
958
+ * falso; `mirar` (`transporte.ts`) es el conjunto aparte que lo evita.
959
+ *
960
+ * Tres silencios a propósito, y ninguno es un error que contar:
961
+ * - **Un `cliente` que no consta**: es una pestaña que ya se fue, o un id viejo tras una
962
+ * reconexión. Un fallo de lookup.
963
+ * - **Una tarea que no corre AQUÍ** (`mirar` devuelve `undefined`): ya terminó, o la
964
+ * ejecuta el otro proceso. No se emite `{actos: []}`, que diría «corre y no ha hecho
965
+ * nada»; lo que esa tarea sí es ya lo cuenta el mensaje de la cola.
966
+ * - **Mirar dos veces lo mismo**: idempotente. Un doble clic o un efecto que se dispare
967
+ * dos veces no puede dejar dos sumideros del mismo cliente en el mismo turno.
968
+ */
969
+ const atenderMirar = (mensaje) => {
970
+ const cliente = porIdDeCliente.get(mensaje.cliente);
971
+ if (cliente === undefined)
972
+ return;
973
+ const yaEnganchado = cliente.mirando.get(mensaje.tarea);
974
+ if (!mensaje.ver) {
975
+ if (yaEnganchado === undefined)
976
+ return;
977
+ cliente.mirando.delete(mensaje.tarea);
978
+ // ESE envoltorio y no otro: la otra persona que mire la misma tarea sigue mirándola.
979
+ opciones.corredorDeTareas?.dejarDeMirar?.(mensaje.tarea, yaEnganchado);
980
+ return;
981
+ }
982
+ if (yaEnganchado !== undefined)
983
+ return;
984
+ const envoltorio = etiquetarComoMirada(mensaje.tarea, cliente.enviar);
985
+ const actos = opciones.corredorDeTareas?.mirar?.(mensaje.tarea, envoltorio);
986
+ if (actos === undefined)
987
+ return;
988
+ cliente.mirando.set(mensaje.tarea, envoltorio);
989
+ // El transcript de ese instante, SOLO al que lo pidió — el corredor lo devuelve en vez
990
+ // de emitirlo, igual que `conectar`, para no mandárselo a quien ya lo tenga.
991
+ cliente.enviar({ clase: "mirada", tarea: mensaje.tarea, via: "todos", actos: [...actos] });
992
+ };
993
+ const atenderAccionDeTarea = async (accion, id) => {
994
+ if (opciones.colaDeTareas === undefined)
995
+ return;
996
+ if (accion === "descartar") {
997
+ const actual = opciones.colaDeTareas.listar().find((t) => t.id === id);
998
+ if (actual?.estado === "en-proceso" && opciones.corredorDeTareas?.corriendoAqui() === false) {
999
+ informar(`no se pudo descartar «${actual.titulo}»: su turno lo ejecuta otro proceso, y no se puede cortar desde aquí`);
1000
+ return;
1001
+ }
1002
+ // Ausente = no hay corredor cableado en esta ejecución, y entonces no hay ningún
1003
+ // turno que pueda estar corriendo: seguro proceder, la misma lectura que «no había
1004
+ // nada en vuelo» dentro del propio corredor.
1005
+ const cortada = (await opciones.corredorDeTareas?.cortar(id)) ?? true;
1006
+ if (!cortada) {
1007
+ informar(`no se pudo descartar «${actual?.titulo ?? id}»: su turno no soltó el proyecto a tiempo — sigue en marcha, reinténtalo`);
1008
+ return;
1009
+ }
1010
+ opciones.colaDeTareas.borrarTarea(id);
1011
+ return;
1012
+ }
1013
+ const lista = opciones.colaDeTareas.listar();
1014
+ const actual = lista.find((t) => t.id === id);
1015
+ if (actual === undefined) {
1016
+ informar(`no se pudo ${accion}: la tarea ya no está en la cola`);
1017
+ return;
1018
+ }
1019
+ try {
1020
+ // **Terminar a mano pasa por `darPorBuenaAMano` y no por `conEstado` a secas**, y no
1021
+ // es cosmético: es la CUARTA forma de llegar a «Terminada» —sin verificador y sin
1022
+ // juez—, `conEstado` borra el `motivo`, y sin la marca la tarjeta resultante es
1023
+ // indistinguible de una entrega por la puerta completa. Ver `Tarea.terminadaAMano`.
1024
+ const siguiente = accion === "reintentar" ? conEstado(actual, "nuevo") : darPorBuenaAMano(actual);
1025
+ opciones.colaDeTareas.guardar(lista.map((t) => (t.id === id ? siguiente : t)));
1026
+ }
1027
+ catch (error) {
1028
+ // Una transición imposible SE IGNORA Y SE DICE: nunca se lanza hacia el cliente, y
1029
+ // nunca se escribe un estado a medias.
1030
+ informar(`no se pudo ${accion} la tarea «${actual.titulo}»: ${error instanceof Error ? error.message : String(error)}`);
1031
+ }
1032
+ };
1033
+ /**
1034
+ * «Se edita la tarea y se agrega el feedback del usuario» (§0 del diseño, textual):
1035
+ * añadir un feedback a una tarea «esperando feedback» la devuelve al lazo. Vive detrás de
1036
+ * `aplicarFeedback` (`agent/tareasEnDisco.ts`) y no repite su lógica aquí, por el mismo
1037
+ * motivo que `atenderCrearTarea`/`atenderAccionDeTarea` no reimplementan `conEstado`: la
1038
+ * regla de qué feedback vale y qué transición es legal está en una sola función, probada
1039
+ * sola y sin necesitar un servidor de mentira alrededor.
1040
+ */
1041
+ const atenderFeedbackDeTarea = (id, texto) => {
1042
+ if (opciones.colaDeTareas === undefined)
1043
+ return;
1044
+ const resultado = aplicarFeedback(opciones.colaDeTareas, id, texto);
1045
+ if (!resultado.hecho) {
1046
+ // Nunca se propaga al cliente, igual que una transición imposible: se DICE, y no se
1047
+ // escribe nada a medias.
1048
+ informar(`no se pudo añadir el feedback: ${resultado.motivo ?? "motivo desconocido"}`);
1049
+ }
1050
+ };
1051
+ /** Los ajustes de destino, o `{}` —que es «se miran todos»— si esta ejecución no los lee. */
1052
+ const ajustesDeDispositivos = () => opciones.ajustesDeDispositivos?.() ?? {};
1053
+ let deteccionEnVuelo;
1054
+ const atenderDispositivos = () => {
1055
+ if (opciones.detectarDispositivos === undefined)
1056
+ return Promise.resolve();
1057
+ if (deteccionEnVuelo !== undefined)
1058
+ return deteccionEnVuelo;
1059
+ const detectar = opciones.detectarDispositivos;
1060
+ deteccionEnVuelo = (async () => {
1061
+ try {
1062
+ informeDeDispositivos = sinRutas(await detectar());
1063
+ emitir({ clase: "dispositivos", informe: informeDeDispositivos, ajustes: ajustesDeDispositivos() });
1064
+ }
1065
+ catch (error) {
1066
+ // El detector reparte los fallos por herramienta y no lanza por ninguno; que
1067
+ // reviente entero es un bug suyo. Aquí solo se cuenta en el terminal: el escritorio
1068
+ // se queda en «consultando…» (o el botón en «Mirando…»), que es la verdad —no llegó
1069
+ // ninguna foto—, y no se emite una máquina vacía para disimularlo.
1070
+ informar(`no se pudo mirar qué dispositivos hay: ${error instanceof Error ? error.message : String(error)}`);
1071
+ }
1072
+ finally {
1073
+ deteccionEnVuelo = undefined;
1074
+ }
1075
+ })();
1076
+ return deteccionEnVuelo;
1077
+ };
1078
+ /**
1079
+ * Verificar la conexión con UN dispositivo, y volver a emitir la foto con lo que contestó.
1080
+ *
1081
+ * Cuatro reglas:
1082
+ * - **El id se resuelve contra la última MEDIDA**, igual que al elegir dispositivo de la
1083
+ * sesión: lo que se le pasa al verificador es el `Dispositivo` que el host midió, no la
1084
+ * cadena que llegó. Un id que no está se ignora en silencio — es una foto vieja del
1085
+ * cliente (desenchufaron el teléfono entre medias), no un error que contar.
1086
+ * - **NO se vuelve a medir.** La verificación vive dentro del informe, así que una medida
1087
+ * nueva se la llevaría — justo la que se acaba de hacer.
1088
+ * - **Solo se toca ESE dispositivo**: las verificaciones de los demás siguen donde
1089
+ * estaban. Se emite el informe entero porque es el mensaje que hay.
1090
+ * - **Y viaja a todos los clientes**, como la foto: la máquina es la misma para todos.
1091
+ */
1092
+ const atenderConexion = async (id) => {
1093
+ const verificar = opciones.verificarDispositivo;
1094
+ if (verificar === undefined || informeDeDispositivos === undefined)
1095
+ return;
1096
+ const dispositivo = informeDeDispositivos.dispositivos.find((d) => d.id === id);
1097
+ if (dispositivo === undefined)
1098
+ return;
1099
+ let resultado;
1100
+ try {
1101
+ resultado = await verificar(dispositivo);
1102
+ }
1103
+ catch (error) {
1104
+ // El verificador no lanza por diseño; que lo haga es un bug suyo, y aun así la
1105
+ // respuesta tiene que ser una respuesta: se dice que no se pudo, sin la ruta de nada.
1106
+ resultado = { ok: false, detalle: `no se pudo verificar (${codigoDe(error)})` };
1107
+ }
1108
+ const verificado = { ...resultado, medido: new Date().toISOString() };
1109
+ if (informeDeDispositivos === undefined)
1110
+ return;
1111
+ informeDeDispositivos = {
1112
+ ...informeDeDispositivos,
1113
+ dispositivos: informeDeDispositivos.dispositivos.map((d) => (d.id === id ? { ...d, verificado } : d)),
1114
+ };
1115
+ emitir({ clase: "dispositivos", informe: informeDeDispositivos, ajustes: ajustesDeDispositivos() });
1116
+ };
1117
+ const atenderCatalogo = async (proveedor) => {
1118
+ if (!proveedorConocido(proveedor))
1119
+ return;
1120
+ const id = proveedor;
1121
+ if (opciones.catalogoDeModelos === undefined) {
1122
+ catalogos.set(id, { error: "esta ejecución no puede consultar catálogos de modelos" });
1123
+ emitirModelos();
1124
+ return;
1125
+ }
1126
+ try {
1127
+ const modelos = await opciones.catalogoDeModelos(id);
1128
+ catalogos.set(id, { modelos: modelos.map((m) => ({ id: m.id, ...(m.nombre === undefined ? {} : { nombre: m.nombre }) })) });
1129
+ }
1130
+ catch (error) {
1131
+ // `ErrorCatalogoModelos` es publicable por contrato: nunca lleva la clave ni el
1132
+ // cuerpo remoto (`agent/catalogoModelos.ts`).
1133
+ catalogos.set(id, { error: error instanceof Error ? error.message : String(error) });
1134
+ }
1135
+ emitirModelos();
1136
+ };
1137
+ /**
1138
+ * Prueba, UNA vez por proceso, los proveedores que no llevan clave.
1139
+ *
1140
+ * «¿Puedo usar Ollama?» no la contesta ninguna credencial —no lleva ninguna—: la contesta
1141
+ * si el demonio responde, y eso solo se sabe pidiéndole el catálogo. Sin esto, el
1142
+ * proveedor por OMISIÓN de esta consola no aparecería nunca entre los comprobados, que es
1143
+ * lo que la pastilla enseña.
1144
+ *
1145
+ * **Y solo esos.** La regla de «el catálogo se pide bajo demanda» está escrita porque cada
1146
+ * uno es una llamada de red; aquí la excepción se gana sola: Ollama es `localhost` y un
1147
+ * personalizado sin clave es el endpoint que el usuario levantó en su máquina. A los de
1148
+ * pago no se les pregunta al arrancar — ahí la credencial ya responde.
1149
+ *
1150
+ * Una vez por PROCESO y no por cliente: la respuesta se guarda en `catalogos`, y una
1151
+ * segunda pestaña no vuelve a llamar. Y no se espera: el resultado llega a todos por el
1152
+ * SSE cuando esté, igual que la foto de la máquina.
1153
+ */
1154
+ let comprobados = false;
1155
+ const comprobarLosSinClave = () => {
1156
+ if (comprobados || opciones.catalogoDeModelos === undefined)
1157
+ return;
1158
+ comprobados = true;
1159
+ for (const p of [...PROVEEDORES, ...idsPersonalizados()]) {
1160
+ if (credencialDe(p) === "puesta" || catalogos.has(p))
1161
+ continue;
1162
+ // Los de serie que no llevan clave (`SIN_CREDENCIAL`) y los personalizados a los que
1163
+ // nadie se la ha puesto: en los dos casos, preguntar es la única forma de saberlo.
1164
+ if (!SIN_CREDENCIAL.has(p) && !esProveedorPersonalizado(p))
1165
+ continue;
1166
+ void atenderCatalogo(p).catch(contar);
1167
+ }
1168
+ };
1169
+ /**
1170
+ * Las sesiones guardadas de un proyecto de este entorno. Sin entorno elegido no hay raíz
1171
+ * que calcular, y sin copia local la lista es vacía — que es la verdad, no un fallo.
1172
+ */
1173
+ /**
1174
+ * Qué sesiones tienen un turno en marcha AHORA, por su id.
1175
+ *
1176
+ * Se pregunta a las consolas vivas y no se guarda en ninguna parte: es un estado de este
1177
+ * instante, y una copia se quedaría diciendo que una sesión trabaja después de que su
1178
+ * turno acabara. Solo entran las que tienen `sesion` —o sea entrada en el índice—, porque
1179
+ * una fila que la barra no pinta no se puede marcar; la de un primer turno que aún no ha
1180
+ * volcado nada aparecerá en cuanto exista, que es el mismo retraso que ya tiene su fila.
1181
+ */
1182
+ const sesionesTrabajando = () => {
1183
+ const trabajando = new Set();
1184
+ for (const consola of vestibulo.proyectosAbiertos()) {
1185
+ if (consola.turnoEnVuelo && consola.sesion !== undefined)
1186
+ trabajando.add(consola.sesion);
1187
+ }
1188
+ return trabajando;
1189
+ };
1190
+ /**
1191
+ * Y las RAÍCES con un turno en marcha, que no es la misma pregunta.
1192
+ *
1193
+ * Una sesión nueva no tiene fila en el índice hasta que vuelca su primer acto, así que el
1194
+ * caso más común —abrir, pedir algo, irse a otro proyecto— no tiene ninguna sesión que
1195
+ * marcar y la barra no enseñaría nada. La raíz sí se sabe desde el primer instante.
1196
+ */
1197
+ const raicesTrabajando = () => new Set(vestibulo.proyectosAbiertos().filter((c) => c.turnoEnVuelo).map((c) => c.raiz));
1198
+ /**
1199
+ * Las raíces cuya siembra de consumos ya se intentó, para no repetirla en cada alta.
1200
+ *
1201
+ * El alta se anuncia DOS veces por turno y recorre todos los proyectos, así que sin esto
1202
+ * cada anuncio releería el índice de cada proyecto y mediría sus `.jsonl` — un trabajo
1203
+ * síncrono en el bucle de eventos, multiplicado por el número de proyectos, dos veces por
1204
+ * turno. Una raíz se intenta UNA vez por proceso, que es lo que basta: lo que la siembra
1205
+ * deja sin rellenar lo rellena `anotarActo` en cuanto esa sesión se usa.
1206
+ */
1207
+ const raicesSembradas = new Set();
1208
+ const sesionesDelProyecto = (nombre) => {
1209
+ if (entornoElegido === undefined)
1210
+ return [];
1211
+ try {
1212
+ const raiz = vestibulo.raizDeProyecto(entornoElegido, nombre);
1213
+ // ANTES de leer la lista, para que este mismo alta ya lleve las cifras de las sesiones
1214
+ // viejas: la siembra escribe el índice y `sesionesDe` lo relee, así que no hace falta
1215
+ // ningún reanuncio — el anuncio que la dispara es el que las enseña.
1216
+ if (!raicesSembradas.has(raiz)) {
1217
+ raicesSembradas.add(raiz);
1218
+ try {
1219
+ sembrarConsumosPendientes(raiz);
1220
+ }
1221
+ catch {
1222
+ // Sembrar es PRESENTACIÓN: lo peor que puede pasar es que una sesión vieja no
1223
+ // enseñe cifra hasta su próximo turno. Blankear la barra entera por un índice roto
1224
+ // sería convertir un adorno que falta en una lista que no está.
1225
+ }
1226
+ }
1227
+ // Viaja un BOOLEANO y no el id de la tarea: la fila lleva una marca, no el nombre de
1228
+ // la tarea —en 280 px no cabe—, así que el id se queda en el host por la misma regla
1229
+ // que la ruta de una herramienta o el pid del corredor. `deTarea` ausente es «no
1230
+ // consta» y no «es una conversación»: no la lleva ninguna sesión anterior a la marca.
1231
+ const trabajando = sesionesTrabajando();
1232
+ return vestibulo.sesionesDe(raiz).map((s) => ({
1233
+ id: s.id,
1234
+ titulo: s.titulo,
1235
+ ...(s.ultimoTurno === undefined ? {} : { ultimoTurno: s.ultimoTurno }),
1236
+ ...(s.tarea === undefined ? {} : { deTarea: true }),
1237
+ ...(trabajando.has(s.id) ? { trabajando: true } : {}),
1238
+ ...(s.consumo === undefined ? {} : { consumo: s.consumo }),
1239
+ }));
1240
+ }
1241
+ catch {
1242
+ return [];
1243
+ }
1244
+ };
1245
+ /**
1246
+ * ¿Existe ya la copia local de ese proyecto? Es `.xonecode/config.json` en su raíz: lo
1247
+ * que `completarProyecto` escribe ENTERO antes de bajar nada.
1248
+ *
1249
+ * El predicado es el del vestíbulo (`esProyectoEnDisco`) y no una copia: `abrirParaTarea`
1250
+ * decide con él si una tarea puede abrir esa raíz, y dos versiones de «esto es un
1251
+ * proyecto» divergen el día que una se afine — con el alta diciendo que hay copia local
1252
+ * y la puerta de tareas diciendo que no.
1253
+ */
1254
+ const hayCopiaLocal = (nombre) => {
1255
+ if (entornoElegido === undefined)
1256
+ return false;
1257
+ try {
1258
+ return esProyectoEnDisco(vestibulo.raizDeProyecto(entornoElegido, nombre));
1259
+ }
1260
+ catch {
1261
+ return false;
1262
+ }
1263
+ };
1264
+ /**
1265
+ * Cambiar de entorno activo: el de cuyos proyectos se habla. Trae su listado consigo
1266
+ * —eso es una conexión con CloudStudio— y limpia lo del anterior: dejar los proyectos del
1267
+ * entorno viejo bajo el nombre del nuevo sería la peor mentira posible en esta barra.
1268
+ *
1269
+ * Y por eso mismo ANUNCIA, como abrir una sesión: es la misma espera de red sin nada que
1270
+ * pintar en medio, y con un daño de más — el `<select>` de la barra va controlado por el
1271
+ * `alta`, así que durante la espera se quedaba clavado en el entorno VIEJO. Medido en el
1272
+ * navegador: 1.480 ms con el valor viejo y ni una señal. El usuario lo dijo con esas
1273
+ * palabras: «hay un delay pero no mostramos un loading o busy animation en ningún lado».
1274
+ * El campo `entorno` es lo que el cliente necesita para ENSEÑAR el que se ha pedido.
1275
+ *
1276
+ * El anuncio va antes de la primera espera y el apagado en el `finally`, con el alta
1277
+ * DELANTE del flanco de bajada: al revés hay un hueco en el que ya no hay indicador y
1278
+ * todavía no ha llegado el estado nuevo. En el `finally` porque un cambio que falla
1279
+ * —token muerto, red caída— también tiene que apagarlo, y ahí es donde el `select` vuelve
1280
+ * solo al entorno que sigue siendo el de verdad.
1281
+ */
1282
+ const atenderEntornoActivo = async (entorno) => {
1283
+ aviso = undefined;
1284
+ // Antes de la primera espera, no después: entre elegir y aquí no hay nada que pintar.
1285
+ anunciarAbriendo({ entorno });
1286
+ try {
1287
+ const nuevos = await vestibulo.proyectosDe(entorno);
1288
+ entornoElegido = entorno;
1289
+ proyectoElegido = undefined;
1290
+ ramas = [];
1291
+ proyectos = nuevos;
1292
+ }
1293
+ catch (error) {
1294
+ // `entornoElegido` NO se toca si falla: con un token muerto o la red caída, seguir
1295
+ // enseñando lo del entorno anterior es la verdad, y el aviso dice qué pasó.
1296
+ aviso = error instanceof Error ? error.message : String(error);
1297
+ contar(error);
1298
+ }
1299
+ finally {
1300
+ await anunciarAlta().catch(contar);
1301
+ anunciarAbriendo();
1302
+ }
1303
+ };
1304
+ /**
1305
+ * Los proyectos de UN entorno, SIN hacerlo activo.
1306
+ *
1307
+ * El hermano de `atenderEntornoActivo`, y lo que los separa es todo lo que este NO hace:
1308
+ * no toca `entornoElegido`, ni `proyectos`, ni `ramas`, ni `proyectoElegido`, ni pone
1309
+ * `aviso`. Es una pregunta para las casillas de una pestaña de Ajustes, y mudar el entorno
1310
+ * activo desde ahí le cambiaría la barra —y los proyectos de los que se habla— a quien
1311
+ * esté trabajando en otro servidor. Por eso la aserción que sostiene el diseño en
1312
+ * `arranque.test.ts` es que `alta.entornoActivo` no cambia.
1313
+ *
1314
+ * No se cachea, a propósito: cada apertura de pestaña pregunta. Con caché, la pestaña
1315
+ * podría contradecir a la barra en cuanto alguien cree un proyecto en Studio, y aquí hay
1316
+ * una sola fuente. Es la diferencia con el catálogo de modelos, que sí se cachea porque
1317
+ * la pastilla se abre constantemente y cada consulta es una API de pago.
1318
+ *
1319
+ * El fallo va EN la respuesta y no en el `aviso` global: es de ese entorno, y los demás
1320
+ * siguen usables.
1321
+ */
1322
+ const atenderProyectosDeEntorno = async (entorno) => {
1323
+ try {
1324
+ const suyos = await vestibulo.proyectosDe(entorno);
1325
+ emitir({
1326
+ clase: "proyectosDeEntorno",
1327
+ entorno,
1328
+ proyectos: suyos.map((p) => ({
1329
+ id: p.id,
1330
+ nombre: p.nombre,
1331
+ ...(p.compartido === undefined ? {} : { compartido: p.compartido }),
1332
+ })),
1333
+ });
1334
+ }
1335
+ catch (error) {
1336
+ contar(error);
1337
+ emitir({
1338
+ clase: "proyectosDeEntorno",
1339
+ entorno,
1340
+ // Sin `proyectos`: ausente es «no se pudo preguntar», que no es una lista vacía.
1341
+ error: error instanceof Error ? error.message : String(error),
1342
+ });
1343
+ }
1344
+ };
1345
+ /**
1346
+ * Abrir una sesión: la nombrada, o una nueva.
1347
+ *
1348
+ * Con copia local ya bajada no hay nada que dar de alta —ni rama que preguntar—, así que
1349
+ * se abre directamente y el cable se muda a su consola. Sin copia local se cae al camino
1350
+ * del alta, que es el único que sabe bajarla: se contestan las ramas y el cliente elige.
1351
+ */
1352
+ /**
1353
+ * Borrar una sesión guardada, o ponerle nombre.
1354
+ *
1355
+ * La raíz se CALCULA con `raizDeProyecto`, igual que al abrir, y no se toma del proyecto
1356
+ * abierto: se puede borrar una sesión de un proyecto que no es el que se está mirando, y
1357
+ * darlo por hecho borraría en el sitio equivocado. Sin entorno elegido no hay raíz que
1358
+ * calcular y se dice, en vez de escribir a ciegas.
1359
+ */
1360
+ const atenderAccionDeSesion = async (peticion) => {
1361
+ aviso = undefined;
1362
+ try {
1363
+ if (entornoElegido === undefined) {
1364
+ aviso = "no sé de qué entorno es ese proyecto";
1365
+ informar(aviso);
1366
+ return;
1367
+ }
1368
+ const identidad = proyectos.find((p) => p.id === peticion.proyecto);
1369
+ const raiz = vestibulo.raizDeProyecto(entornoElegido, identidad?.nombre ?? peticion.proyecto);
1370
+ if (peticion.accion === "borrar") {
1371
+ const { borrada, cerroLaAbierta, motivo } = await vestibulo.borrarSesion(raiz, peticion.sesion);
1372
+ // Un `motivo` es que el vestíbulo DECLINÓ, y entonces se dice ese motivo y no el
1373
+ // «ya no estaba» de siempre: la sesión sigue ahí, y contar lo contrario dejaría al
1374
+ // usuario creyendo que la fila se va a ir del listado. Va además a `aviso`, como
1375
+ // los demás rechazos de este camino.
1376
+ if (motivo !== undefined) {
1377
+ aviso = motivo;
1378
+ informar(motivo);
1379
+ return;
1380
+ }
1381
+ // Se dice lo que pasó, incluido el «no había nada»: un menú que borra y calla deja
1382
+ // dudando de si la fila se fue porque se borró o porque falló el listado.
1383
+ informar(borrada
1384
+ ? cerroLaAbierta
1385
+ ? "sesión borrada; era la que estabas mirando, así que se ha cerrado"
1386
+ : "sesión borrada"
1387
+ : "esa sesión ya no estaba");
1388
+ // Al cerrar la abierta, el cable se queda enganchado a una consola muerta: se muda
1389
+ // de vuelta al vestíbulo, que es lo que el cliente va a pintar (el escritorio).
1390
+ // Y es el TERCER sitio donde cambia qué raíz está bloqueada para las tareas: aquí no
1391
+ // se abre nada, así que la raíz queda LIBRE, y sin revisar la cola una tarea que
1392
+ // esperaba a esa persona se quedaría esperando al siguiente evento que no tiene nada
1393
+ // que ver — que en pantalla se lee como un cuelgue.
1394
+ if (cerroLaAbierta) {
1395
+ adjuntar();
1396
+ opciones.revisarTareas?.();
1397
+ }
1398
+ return;
1399
+ }
1400
+ if (!vestibulo.renombrarSesion(raiz, peticion.sesion, peticion.titulo)) {
1401
+ aviso = "no se pudo renombrar esa sesión";
1402
+ informar(aviso);
1403
+ }
1404
+ }
1405
+ catch (error) {
1406
+ aviso = error instanceof Error ? error.message : String(error);
1407
+ contar(error);
1408
+ }
1409
+ finally {
1410
+ await anunciarAlta().catch(contar);
1411
+ }
1412
+ };
1413
+ /**
1414
+ * Los dos flancos de «se está abriendo algo», emitidos como los del turno y por lo mismo:
1415
+ * el cliente no puede saber cuánto tarda esto —de unos cientos de milisegundos a los
1416
+ * minutos de una descarga— y sin señal la interfaz se queda quieta después de un clic.
1417
+ */
1418
+ /**
1419
+ * Un aviso en la CONVERSACIÓN que se está mirando, como acto de sistema — y solo si no es
1420
+ * lo último que ya se dijo.
1421
+ *
1422
+ * Las dos mitades vienen de la pantalla del usuario. La primera, porque `alta.aviso` por
1423
+ * este camino no lo pinta nadie (lo leen el wizard y la ventana de sesión nueva, y pulsar
1424
+ * una fila de la barra no abre ninguna de las dos), así que un rechazo sería un clic mudo.
1425
+ * La segunda, porque un botón que rechaza invita a insistir: medido, cinco clics dejaron
1426
+ * CINCO copias del mismo párrafo en el chat, y una pared de texto repetido dice menos que
1427
+ * una línea. Se compara con el último acto y nada más: dos avisos distintos, o el mismo
1428
+ * más tarde en la conversación, sí se dicen — es el mismo criterio que la bitácora de
1429
+ * turno, donde un aviso que salta cuando no ha pasado nada enseña a ignorarlos.
1430
+ */
1431
+ const decirEnLaConversacion = (texto) => {
1432
+ const abierta = vestibulo.proyectoAbierto();
1433
+ if (abierta === undefined)
1434
+ return;
1435
+ const ultimo = abierta.actos().at(-1);
1436
+ if (ultimo?.tipo === "sistema" && ultimo.texto === texto)
1437
+ return;
1438
+ abierta.consola.consola.escribir(`${texto}\n`);
1439
+ };
1440
+ const anunciarAbriendo = (que) => {
1441
+ emitir(que === undefined ? { clase: "abriendo", activo: false } : { clase: "abriendo", activo: true, ...que });
1442
+ };
1443
+ const atenderSesion = async (peticion) => {
1444
+ aviso = undefined;
1445
+ // Antes de la primera espera, no después: entre el clic y aquí no hay nada que pintar.
1446
+ anunciarAbriendo({
1447
+ proyecto: peticion.proyecto,
1448
+ ...(peticion.sesion === undefined ? {} : { sesion: peticion.sesion }),
1449
+ });
1450
+ try {
1451
+ if (entornoElegido === undefined) {
1452
+ aviso = "elige antes el entorno del que sale el proyecto";
1453
+ informar(aviso);
1454
+ return;
1455
+ }
1456
+ const identidad = proyectos.find((p) => p.id === peticion.proyecto);
1457
+ const nombre = identidad?.nombre ?? peticion.proyecto;
1458
+ const raiz = vestibulo.raizDeProyecto(entornoElegido, nombre);
1459
+ // El MISMO predicado que `hayCopiaLocal` y que la puerta de tareas: era la tercera
1460
+ // copia del literal, y la que decide si aquí se abre o se pregunta la rama.
1461
+ if (!esProyectoEnDisco(raiz)) {
1462
+ // Todavía no está bajado: el alta es quien sabe hacerlo, y necesita la rama.
1463
+ proyectoElegido = peticion.proyecto;
1464
+ // La identidad ENTERA, no el id: el servidor abre por nombre. `identidad` ya está
1465
+ // resuelta unas líneas más arriba contra el listado.
1466
+ ramas = await vestibulo.ramasDe(entornoElegido, identidad ?? peticion.proyecto);
1467
+ return;
1468
+ }
1469
+ await vestibulo.abrirProyecto({ raiz, ...(peticion.sesion === undefined ? {} : { sesion: peticion.sesion }) });
1470
+ // El cable se muda a la consola del proyecto, como en el alta: sin esto el usuario
1471
+ // mira un transcript vivo cuyas aprobaciones se rechazan solas al otro lado.
1472
+ adjuntar();
1473
+ // Y la cola se vuelve a mirar: este proyecto queda bloqueado para las tareas —gana la
1474
+ // persona— y el que estuviera abierto antes acaba de quedar libre.
1475
+ opciones.revisarTareas?.();
1476
+ }
1477
+ catch (error) {
1478
+ aviso = error instanceof Error ? error.message : String(error);
1479
+ /**
1480
+ * Y se DICE en la conversación que se está mirando, porque `alta.aviso` por este
1481
+ * camino no lo pinta nadie: lo leen el wizard y la ventana de sesión nueva, y un clic
1482
+ * en una fila de la barra no abre ninguna de las dos. Medido leyendo `App.tsx`, y
1483
+ * alcanzable de verdad desde que un proyecto puede declinar por estar trabajando —el
1484
+ * rechazo de una sesión de tarea en curso ya recorría este mismo camino mudo.
1485
+ *
1486
+ * Se escribe como acto de SISTEMA, que es el canal por el que el chat ya enseña la
1487
+ * respuesta a un comando y los avisos de honestidad. Sin ninguna consola en foco no se
1488
+ * pinta en ninguna parte: para llegar ahí hace falta haber borrado la sesión que se
1489
+ * miraba mientras otro proyecto trabajaba, y ese hueco se queda declarado.
1490
+ */
1491
+ decirEnLaConversacion(aviso);
1492
+ contar(error);
1493
+ }
1494
+ finally {
1495
+ // El alta PRIMERO y el flanco de bajada después: al revés hay un hueco en el que ya
1496
+ // no hay indicador y todavía no ha llegado el estado nuevo. Y en el `finally`, así
1497
+ // que un fallo al abrir también lo apaga.
1498
+ await anunciarAlta().catch(contar);
1499
+ anunciarAbriendo();
1500
+ }
1501
+ };
1502
+ /**
1503
+ * «Ponme este modelo.»
1504
+ *
1505
+ * Lo que llega del cliente es la intención —`proveedor/modelo`— y no un comando: la
1506
+ * interfaz no habla en la sintaxis de otra piel ni se apunta actos de usuario que nadie
1507
+ * tecleó. Aplicarlo SÍ reusa el manejador de `/modelo` (`COMANDOS`, `cli/consola.ts`),
1508
+ * porque la precedencia entre banderas, ficheros y elecciones en caliente vive ahí y una
1509
+ * segunda implementación divergiría el primer día. Se encola la línea en el lazo, que es
1510
+ * el único que puede adoptar el estado nuevo, y el acuse que escribe el manejador es lo
1511
+ * que el usuario ve.
1512
+ *
1513
+ * **Y son DOS cosas a la vez, que es lo que este manejador arregla.** `/modelo` escribe la
1514
+ * bandera del estado de la SESIÓN: cambia en caliente y no toca el disco, así que
1515
+ * encolarlo y nada más dejaba la elección muriendo con la consola — se elegía, se
1516
+ * reiniciaba, y el modelo era el de antes. Aquí, además, se GUARDA como defecto de los
1517
+ * tres papeles en el `config.json` global, que es lo que sobrevive. Elegir en el
1518
+ * compositor y elegir en Ajustes son la misma frase, así que tienen que hacer lo mismo.
1519
+ *
1520
+ * Por eso también funciona SIN proyecto abierto, que es donde se configura: en el
1521
+ * vestíbulo no hay lazo al que encolar nada, pero sí hay un defecto que escribir, y la
1522
+ * próxima sesión lo recogerá. Antes esto era un rechazo —«no hay ninguna sesión abierta a
1523
+ * la que cambiarle el modelo»— sobre la única pantalla desde la que se puede fijar el
1524
+ * modelo por defecto.
1525
+ *
1526
+ * Si no hay escritor, se DICE que no se ha guardado en vez de callarlo: un ajuste que
1527
+ * parece puesto y no lo está es peor que uno que falta.
1528
+ */
1529
+ const atenderModelo = (id) => {
1530
+ try {
1531
+ parsear(id);
1532
+ }
1533
+ catch (error) {
1534
+ informar(error instanceof Error ? error.message : String(error));
1535
+ return;
1536
+ }
1537
+ const abierto = vestibulo.proyectoAbierto();
1538
+ if (opciones.guardarModeloGlobal === undefined) {
1539
+ if (abierto === undefined) {
1540
+ informar("esta ejecución no puede guardar el modelo por defecto");
1541
+ return;
1542
+ }
1543
+ abierto.consola.encolar(`/modelo ${id}`);
1544
+ informar(`modelo de esta sesión: ${id} · esta ejecución no lo guarda como defecto`);
1545
+ return;
1546
+ }
1547
+ try {
1548
+ for (const papel of PAPELES)
1549
+ opciones.guardarModeloGlobal(papel, id);
1550
+ }
1551
+ catch (error) {
1552
+ informar(error instanceof Error ? error.message : String(error));
1553
+ return;
1554
+ }
1555
+ if (abierto === undefined) {
1556
+ informar(`modelo por defecto: ${id} · lo usarán las sesiones nuevas`);
1557
+ }
1558
+ else {
1559
+ abierto.consola.encolar(`/modelo ${id}`);
1560
+ informar(`${id} guardado como modelo por defecto, además de aplicarlo a esta sesión`);
1561
+ }
1562
+ // El defecto acaba de cambiar, así que lo que el cliente pinta como «por defecto» se
1563
+ // quedaría viejo hasta el siguiente cambio de estado. Con sesión abierta el `actual`
1564
+ // llega solo —el lazo adopta el modelo y eso vuelve por `alCambiarEstadoDeSesion`—.
1565
+ emitirModelos();
1566
+ };
1567
+ /**
1568
+ * Pide la clave de un proveedor y la guarda, con la MISMA disciplina que el asistente de
1569
+ * cuenta: la criba de balde primero (`motivoDeClaveInaceptable`), y nada se escribe si no
1570
+ * pasa. La pregunta sale por la consola a la que está enganchado el cable —la del
1571
+ * proyecto si hay uno, la del vestíbulo si no—, así que llega como `clase: "secreto"` y
1572
+ * la clave vuelve por ese mismo mensaje y por ninguno más.
1573
+ *
1574
+ * Lo que NO se hace aquí es probarla contra el catálogo antes de escribir, como sí hace
1575
+ * el alta: ahí la elección de modelo obliga a listar de todos modos, y aquí el usuario
1576
+ * puede estar poniendo la clave de un proveedor que no va a usar todavía. El menú del
1577
+ * compositor la probará cuando toque, y su error se enseña donde se elige.
1578
+ */
1579
+ const pedirCredencial = async (proveedor) => {
1580
+ if (opciones.guardarCredencial === undefined) {
1581
+ informar("esta ejecución no puede guardar credenciales");
1582
+ return;
1583
+ }
1584
+ const consola = vestibulo.proyectoAbierto()?.consola.consola ?? vestibulo.consola.consola;
1585
+ const clave = (await consola.leerSecreto(`clave de ${proveedor}: `)).trim();
1586
+ // Cadena vacía es lo que responde una consola sin nadie al otro lado, y también el
1587
+ // usuario que da a Enter sin escribir: en los dos casos no se guarda nada y no se dice
1588
+ // nada más — quien canceló no necesita un sermón.
1589
+ if (clave === "")
1590
+ return;
1591
+ const motivo = motivoDeClaveInaceptable(clave);
1592
+ if (motivo !== undefined) {
1593
+ informar(`no se guardó nada: ${motivo}`);
1594
+ return;
1595
+ }
1596
+ try {
1597
+ const { ruta } = opciones.guardarCredencial(proveedor, clave);
1598
+ informar(`credencial de ${proveedor} guardada en ${ruta}`);
1599
+ }
1600
+ catch (error) {
1601
+ informar(error instanceof Error ? error.message : String(error));
1602
+ }
1603
+ emitirModelos();
1604
+ };
1605
+ /**
1606
+ * Dar de alta o retirar un proveedor personalizado.
1607
+ *
1608
+ * Cinco reglas, y ninguna es de formulario:
1609
+ * - **El identificador lo DERIVA el servidor** del nombre, con la función de `core/`.
1610
+ * Derivarlo también en el cliente sería una segunda copia de la regla, y divergiría.
1611
+ * - **Un slug que ya existe se RECHAZA**, no se pisa: dos endpoints distintos con el
1612
+ * mismo nombre acabarían compartiendo entrada en `auth.json`, o sea que la clave del
1613
+ * segundo viajaría al host del primero. Para cambiar una URL hay que dar de baja y
1614
+ * volver a dar de alta, y la baja dice que se lleva la clave.
1615
+ * - **La URL se comprueba con la MISMA regla que un MCP** (`motivoDeEndpointInaceptable`,
1616
+ * hoy en `core/`): https fuera de la máquina, http solo en loopback, sin credenciales
1617
+ * dentro. Loopback es el caso principal, no la excepción: LM Studio, llama.cpp y vLLM
1618
+ * escuchan ahí.
1619
+ * - **La baja se lleva la credencial.** Una clave en `auth.json` bajo un proveedor que ya
1620
+ * no existe no se puede mandar a ninguna parte, pero sigue siendo un secreto en disco y
1621
+ * nadie volvería a verla en la interfaz para borrarla.
1622
+ * - **El motivo vuelve por el cable**, no por el transcript: esta ventana no lo pinta.
1623
+ */
1624
+ const atenderProveedor = (mensaje) => {
1625
+ const contestar = (hecho, motivo) => emitir({ clase: "proveedor", hecho, ...(motivo === undefined ? {} : { motivo }) });
1626
+ if (mensaje.accion === "alta") {
1627
+ if (opciones.guardarProveedor === undefined) {
1628
+ contestar(false, "esta ejecución no puede dar de alta proveedores");
1629
+ return;
1630
+ }
1631
+ const nombre = typeof mensaje.nombre === "string" ? mensaje.nombre.trim() : "";
1632
+ const baseUrl = typeof mensaje.baseUrl === "string" ? mensaje.baseUrl.trim() : "";
1633
+ if (nombre === "") {
1634
+ contestar(false, "ponle un nombre para reconocerlo");
1635
+ return;
1636
+ }
1637
+ const slug = slugDesdeNombre(nombre);
1638
+ const malSlug = motivoDeSlugInaceptable(slug);
1639
+ if (malSlug !== undefined) {
1640
+ contestar(false, `de ese nombre no sale un identificador válido: ${malSlug}`);
1641
+ return;
1642
+ }
1643
+ const malUrl = motivoDeEndpointInaceptable(baseUrl);
1644
+ if (malUrl !== undefined) {
1645
+ contestar(false, malUrl);
1646
+ return;
1647
+ }
1648
+ if (personalizados().some((d) => d.slug === slug)) {
1649
+ contestar(false, `ya hay un proveedor con el identificador «${slug}»: dale otro nombre, o da de baja el que hay`);
1650
+ return;
1651
+ }
1652
+ try {
1653
+ opciones.guardarProveedor({ slug, nombre, baseUrl });
1654
+ }
1655
+ catch (error) {
1656
+ contestar(false, error instanceof Error ? error.message : String(error));
1657
+ return;
1658
+ }
1659
+ contestar(true);
1660
+ emitirModelos();
1661
+ return;
1662
+ }
1663
+ if (opciones.borrarProveedor === undefined) {
1664
+ contestar(false, "esta ejecución no puede dar de baja proveedores");
1665
+ return;
1666
+ }
1667
+ const slug = typeof mensaje.slug === "string" ? mensaje.slug : "";
1668
+ if (!personalizados().some((d) => d.slug === slug)) {
1669
+ // No está: una vista vieja del cliente, no un error que contar. Se contesta «hecho»
1670
+ // porque el estado que pedía —que no esté— es el que hay.
1671
+ contestar(true);
1672
+ emitirModelos();
1673
+ return;
1674
+ }
1675
+ try {
1676
+ opciones.borrarProveedor(slug);
1677
+ // Y su clave detrás, si esta ejecución puede: ver la regla de arriba.
1678
+ opciones.borrarCredencial?.(idDeProveedorPersonalizado(slug));
1679
+ }
1680
+ catch (error) {
1681
+ contestar(false, error instanceof Error ? error.message : String(error));
1682
+ return;
1683
+ }
1684
+ contestar(true);
1685
+ emitirModelos();
1686
+ };
1687
+ /**
1688
+ * Borrar una credencial. Se DICE lo que pasó por el transcript —incluido el caso en que
1689
+ * el fichero ya no la tenía— y se reemite el estado de modelos, que es lo que repinta el
1690
+ * punto. Si la variable de entorno la sigue llevando, eso también se dice: el punto se
1691
+ * quedará verde y callarlo parecería un fallo del botón.
1692
+ */
1693
+ const atenderCredencial = (mensaje) => {
1694
+ // De serie o personalizado DADO DE ALTA: un slug que no consta se ignora en silencio,
1695
+ // como un id de dispositivo que ya no está — es una vista vieja del cliente, no un
1696
+ // error que contar.
1697
+ if (!proveedorConocido(mensaje.proveedor))
1698
+ return;
1699
+ const proveedor = mensaje.proveedor;
1700
+ if (mensaje.accion === "pedir") {
1701
+ void pedirCredencial(proveedor).catch(contar);
1702
+ return;
1703
+ }
1704
+ if (opciones.borrarCredencial === undefined) {
1705
+ informar("esta ejecución no puede borrar credenciales");
1706
+ return;
1707
+ }
1708
+ try {
1709
+ const { ruta, borrada, quedaEnEntorno } = opciones.borrarCredencial(proveedor);
1710
+ informar(borrada
1711
+ ? `credencial de ${proveedor} borrada de ${ruta}`
1712
+ : `${proveedor} no tenía credencial en ${ruta}`);
1713
+ if (quedaEnEntorno) {
1714
+ informar(`ojo: ${proveedor} sigue con credencial puesta por una variable de entorno`);
1715
+ }
1716
+ }
1717
+ catch (error) {
1718
+ informar(error instanceof Error ? error.message : String(error));
1719
+ }
1720
+ emitirModelos();
1721
+ };
1722
+ /**
1723
+ * Los ficheros de la sesión abierta, o el parche de uno.
1724
+ *
1725
+ * Tres respuestas y no dos, porque son tres situaciones distintas y una sola lista vacía
1726
+ * las haría indistinguibles:
1727
+ *
1728
+ * - **`sin-empezar`**: hay proyecto abierto pero la sesión todavía no tiene id (el id
1729
+ * nace al volcar el primer acto, ver `vestibulo.ts`). No ha tocado nada, y eso SE SABE.
1730
+ * Decir «sin-marca» aquí diagnosticaría mal: la vista mandaría a comprobar si el
1731
+ * proyecto es un repo de git cuando lo único que pasa es que acabas de sentarte.
1732
+ * - **`sin-marca`**: no hay con qué comparar —sin proyecto abierto, o sin el puerto que
1733
+ * sabe mirar el repo—. No se sabe.
1734
+ * - **`git`**: comparado, y esto es lo que hay.
1735
+ */
1736
+ const atenderRevision = async (ruta) => {
1737
+ const abierto = vestibulo.proyectoAbierto();
1738
+ const sesion = abierto?.sesion;
1739
+ if (abierto === undefined || opciones.cambiosDeSesion === undefined) {
1740
+ emitir({ clase: "revision", via: "sin-marca", ficheros: [] });
1741
+ return;
1742
+ }
1743
+ if (sesion === undefined) {
1744
+ emitir({ clase: "revision", via: "sin-empezar", ficheros: [] });
1745
+ return;
1746
+ }
1747
+ if (ruta !== undefined) {
1748
+ const parche = await opciones.parcheDeSesion?.(abierto.raiz, sesion, ruta);
1749
+ // Sin parche que dar se dice con el texto vacío y no callando: el cliente tiene una
1750
+ // fila abierta esperando, y el silencio la deja cargando para siempre.
1751
+ emitir({
1752
+ clase: "parche",
1753
+ ruta,
1754
+ texto: parche?.texto ?? "",
1755
+ recortado: parche?.recortado ?? false,
1756
+ });
1757
+ return;
1758
+ }
1759
+ const { via, ficheros, mezclados } = await opciones.cambiosDeSesion(abierto.raiz, sesion);
1760
+ emitir({ clase: "revision", via, ficheros, ...(mezclados === undefined ? {} : { mezclados }) });
1761
+ };
1762
+ /**
1763
+ * La sincronización con CloudStudio del proyecto abierto, pedida desde un control
1764
+ * (la banda de arriba de Revisión, y «Volver a mirar»).
1765
+ *
1766
+ * **`estado` se mide aquí; `subir` y `bajar` se ENCOLAN**, y la asimetría es deliberada.
1767
+ * Medir es leer una ref de git que ya está en local (`lecturaDeSync`), así que encolarla
1768
+ * por el lazo sería abrir una sesión MCP —OAuth, red, credenciales— para contar lo que ya
1769
+ * se sabe. Subir y bajar, en cambio, son las MISMAS dos acciones del terminal, y por el
1770
+ * lazo salen con el MISMO plan, la MISMA guarda de árbol sucio y la MISMA aprobación
1771
+ * (`Pregunta.tsx`, que es lo que autoriza la escritura). Escribirlas otra vez aquí sería
1772
+ * un segundo camino de subida, que es justo donde el hueco de política que cierra
1773
+ * `core/cloudstudio.ts#PoliticaDeAprobacion` podría volver a abrirse.
1774
+ *
1775
+ * El lazo además las SERIALIZA: una línea encolada corre DESPUÉS del turno en vuelo, así
1776
+ * que un `/sync subir` no puede subir un fichero que el agente está escribiendo a medias.
1777
+ *
1778
+ * Y lo que NO se hace es volver a emitir la lectura después de encolar: el servidor no
1779
+ * sabe cuándo termina una línea de la cola, y un «3 ficheros por subir» recién emitido
1780
+ * tras pulsar Subir sería una cifra que nadie ha vuelto a medir. La pestaña se refresca
1781
+ * al volver a ella y al pulsar «Volver a mirar», que es cuando de verdad se mira.
1782
+ */
1783
+ const atenderSync = (accion) => {
1784
+ const abierto = vestibulo.proyectoAbierto();
1785
+ if (abierto === undefined) {
1786
+ emitir({ clase: "sync", error: "no hay ningún proyecto abierto que sincronizar" });
1787
+ return;
1788
+ }
1789
+ if (accion !== "estado") {
1790
+ abierto.consola.encolar(`/sync ${accion}`);
1791
+ return;
1792
+ }
1793
+ void lecturaDeSync(abierto.raiz, abierto.sesion).then(emitir).catch(contar);
1794
+ };
1795
+ /**
1796
+ * El lanzamiento en curso, si lo hay.
1797
+ *
1798
+ * **Uno a la vez, y para toda la máquina**, como `trabajo` con las recetas y por un motivo
1799
+ * más fuerte: dos lanzamientos al mismo aparato se pisarían en el MISMO directorio del
1800
+ * dispositivo, y el ZIP de la subida no limpia el destino —lo que quede de uno se lanzaría
1801
+ * como parte del otro—. Un segundo «Ejecutar» mientras corre no lanza nada: se reenvía el
1802
+ * estado, que es lo que la otra pestaña necesita para pintar el recorrido que ya va.
1803
+ */
1804
+ let lanzamiento;
1805
+ /** Cuándo se emitió el último progreso del lanzamiento. Ver `alFase`: no es por línea. */
1806
+ let ultimoLanzamiento = 0;
1807
+ /**
1808
+ * Emite el recorrido tal y como va. `ms` es el reloj del CABLE —desde que se dijo
1809
+ * `corriendo`— y no el `ResultadoDeLanzamiento.ms` de la máquina, que mide lo mismo pero
1810
+ * desde dentro: lo que la pestaña enseña es cuánto lleva el recorrido que ELLA está viendo, y
1811
+ * ese empezó en el primer mensaje. Los dos números coinciden salvo por el viaje, así que
1812
+ * enseñar uno mientras el otro avanza sería el desajuste más tonto posible.
1813
+ */
1814
+ const emitirLanzamiento = (estado, motivo) => {
1815
+ if (lanzamiento === undefined)
1816
+ return;
1817
+ emitir({
1818
+ clase: "lanzamiento",
1819
+ ...(lanzamiento.proyecto === undefined ? {} : { proyecto: lanzamiento.proyecto }),
1820
+ ...(lanzamiento.dispositivo === undefined ? {} : { dispositivo: lanzamiento.dispositivo }),
1821
+ fase: lanzamiento.fase,
1822
+ estado,
1823
+ // La COLA del recorrido y no todo: subir un ZIP suelta decenas de líneas de `adb`, y el
1824
+ // cable no es un sitio donde guardarlas. Lo que hace falta es saber que avanza.
1825
+ lineas: lanzamiento.lineas.slice(-LINEAS_DE_LOG),
1826
+ ms: Date.now() - lanzamiento.t0,
1827
+ ...(motivo === undefined ? {} : { motivo }),
1828
+ });
1829
+ ultimoLanzamiento = Date.now();
1830
+ };
1831
+ /**
1832
+ * El framework de XOne en ESE dispositivo, o `undefined` = «no se sabe».
1833
+ *
1834
+ * **Se mide en cada veredicto y no se cachea**, y es una decisión: cachearlo ahorraría un
1835
+ * adb por consulta, pero diría «listo» sobre un framework que alguien acaba de desinstalar —
1836
+ * y el síntoma sería el peor de todos, un lanzamiento que se acepta y una app que no
1837
+ * arranca. Un medidor que reviente es un bug suyo (el real no lanza: contesta
1838
+ * `{instalado: false, detalle}` para lo que no sabe), así que se cuenta y el veredicto sale
1839
+ * como «no se sabe», que es la verdad.
1840
+ */
1841
+ const medirFramework = async (dispositivo) => {
1842
+ const medir = opciones.frameworkEnDispositivo;
1843
+ if (medir === undefined)
1844
+ return undefined;
1845
+ try {
1846
+ return await medir(dispositivo);
1847
+ }
1848
+ catch (error) {
1849
+ informar(`no se pudo mirar si hay framework de XOne en «${dispositivo.nombre}» (${codigoDe(error)})`);
1850
+ return undefined;
1851
+ }
1852
+ };
1853
+ /**
1854
+ * El texto de un descriptor del proyecto (`app.xml`, `app.ini`), o el motivo por el que no
1855
+ * se pudo leer.
1856
+ *
1857
+ * Reusa `leerFichero` en vez de abrir un segundo lector, y no es comodidad: ese lector ya
1858
+ * lleva la barrera de rutas y la regla de las VISTAS APLANADAS (`X.xml` con un `X.xne` al
1859
+ * lado), así que un lector propio sería un segundo sitio donde esa regla puede dejar de
1860
+ * estar. El `error` que devuelve se dice tal cual: es una línea escrita por él.
1861
+ */
1862
+ const leerDescriptor = async (raiz, ruta) => {
1863
+ const leer = opciones.leerFichero;
1864
+ if (leer === undefined)
1865
+ return { motivo: "esta ejecución no puede leer ficheros del proyecto" };
1866
+ try {
1867
+ const fichero = await leer(raiz, ruta);
1868
+ if (fichero.texto !== undefined)
1869
+ return { texto: fichero.texto };
1870
+ return { motivo: fichero.error ?? "no es un fichero de texto" };
1871
+ }
1872
+ catch (error) {
1873
+ return { motivo: motivoLegible(error) };
1874
+ }
1875
+ };
1876
+ /**
1877
+ * Mide el proyecto abierto y compone el veredicto. `undefined` = no hay proyecto abierto.
1878
+ *
1879
+ * Es la composición que junta las cuatro fuentes: la ELECCIÓN de la sesión cruzada con la
1880
+ * última medida (`dispositivoDeLaSesion`), el framework medido ahora, y los descriptores del
1881
+ * proyecto leídos ahora. Lo caro —adb, el disco— está detrás de opciones, así que un test del
1882
+ * cable la ejercita entera con dobles.
1883
+ */
1884
+ const medirLanzamiento = async () => {
1885
+ const abierto = vestibulo.proyectoAbierto();
1886
+ if (abierto === undefined)
1887
+ return undefined;
1888
+ const raiz = abierto.raiz;
1889
+ const { dispositivo, elegido } = dispositivoDeLaSesion(abierto.dispositivo, informeDeDispositivos);
1890
+ const framework = dispositivo === undefined ? undefined : await medirFramework(dispositivo);
1891
+ const [xml, ini] = await Promise.all([leerDescriptor(raiz, "app.xml"), leerDescriptor(raiz, "app.ini")]);
1892
+ // ¿El fichero NO está, o está y no se deja leer? No es la misma cosa ni se arregla en el
1893
+ // mismo sitio (`sin-app-xml` manda a mirar la carpeta, `app-xml-ilegible` a mirar el
1894
+ // fichero), así que la existencia se pregunta aparte. Sin con qué comprobarlo no se
1895
+ // contesta «no está»: se cuenta como ilegible, con el motivo que dio el lector.
1896
+ const falta = opciones.existeEnProyecto?.(raiz, "app.xml") === false;
1897
+ const veredicto = puedeLanzarse({
1898
+ dispositivo,
1899
+ ...(elegido === undefined ? {} : { elegido }),
1900
+ framework,
1901
+ xml: xml.texto,
1902
+ ...(xml.texto !== undefined || falta ? {} : { motivoDeLectura: xml.motivo ?? "no se pudo leer" }),
1903
+ ini: ini.texto,
1904
+ // Un `existe` sin con qué comprobarlo CONTESTA QUE SÍ, que es «no se bloquea por esto»:
1905
+ // lo que no se puede comprobar no se afirma, y el falso bloqueo manda a buscar un
1906
+ // fichero que sí está.
1907
+ existe: (rutaRelativa) => opciones.existeEnProyecto === undefined || opciones.existeEnProyecto(raiz, rutaRelativa),
1908
+ });
1909
+ return {
1910
+ abierto,
1911
+ // El nombre del proyecto, que es un SEGMENTO de su carpeta —el mismo que enseña la
1912
+ // barra— y no una ruta: `sinRutas` no deja cruzar una ruta de la máquina por el cable.
1913
+ proyecto: basename(raiz),
1914
+ dispositivo,
1915
+ ...(veredicto.app === undefined ? {} : { app: veredicto.app }),
1916
+ veredicto,
1917
+ };
1918
+ };
1919
+ /**
1920
+ * Contesta el veredicto de «¿se puede lanzar?», pedido desde la pestaña.
1921
+ *
1922
+ * **Sin proyecto abierto no se contesta nada**, y es deliberado: el mensaje lleva `proyecto`,
1923
+ * y rellenarlo con una cadena vacía —o con el nombre de un proyecto que ya no está— sería
1924
+ * afirmar un proyecto que no existe. Quien pinta la pestaña ya sabe que no hay ninguno por
1925
+ * `alta.proyectoAbierto`, y esa es la pantalla que le toca («abre un proyecto»).
1926
+ */
1927
+ const atenderRevisarLanzamiento = async () => {
1928
+ const medida = await medirLanzamiento();
1929
+ if (medida === undefined)
1930
+ return;
1931
+ const foto = medida.dispositivo === undefined ? undefined : fotoDeDispositivo(medida.dispositivo);
1932
+ emitir({
1933
+ clase: "lanzable",
1934
+ proyecto: medida.proyecto,
1935
+ listo: medida.veredicto.listo,
1936
+ // Las FRASES ya escritas, no las causas: quien compone el texto es
1937
+ // `core/puedeLanzarse.ts#motivoDeBloqueo`, que es donde vive la regla y donde se
1938
+ // arregla. Aquí solo se recorren.
1939
+ faltas: medida.veredicto.causas.map(motivoDeBloqueo),
1940
+ ...(medida.app === undefined ? {} : { app: medida.app }),
1941
+ ...(foto === undefined ? {} : { dispositivo: foto }),
1942
+ // La fecha de la FOTO del equipo, no la de ahora: la medida del framework y la elección
1943
+ // de la sesión son de este instante, pero el `estado` del dispositivo —lo que puede
1944
+ // quedarse viejo— sale de esa foto, y es la que se enseña. Un veredicto sin fecha es una
1945
+ // promesa sin fecha.
1946
+ medido: informeDeDispositivos?.medido ?? new Date().toISOString(),
1947
+ });
1948
+ };
1949
+ /**
1950
+ * Lanza la app del proyecto abierto en el dispositivo de la sesión.
1951
+ *
1952
+ * **Revalida antes de empezar**, y no es prudencia: entre el veredicto y este clic puede
1953
+ * pasar cualquier cosa —desenchufar el teléfono, mover el fichero de la conexión, cerrar el
1954
+ * proyecto—, así que lo que se lanza es lo que se acaba de medir, no lo que se midió cuando
1955
+ * se pintó el botón. Una revalidación que no pasa se contesta con un `lanzamiento` en `fallo`
1956
+ * y NO se llama al lanzador.
1957
+ */
1958
+ const atenderLanzarApp = async () => {
1959
+ // Ya hay uno: se reenvía su estado en vez de lanzar otro, como `atenderReceta`.
1960
+ if (lanzamiento !== undefined) {
1961
+ emitirLanzamiento("corriendo");
1962
+ return;
1963
+ }
1964
+ const medida = await medirLanzamiento();
1965
+ if (medida === undefined) {
1966
+ informar("no hay ningún proyecto abierto que lanzar");
1967
+ return;
1968
+ }
1969
+ const foto = medida.dispositivo === undefined ? undefined : fotoDeDispositivo(medida.dispositivo);
1970
+ const fallar = (motivo) => {
1971
+ emitir({
1972
+ clase: "lanzamiento",
1973
+ proyecto: medida.proyecto,
1974
+ ...(foto === undefined ? {} : { dispositivo: foto }),
1975
+ fase: "comprobando",
1976
+ estado: "fallo",
1977
+ lineas: [],
1978
+ ms: 0,
1979
+ motivo,
1980
+ });
1981
+ };
1982
+ const lanzar = opciones.lanzarEnDispositivo;
1983
+ const primeraFalta = medida.veredicto.causas[0];
1984
+ if (primeraFalta !== undefined) {
1985
+ // La PRIMERA, que es la que hay que arreglar antes de que nada más importe: el orden de
1986
+ // `puedeLanzarse` es el de la lectura, no el alfabético.
1987
+ fallar(motivoDeBloqueo(primeraFalta));
1988
+ return;
1989
+ }
1990
+ if (medida.dispositivo === undefined) {
1991
+ // Inalcanzable con el veredicto vacío —sin dispositivo siempre hay causa—, pero el tipo
1992
+ // no lo sabe y lanzar sin dispositivo no se puede ni componer.
1993
+ fallar(SIN_NOMBRE_DE_APP);
1994
+ return;
1995
+ }
1996
+ if (medida.app === undefined) {
1997
+ fallar(SIN_NOMBRE_DE_APP);
1998
+ return;
1999
+ }
2000
+ if (lanzar === undefined) {
2001
+ fallar(SIN_CAMINO_DE_LANZAMIENTO);
2002
+ return;
2003
+ }
2004
+ /**
2005
+ * El recorrido, tal y como lo cuenta la máquina.
2006
+ *
2007
+ * **La fase que CAMBIA siempre se emite**, y es una decisión: son SEIS en todo el recorrido
2008
+ * —no un flujo—, y cada una es lo que hace que la pestaña diga en cuál va. Lo que se acota a
2009
+ * `MS_ENTRE_PROGRESOS` son las líneas DENTRO de una misma fase, que es donde está el flujo:
2010
+ * subir un ZIP suelta decenas de líneas de `adb` y cada emisión manda la cola entera.
2011
+ */
2012
+ const alFase = (fase, linea) => {
2013
+ if (lanzamiento === undefined)
2014
+ return;
2015
+ lanzamiento.lineas.push(linea);
2016
+ const cambioDeFase = fase !== lanzamiento.fase;
2017
+ lanzamiento.fase = fase;
2018
+ if (cambioDeFase || Date.now() - ultimoLanzamiento >= MS_ENTRE_PROGRESOS) {
2019
+ emitirLanzamiento("corriendo");
2020
+ }
2021
+ };
2022
+ /**
2023
+ * El estado se compone ANTES de llamar, porque `lanzarEnDispositivo` puede llamar a
2024
+ * `alFase` de forma SÍNCRONA —su cuerpo corre hasta el primer `await`— y el primer
2025
+ * `corriendo` tiene que encontrar el trabajo ya puesto.
2026
+ */
2027
+ const trabajo = {
2028
+ proyecto: medida.proyecto,
2029
+ ...(foto === undefined ? {} : { dispositivo: foto }),
2030
+ fase: "comprobando",
2031
+ lineas: [],
2032
+ cancelar: () => { },
2033
+ t0: Date.now(),
2034
+ };
2035
+ lanzamiento = trabajo;
2036
+ // El primer mensaje dice `corriendo`, y su fase es `comprobando`: el recorrido EMPIEZA
2037
+ // ahí, así que la primera `alFase("comprobando", …)` no cambia nada y no vuelve a emitir.
2038
+ // Los seis tramos se ven igual —el primero ya viene en este mensaje— y el cliente no
2039
+ // recibe un mensaje de más para decir lo que ya decía.
2040
+ emitirLanzamiento("corriendo");
2041
+ try {
2042
+ // **Dentro del `try` y no fuera**: que el lanzador reviente al CONSTRUIR el trabajo —un
2043
+ // doble mal hecho, o un bug suyo en el camino síncrono— dejaría el recorrido en
2044
+ // `corriendo` para siempre y el botón muerto, que es justo lo que este `try` existe para
2045
+ // evitar. Su contrato es no lanzar nunca; esto es por si deja de cumplirlo.
2046
+ const enCurso = lanzar({ dispositivo: medida.dispositivo, raiz: medida.abierto.raiz, app: medida.app }, { alFase });
2047
+ trabajo.cancelar = enCurso.cancelar;
2048
+ const resultado = await enCurso.terminado;
2049
+ // El último SIEMPRE se emite, aunque no haya pasado el plazo: es el que dice cómo acabó.
2050
+ // Y la fase es la que dice el RESULTADO —no la última que se oyó— por si el trabajo
2051
+ // acabó en una que no llegó a contarse.
2052
+ ultimoLanzamiento = 0;
2053
+ trabajo.fase = resultado.fase;
2054
+ emitirLanzamiento(resultado.estado, resultado.motivo);
2055
+ }
2056
+ catch (error) {
2057
+ // El contrato del lanzador es no lanzar nunca, así que esto es un bug suyo; aun así hay
2058
+ // que CERRAR el recorrido, o la pestaña se queda en `corriendo` para siempre con el botón
2059
+ // muerto y sin una palabra.
2060
+ ultimoLanzamiento = 0;
2061
+ emitirLanzamiento("fallo", motivoLegible(error));
2062
+ }
2063
+ finally {
2064
+ lanzamiento = undefined;
2065
+ }
2066
+ };
2067
+ /** Parar el lanzamiento en curso. Un lanzamiento a la vez, así que no lleva id que elegir. */
2068
+ const atenderCancelarLanzamiento = () => {
2069
+ if (lanzamiento === undefined) {
2070
+ informar("no hay ningún lanzamiento en curso que cancelar");
2071
+ return;
2072
+ }
2073
+ lanzamiento.cancelar();
2074
+ };
2075
+ /**
2076
+ * Solo el CÓDIGO de un fallo de sistema de ficheros (`EACCES`, `EMFILE`…), nunca su
2077
+ * mensaje: el de Node lleva la ruta absoluta del disco, y aquí `informar` acaba en el
2078
+ * transcript. Sin código, el nombre del error; sin error, la palabra.
2079
+ */
2080
+ const codigoDe = (error) => {
2081
+ if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string")
2082
+ return error.code;
2083
+ return error instanceof Error ? error.name : "error";
2084
+ };
2085
+ /**
2086
+ * El MENSAJE si lo escribimos nosotros, y el CÓDIGO si lo escribió el sistema.
2087
+ *
2088
+ * La misma función que `corredorDeTareas.ts#sinRutas`, y por el mismo motivo: un error de
2089
+ * Node trae `code` y su mensaje lleva la ruta absoluta, mientras que uno escrito a mano en
2090
+ * este repo no trae `code` y su mensaje es justo lo que hay que leer. `codigoDe` a secas
2091
+ * convierte «falta la credencial para openai; usa /provider openai» en
2092
+ * «ErrorDelAumentador», que es un nombre de clase enseñado a una persona.
2093
+ */
2094
+ const motivoLegible = (error) => typeof error === "object" && error !== null && "code" in error
2095
+ ? codigoDe(error)
2096
+ : (error instanceof Error ? error.message : String(error)).split(/\r?\n/)[0].slice(0, 200);
2097
+ /**
2098
+ * El árbol del proyecto abierto. Sin proyecto no hay pestaña que lo pida, así que no se
2099
+ * contesta nada; sin PUERTO sí se contesta, con error: un árbol que nunca llega deja al
2100
+ * cliente en «consultando…» para siempre, y un cargando eterno es un fallo mudo.
2101
+ */
2102
+ const atenderArbol = async () => {
2103
+ const abierto = vestibulo.proyectoAbierto();
2104
+ if (abierto === undefined)
2105
+ return;
2106
+ if (opciones.arbolDelProyecto === undefined) {
2107
+ emitir({ clase: "arbol", rutas: [], recortado: false, error: "esta ejecución no puede listar el proyecto" });
2108
+ return;
2109
+ }
2110
+ try {
2111
+ const { rutas, recortado } = await opciones.arbolDelProyecto(abierto.raiz);
2112
+ emitir({ clase: "arbol", rutas, recortado });
2113
+ }
2114
+ catch (error) {
2115
+ // El mensaje de Node lleva la ruta absoluta del disco, y por el cable no viaja
2116
+ // ninguna ruta de la máquina. Eso vale TAMBIÉN para `informar`: en producción escribe
2117
+ // un acto de sistema en el transcript, así que a él solo le llega el código (EACCES…).
2118
+ informar(`no se pudo listar el proyecto (${codigoDe(error)})`);
2119
+ emitir({ clase: "arbol", rutas: [], recortado: false, error: "no se pudo listar el proyecto" });
2120
+ }
2121
+ };
2122
+ /**
2123
+ * El contenido de una ruta del proyecto abierto. El lector decide si se puede enseñar.
2124
+ *
2125
+ * El `try` es por lo mismo que el del árbol: `leerFicheroDeProyecto` devuelve los rechazos
2126
+ * como `error` en el resultado, pero después del `realpath` todavía puede LANZAR —un
2127
+ * EACCES al hacer `stat` o al abrir—, y esa excepción llegaba al `contar` genérico sin
2128
+ * contestar nada, dejando al cliente en «Trayendo…» para siempre. La respuesta lleva la
2129
+ * `ruta` porque el cliente indexa los contenidos por ella: sin eso no sabría cuál falló.
2130
+ */
2131
+ const atenderFichero = async (ruta) => {
2132
+ const abierto = vestibulo.proyectoAbierto();
2133
+ if (abierto === undefined)
2134
+ return;
2135
+ if (opciones.leerFichero === undefined) {
2136
+ emitir({ clase: "fichero", ruta, recortado: false, binario: false, bytes: 0, error: "esta ejecución no puede leer el proyecto" });
2137
+ return;
2138
+ }
2139
+ try {
2140
+ emitir({ clase: "fichero", ...(await opciones.leerFichero(abierto.raiz, ruta)) });
2141
+ }
2142
+ catch (error) {
2143
+ // Como en el árbol: ni por el cable ni por `informar` viaja el mensaje de Node, que
2144
+ // lleva la ruta absoluta; la `ruta` relativa es la que mandó el cliente.
2145
+ informar(`no se pudo leer «${ruta}» (${codigoDe(error)})`);
2146
+ emitir({
2147
+ clase: "fichero",
2148
+ ruta,
2149
+ recortado: false,
2150
+ binario: false,
2151
+ bytes: 0,
2152
+ error: "no se pudo leer el fichero",
2153
+ });
2154
+ }
2155
+ };
2156
+ /**
2157
+ * El contenido de un ARTEFACTO de la sesión abierta, por su nombre.
2158
+ *
2159
+ * El id que se usa es `idDeHilo` y no `sesion`, y la diferencia es la que hace que esto
2160
+ * funcione: `sesion` espera a que haya entrada en el índice, o sea al final del turno,
2161
+ * mientras que el acto que anuncia un artefacto se emite a MITAD de turno. Con el otro id
2162
+ * el visor habría contestado «no existe» justo cuando se acaba de dibujar.
2163
+ *
2164
+ * El `try` es el de `atenderFichero`, palabra por palabra y por lo mismo: el lector
2165
+ * devuelve sus rechazos como `error`, pero después del `realpath` todavía puede LANZAR un
2166
+ * EACCES, y esa excepción dejaba al cliente esperando para siempre. Ni por el cable ni por
2167
+ * `informar` viaja el mensaje de Node, que lleva la ruta absoluta.
2168
+ */
2169
+ const atenderArtefacto = async (nombre) => {
2170
+ const abierto = vestibulo.proyectoAbierto();
2171
+ if (abierto === undefined)
2172
+ return;
2173
+ const ruta = `${RUTA_ARTEFACTOS}${nombre}`;
2174
+ const fallo = (error) => emitir({ clase: "artefacto", ruta, recortado: false, binario: false, bytes: 0, error });
2175
+ if (opciones.leerArtefacto === undefined) {
2176
+ fallo("esta ejecución no puede leer los artefactos");
2177
+ return;
2178
+ }
2179
+ try {
2180
+ emitir({ clase: "artefacto", ...(await opciones.leerArtefacto(abierto.raiz, abierto.idDeHilo, nombre)) });
2181
+ }
2182
+ catch (error) {
2183
+ informar(`no se pudo leer el artefacto «${nombre}» (${codigoDe(error)})`);
2184
+ fallo("no se pudo leer el artefacto");
2185
+ }
2186
+ };
2187
+ /**
2188
+ * El paso de receta que se está ejecutando, si hay alguno.
2189
+ *
2190
+ * **Uno a la vez, y para toda la máquina.** Dos `sdkmanager` a la vez sobre el mismo SDK
2191
+ * es una carrera con la instalación de por medio, y la máquina es UNA aunque haya dos
2192
+ * pestañas — el mismo motivo por el que la detección comparte una sola medida en vuelo. Un
2193
+ * segundo «ejecutar» mientras corre no lanza nada: se reenvía el estado, que es lo que la
2194
+ * otra pestaña necesita para pintar el log que ya va por dentro.
2195
+ */
2196
+ let trabajo;
2197
+ /** Cuándo se emitió el último progreso: el log se emite a ritmo, no por línea. */
2198
+ let ultimoProgreso = 0;
2199
+ const emitirProgreso = (estado, motivo) => {
2200
+ if (trabajo === undefined)
2201
+ return;
2202
+ emitir({
2203
+ clase: "instalacion",
2204
+ receta: trabajo.receta,
2205
+ paso: trabajo.paso,
2206
+ titulo: trabajo.titulo,
2207
+ estado,
2208
+ // La COLA del log y no todo: `sdkmanager` son miles de líneas y el cable no es un sitio
2209
+ // donde guardarlas. Lo que hace falta es saber que avanza y en qué va.
2210
+ lineas: trabajo.lineas.slice(-LINEAS_DE_LOG),
2211
+ ms: Date.now() - trabajo.t0,
2212
+ ...(motivo === undefined ? {} : { motivo }),
2213
+ });
2214
+ ultimoProgreso = Date.now();
2215
+ };
2216
+ /**
2217
+ * Lanza un paso, o cancela el que corre.
2218
+ *
2219
+ * Al terminar se vuelve a MEDIR, y es lo que decide si el paso queda hecho: lo que diga el
2220
+ * instalador es lo que él cree, y la foto es lo que hay. Es la misma regla que ya sigue
2221
+ * `instalar`.
2222
+ */
2223
+ const atenderReceta = (mensaje) => {
2224
+ if (mensaje.accion === "cancelar") {
2225
+ trabajo?.cancelar();
2226
+ return;
2227
+ }
2228
+ if (trabajo !== undefined) {
2229
+ // Ya hay uno: se reenvía su estado en vez de lanzar otro.
2230
+ emitirProgreso("corriendo");
2231
+ return;
2232
+ }
2233
+ const correr = opciones.correrPasoDeReceta;
2234
+ if (correr === undefined) {
2235
+ informar("esta ejecución no puede ejecutar pasos de instalación");
2236
+ return;
2237
+ }
2238
+ const enMarcha = correr(mensaje.id, mensaje.paso, (linea) => {
2239
+ if (trabajo === undefined)
2240
+ return;
2241
+ trabajo.lineas.push(linea);
2242
+ // A ritmo: cada emisión manda la cola entera, y por línea sería cuadrático en bytes
2243
+ // con un `sdkmanager` que habla cada pocos milisegundos. Es la misma razón que
2244
+ // `MS_ENTRE_PARCIALES` en la piel web.
2245
+ if (Date.now() - ultimoProgreso >= MS_ENTRE_PROGRESOS)
2246
+ emitirProgreso("corriendo");
2247
+ });
2248
+ trabajo = {
2249
+ receta: mensaje.id,
2250
+ paso: mensaje.paso,
2251
+ titulo: enMarcha.titulo,
2252
+ lineas: [],
2253
+ cancelar: enMarcha.cancelar,
2254
+ t0: Date.now(),
2255
+ };
2256
+ emitirProgreso("corriendo");
2257
+ void (async () => {
2258
+ const resultado = await enMarcha.terminado;
2259
+ // El último progreso SIEMPRE se emite, aunque no haya pasado el plazo: es el que
2260
+ // completa el log y el que dice cómo acabó.
2261
+ ultimoProgreso = 0;
2262
+ emitirProgreso(resultado.estado, resultado.motivo);
2263
+ trabajo = undefined;
2264
+ // Y la foto nueva es la que dice si el paso quedó hecho, no el código de salida.
2265
+ informeDeDispositivos = undefined;
2266
+ await atenderDispositivos().catch(contar);
2267
+ })();
2268
+ };
2269
+ /**
2270
+ * Los modelos de un motor externo, para el desplegable de un subagente.
2271
+ *
2272
+ * Bajo demanda y cacheado por proceso: el de Claude Code es una tabla, pero el de Codex se
2273
+ * le PREGUNTA a él —`model/list` sobre su `app-server`—, y eso arranca un proceso. Pedirlo
2274
+ * al conectar lo lanzaría en cada arranque para una ventana que casi nadie abre.
2275
+ */
2276
+ const modelosPorMotor = new Map();
2277
+ const atenderModelosDeMotor = async (motor) => {
2278
+ const emitirlos = (r) => emitir({ clase: "modelosDeMotor", motor, modelos: r.modelos, ...(r.error === undefined ? {} : { error: r.error }) });
2279
+ const guardado = modelosPorMotor.get(motor);
2280
+ if (guardado !== undefined) {
2281
+ emitirlos(guardado);
2282
+ return;
2283
+ }
2284
+ if (opciones.modelosDeMotor === undefined) {
2285
+ emitirlos({ modelos: [], error: "esta ejecución no puede consultar los modelos de ese motor" });
2286
+ return;
2287
+ }
2288
+ const r = await opciones.modelosDeMotor(motor);
2289
+ // Un fallo NO se cachea: es lo que pasa cuando Codex no estaba instalado todavía, y
2290
+ // cachearlo dejaría el desplegable vacío hasta reiniciar la consola aunque lo instale.
2291
+ if (r.error === undefined)
2292
+ modelosPorMotor.set(motor, r);
2293
+ emitirlos(r);
2294
+ };
2295
+ /** Un paso del alta resuelto en el navegador. Cada rama termina volviendo a anunciar. */
2296
+ const atenderAlta = async (mensaje) => {
2297
+ // Se limpia al empezar: un aviso viejo pegado a un paso que ya salió bien mentiría.
2298
+ aviso = undefined;
2299
+ try {
2300
+ if (mensaje.paso === "cuenta") {
2301
+ // Volver al paso de modelo desde la progresión del alta. Se re-arma `cuentaHecha`
2302
+ // —lo que impide preguntar dos veces es justo esa marca— y se relanza el asistente
2303
+ // SUELTO: pintarlo es cosa suya (`selector` y `secreto`), y el `POST` no puede
2304
+ // quedarse abierto mientras un humano elige. Cuando termine, `anunciarAlta` cuenta
2305
+ // cómo quedó todo.
2306
+ cuentaHecha = false;
2307
+ void conducirCuenta(true)
2308
+ .catch(contar)
2309
+ .finally(() => void anunciarAlta().catch(contar));
2310
+ return;
2311
+ }
2312
+ if (mensaje.paso === "entorno") {
2313
+ const elegido = mensaje.entorno;
2314
+ if (elegido === undefined || elegido.url.trim() === "") {
2315
+ aviso = "el entorno necesita una URL";
2316
+ informar(aviso);
2317
+ return;
2318
+ }
2319
+ // SIEMPRE se registra, aunque el entorno ya esté en la lista. La versión anterior
2320
+ // se lo saltaba comparando contra `opcionesDeEntorno()`, que es la lista OFRECIDA
2321
+ // (los dos oficiales más «otro») y no la registrada: con un `settings.json` recién
2322
+ // nacido, elegir WebStudio casaba con el oficial, no se registraba nada, y el
2323
+ // `proyectosDe` siguiente moría con «el entorno no está registrado». Registrar dos
2324
+ // veces no cuesta nada: en disco `guardarEntorno` sustituye por id y en memoria el
2325
+ // vestíbulo hace lo mismo.
2326
+ // El id con el que quedó REGISTRADO, no el que llegó: el formulario solo pide la
2327
+ // URL, y de un «otro» el vestíbulo deduce id y nombre del host
2328
+ // (`identidadDeEntorno`). Con el id de la lista, el `proyectosDe` de la línea
2329
+ // siguiente moriría con «el entorno «otro» no está registrado».
2330
+ const { entorno: registrado } = await vestibulo.registrarEntorno({
2331
+ id: elegido.id,
2332
+ nombre: elegido.nombre,
2333
+ url: elegido.url,
2334
+ });
2335
+ entornoElegido = registrado.id;
2336
+ proyectoElegido = undefined;
2337
+ ramas = [];
2338
+ proyectos = await vestibulo.proyectosDe(registrado.id);
2339
+ return;
2340
+ }
2341
+ if (entornoElegido === undefined) {
2342
+ aviso = "elige antes el entorno del que sale el proyecto";
2343
+ informar(aviso);
2344
+ return;
2345
+ }
2346
+ const proyecto = mensaje.proyecto;
2347
+ if (proyecto === undefined || proyecto === "") {
2348
+ aviso = "el paso de proyecto necesita un proyecto";
2349
+ informar(aviso);
2350
+ return;
2351
+ }
2352
+ if (mensaje.rama === undefined || mensaje.rama === "") {
2353
+ // Sin rama todavía no se abre nada: se contestan las ramas de ese proyecto. Es la
2354
+ // alternativa a inventarse las del primero de la lista antes de que nadie elija.
2355
+ proyectoElegido = proyecto;
2356
+ // Igual que arriba: el servidor abre por NOMBRE, y el cable trae el id.
2357
+ ramas = await vestibulo.ramasDe(entornoElegido, proyectos.find((p) => p.id === proyecto) ?? proyecto);
2358
+ return;
2359
+ }
2360
+ // El proyecto que viene en ESTE mensaje, no el cacheado: lo enviado es la verdad y
2361
+ // `proyectoElegido` puede haberse quedado atrás.
2362
+ const identidad = proyectos.find((p) => p.id === proyecto) ?? proyecto;
2363
+ // La espera LARGA: aquí se baja el proyecto entero. Se dice como tal (`descargando`)
2364
+ // porque un indicador que solo gire no distingue medio segundo de tres minutos.
2365
+ anunciarAbriendo({ proyecto, descargando: true });
2366
+ const { raiz } = await vestibulo.completarProyecto({
2367
+ entorno: entornoElegido,
2368
+ proyecto: identidad,
2369
+ rama: mensaje.rama,
2370
+ });
2371
+ await vestibulo.abrirProyecto({ raiz });
2372
+ // El cable se muda a la consola del proyecto. Sin esto el usuario mira un transcript
2373
+ // vivo cuyas aprobaciones se rechazan solas al otro lado. `adjuntar` reemite también
2374
+ // el estado de modelos, que hasta ahora no tenía `actual` que dar: sin sesión abierta
2375
+ // no hay modelo en vigor.
2376
+ adjuntar();
2377
+ // Como en `atenderSesion`: abrir bloquea esta raíz para las tareas y libera la que
2378
+ // estuviera abierta antes.
2379
+ opciones.revisarTareas?.();
2380
+ }
2381
+ catch (error) {
2382
+ // El aviso se fija ANTES de anunciar: el `finally` de abajo es quien lo lleva al paso.
2383
+ aviso = error instanceof Error ? error.message : String(error);
2384
+ contar(error);
2385
+ }
2386
+ finally {
2387
+ await anunciarAlta().catch(contar);
2388
+ // Solo este paso del alta enciende el indicador, pero apagarlo aquí es correcto para
2389
+ // todos: apagar lo que ya está apagado no se ve.
2390
+ anunciarAbriendo();
2391
+ }
2392
+ };
2393
+ // El modelo en vigor cambia DENTRO del lazo de la consola (`/modelo` y `/modelos` no
2394
+ // tocan disco), así que la única forma de enterarse es que el vestíbulo lo diga. Sin
2395
+ // esto, el disparador del compositor seguiría enseñando el modelo con el que se abrió la
2396
+ // sesión después de haberlo cambiado — una cifra con forma de verdad.
2397
+ vestibulo.alCambiarEstadoDeSesion(() => emitirModelos());
2398
+ // El turno se emite solo (`consolaWeb.turno`), pero además hay que RECORDARLO: una pestaña
2399
+ // que conecta a mitad no vio ese mensaje, y necesita saberlo para apagar su compositor.
2400
+ /**
2401
+ * Lo consumido por la sesión en foco. Se emite en cada cambio y también al conectar: una
2402
+ * pestaña que llega a mitad de sesión no vio los anteriores, y sin esto su contador se
2403
+ * quedaría a cero hasta el siguiente token — que en un turno largo son minutos.
2404
+ */
2405
+ const emitirConsumo = () => {
2406
+ const c = vestibulo.consumoDeSesion();
2407
+ // Ausente es «no consta», y entonces no se manda nada: el cliente prefiere no pintar el
2408
+ // contador a pintar un cero que nadie ha medido.
2409
+ if (c === undefined)
2410
+ return;
2411
+ emitir({ clase: "consumo", modelo: c.modelo, externo: c.externo, ventana: ventanaDeAhora(c.contexto) });
2412
+ };
2413
+ /**
2414
+ * Cuánto ocupa la ventana y cuál es su tope, si se sabe.
2415
+ *
2416
+ * El tope se resuelve con **la misma función que la barra del terminal**
2417
+ * (`cli/main.ts#crearTopeDelModelo`, que entra por OPCIÓN para no importar `cli/` desde
2418
+ * aquí — el mismo motivo por el que `crearEjecutor` entra así). Dos resoluciones serían
2419
+ * dos topes que divergen, y de ahí sale un porcentaje que miente en una de las dos
2420
+ * pieles. Esa función ya respeta la precedencia de `/config`: lo del proyecto gana a lo
2421
+ * global y la tabla es el último recurso.
2422
+ *
2423
+ * Se re-resuelve en cada emisión y no se cachea, por lo mismo que en el terminal:
2424
+ * `/modelo` cambia el modelo en caliente, y el tope del modelo equivocado es la misma
2425
+ * mentira. Sin tope no se manda el campo: con Ollama no hay A PROPÓSITO.
2426
+ */
2427
+ const ventanaDeAhora = (usado) => {
2428
+ const abierto = vestibulo.proyectoAbierto();
2429
+ if (abierto === undefined)
2430
+ return { usado };
2431
+ const trabajo = resolver(abierto.estadoDeSesion.fuentes).trabajo;
2432
+ const tope = opciones.topeDeContexto?.(abierto.raiz, `${trabajo.proveedor}/${trabajo.modelo}`);
2433
+ return tope === undefined ? { usado } : { usado, tope };
2434
+ };
2435
+ vestibulo.alCambiarConsumo(() => emitirConsumo());
2436
+ vestibulo.alCambiarTurno(() => {
2437
+ // La cola se vuelve a mirar: por aquí pasa también el CIERRE de una consola de segundo
2438
+ // plano cuyo turno acabó, y eso libera su raíz para las tareas («gana la persona» deja
2439
+ // de aplicar ahí). Sin este empujón la tarea que esperaba ese proyecto se quedaría
2440
+ // quieta hasta que alguien tocara la cola por su cuenta: no hay temporizador.
2441
+ opciones.revisarTareas?.();
2442
+ // El alta se reanuncia en los DOS flancos, y DIFERIDO. Medido: una sesión nueva no
2443
+ // aparecía en la barra hasta recargar la página, porque su id nace en `volcar()` —al
2444
+ // final del turno— y nadie volvía a anunciar. Y `historica` deja de ser cierto al
2445
+ // EMPEZAR el primer turno nuevo, que es el otro flanco. Diferido a una microtarea porque
2446
+ // `vestibulo.ts` llama a esta escucha ANTES de `volcar()`, en el mismo `finally`
2447
+ // síncrono: anunciar en el acto leería la sesión sin id todavía. Una microtarea corre
2448
+ // cuando ese bloque ha terminado, o sea con el `.jsonl` ya escrito.
2449
+ void Promise.resolve()
2450
+ .then(() => anunciarAlta())
2451
+ .catch(contar);
2452
+ });
2453
+ servidor.registrarRuta("GET", RUTA_EVENTOS, (peticion, respuesta) => {
2454
+ respuesta.writeHead(200, {
2455
+ "Content-Type": "text/event-stream; charset=utf-8",
2456
+ "Cache-Control": "no-cache",
2457
+ Connection: "keep-alive",
2458
+ // Sin esto un proxy intermedio bufferiza el stream y el transcript llega a tirones.
2459
+ "X-Accel-Buffering": "no",
2460
+ });
2461
+ const sumidero = (mensaje) => {
2462
+ // El socket se puede haber ido entre el último acto y este: escribir en él lanza, y
2463
+ // ese lanzamiento subiría por el emisor del acto hasta el motor de turno.
2464
+ try {
2465
+ respuesta.write(`data: ${JSON.stringify(mensaje)}\n\n`);
2466
+ }
2467
+ catch {
2468
+ /* el cliente se fue; el `close` de abajo ya desconecta */
2469
+ }
2470
+ };
2471
+ clientes.add(sumidero);
2472
+ /**
2473
+ * El identificador que el navegador eligió para ESTA conexión, si lo mandó. Es lo que
2474
+ * después le permite decir «engánchame a la tarea t1» por `POST /accion`, que es otra
2475
+ * petición y no trae sumidero ninguno. Ausente = un cliente que no va a mirar nada, y
2476
+ * entonces no se apunta: nada que limpiar al cerrarse.
2477
+ *
2478
+ * Se lee de la query a mano y no con `new URL()` por la misma razón que
2479
+ * `servidor.ts#manejarPeticion`, aunque aquí no haya segmentos que preservar: es el
2480
+ * patrón del fichero. Y no es una ruta ni un nombre de fichero: solo la clave de
2481
+ * `porIdDeCliente`.
2482
+ */
2483
+ const idDeCliente = new URLSearchParams((peticion.url ?? "").split("?")[1] ?? "").get("cliente") ?? undefined;
2484
+ if (idDeCliente !== undefined && idDeCliente !== "") {
2485
+ /**
2486
+ * Si el id ya estaba, es la MISMA pestaña reconectando, y sus envoltorios viejos hay
2487
+ * que desengancharlos AQUÍ: el `close` de la conexión anterior puede llegar después
2488
+ * —es la carrera que documenta el `close` de abajo— y entonces se salta la limpieza por
2489
+ * la guarda de `enviar === sumidero`, que está bien puesta porque si no se llevaría por
2490
+ * delante las miradas del recién llegado. Sin esto, esos envoltorios se quedaban en el
2491
+ * `mirones` del transporte de la tarea hasta que su consola cerrara, escribiendo en un
2492
+ * socket que ya no está (el `try/catch` del sumidero se lo traga: ni se veía). Reclamar
2493
+ * el id es el único momento en que consta que la conexión anterior murió, y el cliente
2494
+ * vuelve a pedir lo que mirara al reconectar.
2495
+ */
2496
+ const anterior = porIdDeCliente.get(idDeCliente);
2497
+ if (anterior !== undefined) {
2498
+ for (const [tarea, envoltorio] of anterior.mirando) {
2499
+ opciones.corredorDeTareas?.dejarDeMirar?.(tarea, envoltorio);
2500
+ }
2501
+ }
2502
+ porIdDeCliente.set(idDeCliente, { enviar: sumidero, mirando: new Map() });
2503
+ }
2504
+ // Un comentario SSE abre el stream de verdad: sin nada escrito, algunos navegadores no
2505
+ // disparan `onopen` hasta el primer dato.
2506
+ respuesta.write(": xonecode\n\n");
2507
+ adjuntar(sumidero);
2508
+ // ANTES de `conducirCuenta()`, no después: el nombre ya está resuelto (es local, no
2509
+ // depende de ninguna cuenta) y el paso de cuenta puede tardar lo que tarde un humano
2510
+ // en elegir modelo y teclear una clave. Mandarlo solo dentro de `alta` —al final de
2511
+ // TODO esto— dejaba el saludo en «Hola» a secas mientras tanto (`transporte.ts`
2512
+ // documenta la medida).
2513
+ // Solo al recién llegado: los demás ya recibieron su saludo al conectar.
2514
+ sumidero({ clase: "bienvenida", ...(vestibulo.nombre === undefined ? {} : { nombre: vestibulo.nombre }) });
2515
+ void conducirCuenta()
2516
+ .catch(contar)
2517
+ .then(() => poblarProyectosSiProcede())
2518
+ .finally(() => void anunciarAlta().catch(contar));
2519
+ peticion.on("close", () => {
2520
+ // Se va ESTE cliente, no «el cliente». La guarda de antes (`enviar !== sumidero`)
2521
+ // existía porque el `close` de una pestaña recargada puede llegar DESPUÉS de que el
2522
+ // SSE nuevo se enganche, y con una sola ranura eso desconectaba al recién llegado;
2523
+ // con un conjunto, quitar el suyo es exacto y esa carrera desaparece.
2524
+ clientes.delete(sumidero);
2525
+ /**
2526
+ * Y se desenganchan sus MIRADAS. Sin esto, el envoltorio de una pestaña cerrada se
2527
+ * queda enganchado al turno de la tarea para siempre, escribiendo en un socket que ya
2528
+ * no está. La guarda de `enviar === sumidero` es la misma carrera que documenta el
2529
+ * `close` de aquí arriba: una pestaña recargada puede cerrar DESPUÉS de que su
2530
+ * reconexión haya reclamado el mismo id, y sin comparar el sumidero el cierre viejo se
2531
+ * llevaría por delante las miradas del recién llegado.
2532
+ */
2533
+ if (idDeCliente !== undefined) {
2534
+ const entrada = porIdDeCliente.get(idDeCliente);
2535
+ if (entrada !== undefined && entrada.enviar === sumidero) {
2536
+ for (const [tarea, envoltorio] of entrada.mirando) {
2537
+ opciones.corredorDeTareas?.dejarDeMirar?.(tarea, envoltorio);
2538
+ }
2539
+ porIdDeCliente.delete(idDeCliente);
2540
+ }
2541
+ }
2542
+ // Y la consola solo se da por sola cuando se va el ÚLTIMO: el transporte lo decide
2543
+ // mirando sus sumideros. Cortar a la primera baja rechazaría la aprobación que otra
2544
+ // pestaña todavía tiene delante.
2545
+ adjunto?.desconectar(sumidero);
2546
+ if (clientes.size === 0) {
2547
+ /**
2548
+ * Y las de SEGUNDO PLANO también, que es lo que `soltar` deja pendiente a
2549
+ * propósito: mudarse de consola no da por ido al humano, pero cerrarse el último
2550
+ * SSE sí. Sin esto, una consola que se quedó detrás con un turno en marcha seguiría
2551
+ * creyendo que hay alguien a quien preguntar y su aprobación esperaría el plazo
2552
+ * entero antes de rechazarse. Se les dice a todas menos a la que ya se acaba de
2553
+ * cortar arriba.
2554
+ */
2555
+ for (const consola of vestibulo.proyectosAbiertos()) {
2556
+ if (consola !== adjunto)
2557
+ consola.desconectar();
2558
+ }
2559
+ adjunto = undefined;
2560
+ }
2561
+ });
2562
+ });
2563
+ /**
2564
+ * `GET /artefacto?n=<nombre>` — el documento que pinta el iframe, y la descarga.
2565
+ *
2566
+ * **Aquí vive la decisión de sandbox que tenía parada esta pantalla.** Lo que se sirve lo
2567
+ * escribió un MODELO, y servirlo en el mismo origen que tiene la cookie del token sería
2568
+ * darle a ese HTML la consola entera: un `fetch("/accion")` desde dentro podría abrir un
2569
+ * proyecto, cambiar el modelo o pedir una clave. Se cierra con dos capas, y las dos hacen
2570
+ * falta:
2571
+ * - El iframe va `sandbox="allow-scripts"` SIN `allow-same-origin` (`Artefactos.tsx`),
2572
+ * así que el documento tiene un origen OPACO: no ve la cookie, no ve el padre, y sus
2573
+ * peticiones salen con `Origin: null`, que la comprobación de `servidor.ts` ya contesta
2574
+ * con 403.
2575
+ * - Y la respuesta lleva `Content-Security-Policy: sandbox allow-scripts`, que cubre lo
2576
+ * que el atributo no puede: abrir esta URL en una pestaña del navegador es una
2577
+ * navegación de PRIMER nivel en el origen real, con la cookie puesta. La cabecera hace
2578
+ * que ahí también sea un origen opaco.
2579
+ *
2580
+ * Lo que NO se pone es una CSP de red. Estos artefactos cargan tipografías y Mermaid de un
2581
+ * CDN (es lo que sus skills mandan), y con origen opaco no hay nada que exfiltrar: cerrar
2582
+ * la red solo los dejaría sin estilo. La contrapartida se DICE en la pantalla: no se ven
2583
+ * sin conexión.
2584
+ *
2585
+ * Y un mime que no conocemos no se sirve inline. Adivinarlo es cómo un `.txt` acaba
2586
+ * ejecutándose como HTML; sin `nosniff` lo adivinaría el navegador.
2587
+ */
2588
+ servidor.registrarRuta("GET", RUTA_ARTEFACTO, async (peticion, respuesta) => {
2589
+ const responder = (codigo, texto) => {
2590
+ respuesta.writeHead(codigo, { "Content-Type": "text/plain; charset=utf-8" });
2591
+ respuesta.end(texto);
2592
+ };
2593
+ // El nombre sale de la QUERY. `searchParams` decodifica una vez, así que un `%2e%2e`
2594
+ // llega ya como `..` y lo caza `esRutaDeArtefacto`; un `%252e%252e` llega con el `%`
2595
+ // dentro, que tampoco es texto llano. La ruta no se parsea con `new URL` para nada más:
2596
+ // aquí no hay ningún camino que normalizar.
2597
+ const query = new URLSearchParams((peticion.url ?? "").split("?")[1] ?? "");
2598
+ const nombre = query.get("n");
2599
+ if (nombre === null || nombre === "") {
2600
+ responder(400, "falta el artefacto");
2601
+ return;
2602
+ }
2603
+ // La MISMA función que decide que escribir ahí no pide aprobación humana. Se comprueba
2604
+ // también aquí, antes de llamar al lector —que tiene su propia barrera— porque una ruta
2605
+ // que se apoya solo en la barrera de su lector se queda abierta el día que alguien
2606
+ // cambie el lector.
2607
+ if (!esRutaDeArtefacto(`${RUTA_ARTEFACTOS}${nombre}`)) {
2608
+ responder(403, "ese nombre no es de un artefacto");
2609
+ return;
2610
+ }
2611
+ const abierto = vestibulo.proyectoAbierto();
2612
+ if (abierto === undefined || opciones.leerArtefactoCrudo === undefined) {
2613
+ responder(404, "no hay artefacto");
2614
+ return;
2615
+ }
2616
+ let leido;
2617
+ try {
2618
+ leido = await opciones.leerArtefactoCrudo(abierto.raiz, abierto.idDeHilo, nombre);
2619
+ }
2620
+ catch (error) {
2621
+ // El mensaje de Node lleva la ruta absoluta: al cliente solo le llega el código, y a
2622
+ // `informar` tampoco más, que escribe en el transcript y por tanto viaja.
2623
+ informar(`no se pudo servir el artefacto «${nombre}» (${codigoDe(error)})`);
2624
+ responder(500, "no se pudo leer");
2625
+ return;
2626
+ }
2627
+ if (!leido.ok) {
2628
+ // Un código por motivo: con un 404 para todo no habría forma de distinguir «ese
2629
+ // nombre no vale» de «ya no está» ni de «no cabe».
2630
+ const codigos = { rechazado: 403, "no-existe": 404, "demasiado-grande": 413 };
2631
+ const textos = {
2632
+ rechazado: "ese nombre no es de un artefacto",
2633
+ "no-existe": "no hay artefacto",
2634
+ "demasiado-grande": "el artefacto es demasiado grande",
2635
+ };
2636
+ responder(codigos[leido.motivo], textos[leido.motivo]);
2637
+ return;
2638
+ }
2639
+ const descargar = query.get("descargar") !== null;
2640
+ // Sin mime conocido se descarga en vez de adivinar. El nombre ya pasó la barrera de
2641
+ // segmento llano, así que entre comillas no puede romper la cabecera.
2642
+ const inline = !descargar && leido.mime !== undefined;
2643
+ respuesta.writeHead(200, {
2644
+ "Content-Type": inline ? tipoServido(leido.mime) : "application/octet-stream",
2645
+ "Content-Length": leido.datos.length,
2646
+ "Content-Security-Policy": "sandbox allow-scripts",
2647
+ "X-Content-Type-Options": "nosniff",
2648
+ // Un artefacto se sobrescribe con el mismo nombre en el turno siguiente, y una
2649
+ // respuesta cacheada enseñaría el dibujo de antes sin decirlo.
2650
+ "Cache-Control": "no-store",
2651
+ ...(inline ? {} : { "Content-Disposition": `attachment; filename="${leido.nombre}"` }),
2652
+ });
2653
+ respuesta.end(leido.datos);
2654
+ });
2655
+ /**
2656
+ * `POST /adjunto?tarea=<id>&nombre=<fichero>` — los BYTES de un adjunto de tarea.
2657
+ *
2658
+ * Por HTTP y no por el cable, que lleva JSON. Las comprobaciones de `Host`, `Origin` y
2659
+ * token las hace `servidor.ts` antes de llegar aquí, igual que a todas las rutas. Lo
2660
+ * propio de esta son cuatro cosas:
2661
+ *
2662
+ * - **El nombre pasa la MISMA barrera de segmento llano que un artefacto**
2663
+ * (`nombreDeAdjuntoAceptable`, `core/adjuntos.ts`), y el id de la tarea también: de ellos
2664
+ * se compone una ruta de disco. `searchParams` decodifica una vez, así que un `%2e%2e`
2665
+ * llega ya como `..` y lo caza la lista blanca; un `%252e%252e` llega con el `%` dentro,
2666
+ * que tampoco es texto llano. La barrera se aplica además OTRA vez dentro del puerto
2667
+ * —sobre el texto y sobre el camino real—, que es donde se caza un enlace simbólico.
2668
+ * - **Una subida no puede caer en la carpeta de una tarea que ya existe** (409). El id lo
2669
+ * elige el cliente, porque los bytes tienen que estar en disco ANTES de crear la tarea
2670
+ * (crear dispara `revisarTareas()` y el corredor puede arrancarla en el acto). Sin esta
2671
+ * guarda, un adjunto podría aterrizar en la carpeta de una tarea viva que el agente está
2672
+ * leyendo por `/adjuntos/` ahora mismo.
2673
+ * - **El cuerpo se lee con el tope del ADJUNTO, no con el del cable** (1 MB): 413 en
2674
+ * cuanto se pasa, y se corta ahí — no se acumulan 20 MB para después rechazarlos.
2675
+ * - **Ninguna respuesta lleva una ruta de la máquina** ni nada de lo recibido, que es la
2676
+ * regla de `POST /accion`.
2677
+ */
2678
+ servidor.registrarRuta("POST", RUTA_ADJUNTO, async (peticion, respuesta) => {
2679
+ const responder = (codigo, texto) => {
2680
+ respuesta.writeHead(codigo, { "Content-Type": "text/plain; charset=utf-8" });
2681
+ respuesta.end(texto);
2682
+ };
2683
+ const query = new URLSearchParams((peticion.url ?? "").split("?")[1] ?? "");
2684
+ const tarea = query.get("tarea");
2685
+ const nombre = query.get("nombre");
2686
+ if (tarea === null || tarea === "" || nombre === null || nombre === "") {
2687
+ responder(400, "faltan «tarea» o «nombre»");
2688
+ return;
2689
+ }
2690
+ if (!nombreDeAdjuntoAceptable(tarea) || !nombreDeAdjuntoAceptable(nombre)) {
2691
+ responder(403, "ese nombre no vale para un adjunto");
2692
+ return;
2693
+ }
2694
+ if (opciones.colaDeTareas === undefined) {
2695
+ responder(404, "esta consola no ejecuta tareas");
2696
+ return;
2697
+ }
2698
+ if (opciones.colaDeTareas.listar().some((t) => t.id === tarea)) {
2699
+ responder(409, "ese identificador ya es de una tarea: los adjuntos se suben antes de crearla");
2700
+ return;
2701
+ }
2702
+ let datos;
2703
+ try {
2704
+ datos = await leerCuerpoCrudo(peticion, TOPE_DE_ADJUNTO);
2705
+ }
2706
+ catch {
2707
+ // Se pasó del tope, o el flujo se cortó. NO se devuelve nada de lo recibido: es la
2708
+ // misma regla que `POST /accion`, y aquí lo recibido son los bytes de un documento
2709
+ // de una persona.
2710
+ responder(413, "el adjunto es demasiado grande");
2711
+ return;
2712
+ }
2713
+ const guardado = opciones.colaDeTareas.guardarAdjunto(tarea, nombre, datos);
2714
+ if (!guardado.ok) {
2715
+ // El motivo lo escribe el puerto y no lleva ninguna ruta (su test lo vigila). 413 para
2716
+ // los dos topes —por fichero y por tarea— porque los dos son «no cabe».
2717
+ responder(413, guardado.motivo ?? "no se pudo guardar el adjunto");
2718
+ return;
2719
+ }
2720
+ respuesta.writeHead(204);
2721
+ respuesta.end();
2722
+ });
2723
+ servidor.registrarRuta("POST", RUTA_ACCION, async (peticion, respuesta) => {
2724
+ let mensaje;
2725
+ try {
2726
+ mensaje = JSON.parse(await leerCuerpo(peticion));
2727
+ }
2728
+ catch {
2729
+ // Cuerpo ilegible o demasiado grande. NO se devuelve nada de lo recibido: por aquí
2730
+ // pasa la clave de API del paso de cuenta, y un eco la dejaría en el log del cliente.
2731
+ respuesta.writeHead(400);
2732
+ respuesta.end();
2733
+ return;
2734
+ }
2735
+ if (typeof mensaje === "object" &&
2736
+ mensaje !== null &&
2737
+ mensaje.clase === "entorno" &&
2738
+ mensaje.accion === "activo") {
2739
+ void atenderEntornoActivo(mensaje.entorno);
2740
+ respuesta.writeHead(204);
2741
+ respuesta.end();
2742
+ return;
2743
+ }
2744
+ if (typeof mensaje === "object" &&
2745
+ mensaje !== null &&
2746
+ mensaje.clase === "entorno" &&
2747
+ mensaje.accion === "proyectos") {
2748
+ void atenderProyectosDeEntorno(mensaje.entorno);
2749
+ respuesta.writeHead(204);
2750
+ respuesta.end();
2751
+ return;
2752
+ }
2753
+ if (typeof mensaje === "object" &&
2754
+ mensaje !== null &&
2755
+ mensaje.clase === "entorno" &&
2756
+ mensaje.accion === "visibles") {
2757
+ void vestibulo
2758
+ .guardarProyectosVisibles(mensaje.entorno, mensaje.proyectos)
2759
+ .catch(contar)
2760
+ .finally(() => void anunciarAlta().catch(contar));
2761
+ respuesta.writeHead(204);
2762
+ respuesta.end();
2763
+ return;
2764
+ }
2765
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "revision") {
2766
+ void atenderRevision(mensaje.ruta).catch(contar);
2767
+ respuesta.writeHead(204);
2768
+ respuesta.end();
2769
+ return;
2770
+ }
2771
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "arbol") {
2772
+ void atenderArbol().catch(contar);
2773
+ respuesta.writeHead(204);
2774
+ respuesta.end();
2775
+ return;
2776
+ }
2777
+ if (typeof mensaje === "object" &&
2778
+ mensaje !== null &&
2779
+ mensaje.clase === "sync" &&
2780
+ (mensaje.accion === "estado" || mensaje.accion === "subir" || mensaje.accion === "bajar")) {
2781
+ // Una acción que no se entiende NO cae en `estado`: en esta casa lo que no se
2782
+ // entiende se rechaza, y `estado` solo se alcanza nombrándolo.
2783
+ atenderSync(mensaje.accion);
2784
+ respuesta.writeHead(204);
2785
+ respuesta.end();
2786
+ return;
2787
+ }
2788
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "fichero" && typeof mensaje.ruta === "string") {
2789
+ void atenderFichero(mensaje.ruta).catch(contar);
2790
+ respuesta.writeHead(204);
2791
+ respuesta.end();
2792
+ return;
2793
+ }
2794
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "artefacto" && typeof mensaje.nombre === "string") {
2795
+ void atenderArtefacto(mensaje.nombre).catch(contar);
2796
+ respuesta.writeHead(204);
2797
+ respuesta.end();
2798
+ return;
2799
+ }
2800
+ if (typeof mensaje === "object" &&
2801
+ mensaje !== null &&
2802
+ mensaje.clase === "receta" &&
2803
+ typeof mensaje.id === "string" &&
2804
+ typeof mensaje.paso === "number" &&
2805
+ (mensaje.accion === "ejecutar" || mensaje.accion === "cancelar")) {
2806
+ atenderReceta(mensaje);
2807
+ respuesta.writeHead(204);
2808
+ respuesta.end();
2809
+ return;
2810
+ }
2811
+ /**
2812
+ * ANTES del `recibir` de la consola, y también antes que las demás clases de tarea: por
2813
+ * aquí no entra nada hacia ningún turno. Ver `atenderMirar`.
2814
+ */
2815
+ if (typeof mensaje === "object" &&
2816
+ mensaje !== null &&
2817
+ mensaje.clase === "mirar" &&
2818
+ typeof mensaje.tarea === "string" &&
2819
+ typeof mensaje.cliente === "string" &&
2820
+ typeof mensaje.ver === "boolean") {
2821
+ atenderMirar(mensaje);
2822
+ respuesta.writeHead(204);
2823
+ respuesta.end();
2824
+ return;
2825
+ }
2826
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "tarea") {
2827
+ if (mensaje.accion === "crear" && opciones.colaDeTareas !== undefined) {
2828
+ const creada = atenderCrearTarea(mensaje.proyecto, mensaje.peticion, mensaje.encargo, mensaje.borrador);
2829
+ if (creada !== undefined) {
2830
+ opciones.revisarTareas?.();
2831
+ emitirTareas();
2832
+ }
2833
+ }
2834
+ else if (mensaje.accion === "augmentar" && opciones.augmentar !== undefined) {
2835
+ void atenderAugmentar(opciones.augmentar, mensaje.proyecto, mensaje.peticion, mensaje.borrador);
2836
+ }
2837
+ else if (mensaje.accion === "feedback" && opciones.colaDeTareas !== undefined) {
2838
+ atenderFeedbackDeTarea(mensaje.id, mensaje.texto);
2839
+ opciones.revisarTareas?.();
2840
+ emitirTareas();
2841
+ }
2842
+ else if (mensaje.accion !== "crear" &&
2843
+ mensaje.accion !== "augmentar" &&
2844
+ mensaje.accion !== "feedback" &&
2845
+ opciones.colaDeTareas !== undefined) {
2846
+ /**
2847
+ * Ya no es `void x(); revisarTareas(); emitirTareas();` seguidas — reintentar y
2848
+ * terminar siguen siendo instantáneos, pero descartar puede esperar a que el
2849
+ * corredor corte un turno en vuelo (Task 14), y emitir la cola ANTES de que eso
2850
+ * termine enseñaría la tarea todavía «en proceso» un instante antes de que
2851
+ * `borrarTarea` la quite de verdad. Se secuencia con `.then()`, el mismo patrón que
2852
+ * el resto de acciones asíncronas de este manejador (`atenderRevision`,
2853
+ * `atenderArbol`…), y el fallo se cuenta y no se propaga: la respuesta HTTP ya se
2854
+ * mandó en el acto.
2855
+ */
2856
+ void atenderAccionDeTarea(mensaje.accion, mensaje.id)
2857
+ .then(() => {
2858
+ opciones.revisarTareas?.();
2859
+ emitirTareas();
2860
+ })
2861
+ .catch(contar);
2862
+ }
2863
+ respuesta.writeHead(204);
2864
+ respuesta.end();
2865
+ return;
2866
+ }
2867
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "tareas" && typeof mensaje.concurrencia === "number") {
2868
+ // Cambiar el tope no vale nada sin volver a revisar: con la cola llena y hueco nuevo,
2869
+ // sin este empujón se quedaría esperando al siguiente evento que no tiene nada que
2870
+ // ver — la misma razón por la que crear y accionar revisan tras escribir.
2871
+ opciones.guardarConcurrencia?.(mensaje.concurrencia);
2872
+ opciones.revisarTareas?.();
2873
+ emitirTareas();
2874
+ respuesta.writeHead(204);
2875
+ respuesta.end();
2876
+ return;
2877
+ }
2878
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "modelosDeMotor" && typeof mensaje.motor === "string") {
2879
+ void atenderModelosDeMotor(mensaje.motor).catch(contar);
2880
+ respuesta.writeHead(204);
2881
+ respuesta.end();
2882
+ return;
2883
+ }
2884
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "cancelar") {
2885
+ // Parar ESTE turno, no cerrar la conversación. Sin proyecto abierto no hay turno que
2886
+ // parar y se dice: un botón que no puede cumplir no puede callar.
2887
+ const abierto = vestibulo.proyectoAbierto();
2888
+ if (abierto === undefined || !abierto.cancelarTurno()) {
2889
+ informar("no hay ningún turno en vuelo que parar");
2890
+ }
2891
+ respuesta.writeHead(204);
2892
+ respuesta.end();
2893
+ return;
2894
+ }
2895
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "sesion") {
2896
+ // Suelto: abrir un proyecto arranca una consola entera y el `POST` no se queda
2897
+ // esperando. Lo que pase se cuenta por el cable.
2898
+ void atenderSesion(mensaje);
2899
+ respuesta.writeHead(204);
2900
+ respuesta.end();
2901
+ return;
2902
+ }
2903
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "sesionAccion") {
2904
+ void atenderAccionDeSesion(mensaje).catch(contar);
2905
+ respuesta.writeHead(204);
2906
+ respuesta.end();
2907
+ return;
2908
+ }
2909
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "modelo") {
2910
+ atenderModelo(mensaje.id);
2911
+ respuesta.writeHead(204);
2912
+ respuesta.end();
2913
+ return;
2914
+ }
2915
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "agente") {
2916
+ atenderAgente(mensaje);
2917
+ respuesta.writeHead(204);
2918
+ respuesta.end();
2919
+ return;
2920
+ }
2921
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "proveedor") {
2922
+ atenderProveedor(mensaje);
2923
+ respuesta.writeHead(204);
2924
+ respuesta.end();
2925
+ return;
2926
+ }
2927
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "credencial") {
2928
+ atenderCredencial(mensaje);
2929
+ respuesta.writeHead(204);
2930
+ respuesta.end();
2931
+ return;
2932
+ }
2933
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "dispositivo") {
2934
+ // **El cliente manda el ID y nada más.** El nombre, la plataforma y la clase salen de
2935
+ // la última MEDIDA: son datos sobre la máquina, y el navegador no es fuente sobre la
2936
+ // máquina — aceptar su versión dejaría entrar un «iPhone 16» que nadie ha visto. Un id
2937
+ // que no esté en la medida se ignora en silencio: es una foto vieja del cliente
2938
+ // (desenchufaron el teléfono entre medias), no un error que contar.
2939
+ const abierta = vestibulo.proyectoAbierto();
2940
+ const pedido = mensaje.id;
2941
+ if (abierta !== undefined) {
2942
+ if (pedido === undefined)
2943
+ abierta.elegirDispositivo(undefined);
2944
+ else if (typeof pedido === "string") {
2945
+ const d = informeDeDispositivos?.dispositivos.find((x) => x.id === pedido);
2946
+ if (d !== undefined) {
2947
+ abierta.elegirDispositivo({ id: d.id, nombre: d.nombre, plataforma: d.plataforma, clase: d.clase });
2948
+ }
2949
+ }
2950
+ // Suelto: `anunciarAlta` consulta CloudStudio y el `POST` no puede quedarse
2951
+ // esperando por eso. El alta nueva llega por el SSE, como siempre.
2952
+ void anunciarAlta().catch(contar);
2953
+ }
2954
+ respuesta.writeHead(204);
2955
+ respuesta.end();
2956
+ return;
2957
+ }
2958
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "conexion") {
2959
+ // Suelto: hablarle a un dispositivo tarda segundos (adb en frío arranca su demonio) y
2960
+ // la respuesta va por el SSE, como la foto.
2961
+ const id = mensaje.id;
2962
+ if (typeof id === "string")
2963
+ void atenderConexion(id).catch(contar);
2964
+ respuesta.writeHead(204);
2965
+ respuesta.end();
2966
+ return;
2967
+ }
2968
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "revisarLanzamiento") {
2969
+ // Suelto, como `conexion`: medir el framework es un adb, y la respuesta va por el SSE
2970
+ // en forma de `lanzable`.
2971
+ void atenderRevisarLanzamiento().catch(contar);
2972
+ respuesta.writeHead(204);
2973
+ respuesta.end();
2974
+ return;
2975
+ }
2976
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "lanzarApp") {
2977
+ // Suelto: revalida (otro adb), y el lanzamiento entero tarda minutos. La respuesta va
2978
+ // por el SSE, en forma de `lanzamiento`, y no se espera aquí ni de lejos.
2979
+ void atenderLanzarApp().catch(contar);
2980
+ respuesta.writeHead(204);
2981
+ respuesta.end();
2982
+ return;
2983
+ }
2984
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "cancelarLanzamiento") {
2985
+ // Este SÍ es síncrono: `cancelar` solo mata el grupo del proceso en curso.
2986
+ atenderCancelarLanzamiento();
2987
+ respuesta.writeHead(204);
2988
+ respuesta.end();
2989
+ return;
2990
+ }
2991
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "dispositivos") {
2992
+ // Suelto, como el catálogo: lanza procesos que tardan segundos y la respuesta va por
2993
+ // el SSE. Es la ÚNICA forma de volver a medir: no hay sondeo.
2994
+ //
2995
+ // Con `ajustes` se GUARDAN primero y después se mide, en ese orden y no al revés: el
2996
+ // detector los lee de disco en cada medida, así que medir antes de guardar daría la
2997
+ // foto de la configuración anterior justo cuando se acaba de cambiar. Un fallo al
2998
+ // escribir NO impide medir —se cuenta y se sigue—, porque la foto sigue siendo
2999
+ // verdad; lo que no se puede es callarlo.
3000
+ const pedidos = mensaje.ajustes;
3001
+ if (typeof pedidos === "object" && pedidos !== null && opciones.guardarAjustesDeDispositivos !== undefined) {
3002
+ const limpios = {};
3003
+ for (const plataforma of PLATAFORMAS_DE_DISPOSITIVO) {
3004
+ const valor = pedidos[plataforma];
3005
+ if (typeof valor === "boolean")
3006
+ limpios[plataforma] = valor;
3007
+ }
3008
+ try {
3009
+ opciones.guardarAjustesDeDispositivos(limpios);
3010
+ }
3011
+ catch (error) {
3012
+ informar(`no se pudieron guardar los destinos de prueba (${codigoDe(error)})`);
3013
+ }
3014
+ }
3015
+ // Instalar va ANTES de medir y por el nombre, nunca por un comando del cliente. Se
3016
+ // espera a que termine para que la foto de después cuente la verdad: medir mientras
3017
+ // el instalador corre diría que la herramienta sigue sin estar.
3018
+ const aInstalar = mensaje.instalar;
3019
+ const instalador = opciones.instalarHerramienta;
3020
+ if (typeof aInstalar === "string" && instalador !== undefined && esNombreDeHerramienta(aInstalar)) {
3021
+ void (async () => {
3022
+ try {
3023
+ await instalador(aInstalar);
3024
+ }
3025
+ catch (error) {
3026
+ // Un instalador que falla no puede tumbar nada: se cuenta y se mide igual, que
3027
+ // es lo que dirá si la herramienta apareció o no.
3028
+ informar(`no se pudo instalar ${aInstalar} (${codigoDe(error)})`);
3029
+ }
3030
+ informeDeDispositivos = undefined;
3031
+ await atenderDispositivos().catch(contar);
3032
+ })();
3033
+ respuesta.writeHead(204);
3034
+ respuesta.end();
3035
+ return;
3036
+ }
3037
+ informeDeDispositivos = undefined;
3038
+ void atenderDispositivos().catch(contar);
3039
+ respuesta.writeHead(204);
3040
+ respuesta.end();
3041
+ return;
3042
+ }
3043
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "catalogo") {
3044
+ // Suelto, como el alta: consultar un catálogo es una petición de red y el `POST` no
3045
+ // se queda abierto esperándola. La respuesta viaja por el SSE.
3046
+ void atenderCatalogo(mensaje.proveedor).catch(contar);
3047
+ respuesta.writeHead(204);
3048
+ respuesta.end();
3049
+ return;
3050
+ }
3051
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "alta") {
3052
+ // Suelto y sin esperar: el alta hace dos viajes a CloudStudio y una descarga entera,
3053
+ // y el `POST` no puede quedarse abierto minutos. Lo que pase se cuenta por el cable.
3054
+ void atenderAlta(mensaje);
3055
+ }
3056
+ else {
3057
+ destinoActual().recibir(mensaje);
3058
+ /**
3059
+ * Y si fue una PROSA, se reanuncia el alta: la sesión acaba de darse de alta en el
3060
+ * índice (`ConsolaDeProyecto.recibir`), así que hay una fila nueva que la barra tiene
3061
+ * que enseñar YA — con su título y marcada como la activa.
3062
+ *
3063
+ * Diferido por lo mismo que el reanuncio de los flancos: el lazo coge la línea en una
3064
+ * microtarea, y anunciar en el acto contaría el turno como no empezado. No sustituye a
3065
+ * los flancos —de ellos sale la marca de «trabajando»—, cubre el caso en que no hay
3066
+ * flanco ninguno: un `/comando`, que no corre turno.
3067
+ */
3068
+ if (mensaje.clase === "prosa") {
3069
+ void Promise.resolve()
3070
+ .then(() => anunciarAlta())
3071
+ .catch(contar);
3072
+ }
3073
+ }
3074
+ respuesta.writeHead(204);
3075
+ respuesta.end();
3076
+ });
3077
+ return { emitirTareas };
3078
+ }
3079
+ /**
3080
+ * Construye el corredor de tareas y la costura que lo conecta al cable.
3081
+ *
3082
+ * Extraído de `arrancarConsolaWeb` a su propia función por la MISMA razón que
3083
+ * `backendDeAgente` (`agent/proyecto.ts`): la composición vivía inline dentro de una
3084
+ * función que todos sus tests DOBLAN —`vestibulo`, `crearServidor`, `crearEjecutor`—, así
3085
+ * que esta costura concreta podía dejar de estar montada con el resto en verde. Aquí
3086
+ * tiene su propio test que la compone de VERDAD: construye un corredor real
3087
+ * (`crearCorredorDeTareas`) y comprueba que un cambio que el corredor mueve por su
3088
+ * cuenta —nace DENTRO del lazo, no de una petición del cliente— llega a
3089
+ * `emitirTareas`, y que si `emitirTareas` revienta el lazo de tareas no se entera.
3090
+ */
3091
+ /**
3092
+ * Las fuentes con que se resuelve el papel del JUEZ para una tarea, por la raíz de SU
3093
+ * proyecto.
3094
+ *
3095
+ * Está extraída y exportada por el mismo motivo que `revisionConGit` y que `backendDeAgente`:
3096
+ * vivía dentro del cierre de `arrancarConsolaWeb`, que todos sus tests doblan, así que
3097
+ * quitarle la capa de proyecto no ponía ni un test en rojo — comprobado por mutación. Es la
3098
+ * cuarta vez en esta tanda que una composición de producción escondida en un cierre deja una
3099
+ * regla sin montar con todo en verde.
3100
+ *
3101
+ * Las dos capas salen de `cargar(raiz)`, que trae el `config.json` del proyecto y el global:
3102
+ * en la consola web `FuentesDeEleccion.proyecto` no se rellena nunca —el vestíbulo sirve
3103
+ * muchos proyectos y las fuentes se construyen una vez al arrancar—, así que sin preguntarle
3104
+ * al disco por la raíz de la tarea, un proyecto que apuntara `afilado` a otro modelo se
3105
+ * ignoraba en silencio. La misma trampa que `cloudstudioDelProyecto` ya resolvió así.
3106
+ *
3107
+ * `proyecto` se omite en vez de ponerse a `undefined`: ausente es «este proyecto no dice
3108
+ * nada», y la precedencia de `core/modelos.ts` cuenta con eso.
3109
+ */
3110
+ export function fuentesDelJuez(raiz) {
3111
+ const { config } = cargar(raiz);
3112
+ return {
3113
+ ...(config.proyecto === undefined ? {} : { proyecto: config.proyecto }),
3114
+ global: config.global,
3115
+ entorno: { XONECODE_MODELO: process.env.XONECODE_MODELO },
3116
+ };
3117
+ }
3118
+ /**
3119
+ * Las fuentes del modelo con que arranca CADA consola de proyecto del vestíbulo.
3120
+ *
3121
+ * Es una función y no una constante por lo mismo que `fuentesDelJuez` está extraída: vivía
3122
+ * dentro del cierre de `vestibuloReal` y se construía UNA vez, al arrancar. El global es del
3123
+ * usuario —esta misma consola lo reescribe desde Ajustes—, así que la instantánea dejaba a
3124
+ * las sesiones NUEVAS resolviendo contra el valor viejo mientras Ajustes enseñaba ya el
3125
+ * elegido. El síntoma, medido: el proceso arrancó con `gemini` en el fichero, el fichero pasó
3126
+ * a `ollama`, y una sesión nueva seguía diciendo `gemini`.
3127
+ *
3128
+ * La capa de PROYECTO no se rellena —el vestíbulo sirve muchos proyectos a la vez y aquí no
3129
+ * hay uno del que hablar—, así que se llama a `cargar` por el cwd SOLO para llegar al fichero
3130
+ * global, que es el único que este lector tiene que ver. Mismo camino que `modeloPorDefecto`.
3131
+ */
3132
+ export function fuentesDeLaConsolaWeb(cwd) {
3133
+ return {
3134
+ global: cargar(cwd).config.global,
3135
+ entorno: { XONECODE_MODELO: process.env.XONECODE_MODELO },
3136
+ };
3137
+ }
3138
+ /**
3139
+ * Cuánta memoria de proyecto se le da al aumentador.
3140
+ *
3141
+ * Es CONTEXTO de una llamada, no un fichero que servir: `.xonecode/memoria.md` lo escribe el
3142
+ * agente turno tras turno y puede crecer sin tope. Y el árbol del proyecto ya se descartó por
3143
+ * lo mismo (§7 del diseño: «el árbol entero sería contexto por gastar»); dejar la memoria sin
3144
+ * acotar reintroduciría el problema por la puerta de al lado.
3145
+ */
3146
+ export const TOPE_DE_MEMORIA = 4_000;
3147
+ /**
3148
+ * Lo que se le añade a la petición del aumentador leyendo el DISCO por la raíz: la rama del
3149
+ * proyecto y su memoria.
3150
+ *
3151
+ * **Por la raíz y no por las fuentes**, la trampa que `cloudstudioDelProyecto` ya resolvió:
3152
+ * en la consola web `FuentesDeEleccion.proyecto` no se rellena nunca. Y lo que el disco no
3153
+ * dice NO se pone —ausente es «no hay»—: un `rama: undefined` o un `memoria: ""` en el prompt
3154
+ * harían que el modelo hablara de una rama sin nombre como si fuera un dato.
3155
+ *
3156
+ * Nada de esto lanza. Que no haya memoria, o que el `config.json` no se pueda leer, no puede
3157
+ * impedir redactar un encargo.
3158
+ */
3159
+ export function contextoDelProyecto(raiz) {
3160
+ const rama = cloudstudioDelProyecto(raiz)?.rama;
3161
+ let memoria;
3162
+ try {
3163
+ const leido = readFileSync(rutaMemoriaDeProyecto(raiz), "utf8").trim();
3164
+ if (leido !== "")
3165
+ memoria = leido.slice(0, TOPE_DE_MEMORIA);
3166
+ }
3167
+ catch {
3168
+ // No hay memoria, o no se puede leer: no se afirma ninguna.
3169
+ }
3170
+ return {
3171
+ ...(rama === undefined || rama === "" ? {} : { rama }),
3172
+ ...(memoria === undefined ? {} : { memoria }),
3173
+ };
3174
+ }
3175
+ /**
3176
+ * Cruza la elección GUARDADA de la sesión con la última MEDIDA del equipo.
3177
+ *
3178
+ * **Esto es la costura entera de `dispositivo-desconocido`, y sin ella esa causa no tiene
3179
+ * emisor.** Las dos situaciones —«no has elegido ninguno» y «elegiste uno que ya no está»—
3180
+ * llegan a `puedeLanzarse` con `dispositivo: undefined`, porque la diferencia no cabe en el
3181
+ * dato del dispositivo: no hay ninguno. Lo único que las separa es el `elegido`, y quien lo
3182
+ * sabe es la SESIÓN, que guarda la foto (`ConsolaDeProyecto.dispositivo`) mientras el informe
3183
+ * la resuelve.
3184
+ *
3185
+ * **Se resuelve por lo que NO se encontró**: con un id guardado que no está en la medida, la
3186
+ * causa es `dispositivo-desconocido`; sin id guardado no hay nada que buscar y ése es
3187
+ * `sin-dispositivo-elegido`. Y cuando el id SÍ resuelve, `elegido` no se pone — manda
3188
+ * `dispositivo`, que es el dato bueno.
3189
+ *
3190
+ * La rama `dispositivo` del cable (`POST /accion`) ya tolera en silencio un id que no esté en
3191
+ * la medida —es una foto vieja del cliente, no un error que contar—; aquí ese mismo caso se
3192
+ * CUENTA, porque es justo lo que decide la frase que se le enseña a quien va a pulsar.
3193
+ *
3194
+ * Extraída y exportada por la razón de siempre: es una regla de producción, y compuesta dentro
3195
+ * de un cierre que los tests doblan no estaría probada — estaría escrita.
3196
+ */
3197
+ export function dispositivoDeLaSesion(elegido, informe) {
3198
+ if (elegido === undefined)
3199
+ return { dispositivo: undefined };
3200
+ const fila = informe?.dispositivos.find((d) => d.id === elegido.id);
3201
+ if (fila === undefined)
3202
+ return { dispositivo: undefined, elegido: elegido.id };
3203
+ return { dispositivo: fila };
3204
+ }
3205
+ /**
3206
+ * La foto de un dispositivo tal y como viaja: las CUATRO cosas que la pestaña pinta, y nada
3207
+ * más. `estado` y `detalle` se quedan fuera a propósito — son de la medida, y la medida ya
3208
+ * viaja entera por el mensaje `dispositivos`; repetirla en cada veredicto sería una segunda
3209
+ * copia del mismo dato, que es la que se queda vieja.
3210
+ */
3211
+ function fotoDeDispositivo(dispositivo) {
3212
+ return {
3213
+ id: dispositivo.id,
3214
+ nombre: dispositivo.nombre,
3215
+ plataforma: dispositivo.plataforma,
3216
+ clase: dispositivo.clase,
3217
+ };
3218
+ }
3219
+ /**
3220
+ * Por qué no se lanza aunque el veredicto diga que sí, en los dos casos que el veredicto no
3221
+ * cubre a propósito.
3222
+ *
3223
+ * No son causas de `CausaDeBloqueo`: el catálogo de `core/puedeLanzarse.ts` dice qué le falta
3224
+ * al DISPOSITIVO o al PROYECTO, y estos dos dicen qué le falta a ESTA ejecución para poder
3225
+ * hacer el trabajo. Meterlos en la unión del veredicto mezclaría «tu proyecto no está listo»
3226
+ * con «yo no puedo», que se arreglan en sitios distintos.
3227
+ */
3228
+ const SIN_NOMBRE_DE_APP = "No se puede lanzar sin saber cómo se llama la app: el nombre sale del `name=` de app.ini, y ese fichero no está o no lo dice. Ábrelo en XOne Studio y vuelve a guardarlo.";
3229
+ const SIN_CAMINO_DE_LANZAMIENTO = "Esta ejecución de xonecode no puede lanzar apps —no tiene montado el lanzamiento—, así que el veredicto se puede mirar pero no ejecutar.";
3230
+ /**
3231
+ * Lo que la pestaña CloudStudio enseña: de qué rama es este proyecto y cuántos ficheros
3232
+ * quedan por subir.
3233
+ *
3234
+ * **Extraída y exportada, no inline en el manejador del cable**, por la razón de siempre en
3235
+ * esta casa: una regla de producción compuesta dentro de un cierre que los tests doblan no
3236
+ * está probada, está escrita. Aquí lo que se caería sin síntoma es la MEDIDA —una lectura
3237
+ * que devolviera cero pendientes siempre se vería como un proyecto al día, que es
3238
+ * exactamente lo que nadie iría a comprobar—.
3239
+ *
3240
+ * Se mide contra la ref de seguimiento (`refs/remotes/cloudstudio/<rama>`), que es la MISMA
3241
+ * cuenta que da `/sync estado` y la que decide qué sube el plan, y **sin abrir sesión MCP**:
3242
+ * el número no está arriba, está en esa ref, y abrir OAuth para contar lo que ya se sabe en
3243
+ * local sería pedir red y credenciales para pintar un contador. El comando del terminal sí
3244
+ * la abre —es genérico y no se toca—; la diferencia se declara aquí.
3245
+ *
3246
+ * Un mensaje SIN `proyecto` ni `rama` no es un fallo: es que este proyecto no está dado de
3247
+ * alta en CloudStudio, y quien lo pinta tiene que poder decir eso en vez de un cero.
3248
+ *
3249
+ * `sesion` entra para poder decir DE QUIÉN son los pendientes (`deLaSesion`), que es lo que
3250
+ * evita que las dos cifras de la pestaña —la de la banda y la de la lista— parezcan
3251
+ * contradecirse cuando en realidad se miden contra referencias distintas.
3252
+ */
3253
+ export async function lecturaDeSync(raiz, sesion) {
3254
+ const cloudstudio = cloudstudioDelProyecto(raiz);
3255
+ const proyecto = cloudstudio?.proyecto?.nombre;
3256
+ const rama = cloudstudio?.rama;
3257
+ if (proyecto === undefined || rama === undefined || rama === "")
3258
+ return { clase: "sync" };
3259
+ try {
3260
+ const pendientes = await cambiosPendientes(raiz, rama);
3261
+ const deLaSesion = await cuantosDeLaSesion(raiz, sesion, pendientes);
3262
+ return {
3263
+ clase: "sync",
3264
+ proyecto,
3265
+ rama,
3266
+ pendientes: pendientes.length,
3267
+ ...(deLaSesion === undefined ? {} : { deLaSesion }),
3268
+ };
3269
+ }
3270
+ catch {
3271
+ // Ni el `code` de Node ni el stderr de git: lo que aquí falla es que la copia no se
3272
+ // puede comparar con la rama (no es un repo, o no tiene la ref de la descarga), y eso
3273
+ // se dice con palabras. El mensaje de git trae rutas absolutas del disco.
3274
+ return {
3275
+ clase: "sync",
3276
+ proyecto,
3277
+ rama,
3278
+ error: "no se pudo medir lo que falta por subir: la copia local no se puede comparar con la rama",
3279
+ };
3280
+ }
3281
+ }
3282
+ /**
3283
+ * De los pendientes, cuántos tocó ESTA sesión — o `undefined` cuando no se puede atribuir.
3284
+ *
3285
+ * Existe porque las dos cifras que la pestaña enseña juntas se miden contra referencias
3286
+ * distintas: `pendientes` contra la rama (todo lo que difiere de lo que consta arriba, sea de
3287
+ * quien sea) y la lista de Revisión contra el sello de la sesión. Sin esto, la banda dice
3288
+ * «3 ficheros por subir» encima de una sesión cuyos cambios no están entre esos 3, y las dos
3289
+ * cifras parecen contradecirse cuando lo que pasa es que hablan de trabajos distintos.
3290
+ *
3291
+ * **`via !== "git"` se trata como no atribuible, y es la decisión que importa.** Las otras
3292
+ * dos vías no son atribución: `desde-apertura` es «todo lo que cambió en esta copia desde que
3293
+ * te sentaste», que incluye lo que escribieran una tarea de fondo o el propio usuario, y
3294
+ * `sin-marca` es no saber. Devolver un número con esas dos afirmaría «esta sesión tocó N de
3295
+ * estos» sobre algo que la sesión no tocó — la misma mentira que `Revision` evita rotulando
3296
+ * esas filas «Desde que abriste» en vez de «Sesión».
3297
+ *
3298
+ * Y **un fallo aquí no puede llevarse por delante la medida que sí se hizo**: arriba hay un
3299
+ * `git diff` que ya contestó, y convertir eso en «no se pudo medir lo que falta por subir»
3300
+ * porque la atribución falló sería tirar el dato bueno por el malo. Se devuelve `undefined`,
3301
+ * que es exactamente «no consta» y lo que la banda sabe no pintar.
3302
+ */
3303
+ async function cuantosDeLaSesion(raiz, sesion, pendientes) {
3304
+ if (sesion === undefined)
3305
+ return undefined;
3306
+ try {
3307
+ const { via, ficheros } = await cambiosDeSesion(raiz, sesion);
3308
+ if (via !== "git")
3309
+ return undefined;
3310
+ const suyos = new Set(ficheros.map((fichero) => fichero.ruta));
3311
+ return pendientes.filter((cambio) => suyos.has(cambio.ruta)).length;
3312
+ }
3313
+ catch {
3314
+ return undefined;
3315
+ }
3316
+ }
3317
+ /**
3318
+ * La costura entre el cable y el aumentador: convierte lo que `montarRutas` resuelve
3319
+ * —proyecto y adjuntos— en la `PeticionDeTarea` del puerto, añadiéndole lo que solo se sabe
3320
+ * mirando el disco.
3321
+ *
3322
+ * **Extraída y exportada, no inline en `arrancarConsolaWeb`**, por la misma razón que
3323
+ * `revisionConGit` y `fuentesDelJuez`: en esta tanda cuatro veces una composición de
3324
+ * producción vivía en un cierre que todos sus tests doblan, y una regla se quedó sin montar
3325
+ * con todo en verde. Lo que aquí se caería sin dar ni un síntoma es la mitad del contexto: un
3326
+ * encargo redactado sin la rama ni la memoria del proyecto se ve perfectamente normal.
3327
+ *
3328
+ * El error se PROPAGA: quien lo convierte en palabras para la ventana es `atenderAugmentar`,
3329
+ * que aplica la regla del mensaje-o-código. Tragárselo aquí dejaría a la ventana con un
3330
+ * encargo vacío y sin motivo.
3331
+ */
3332
+ export function augmentacionCableada(opciones) {
3333
+ return async ({ texto, proyecto, adjuntos }) => {
3334
+ const extra = opciones.contexto?.(proyecto.raiz) ?? {};
3335
+ return opciones.aumentador.augmentar({
3336
+ texto,
3337
+ // El `id` del proyecto NO viaja al modelo: es un identificador de CloudStudio y para
3338
+ // redactar no aporta nada. Sí la raíz, que es con lo que se resuelve el papel.
3339
+ proyecto: { nombre: proyecto.nombre, raiz: proyecto.raiz, ...(extra.rama === undefined ? {} : { rama: extra.rama }) },
3340
+ adjuntos,
3341
+ ...(extra.memoria === undefined ? {} : { memoria: extra.memoria }),
3342
+ });
3343
+ };
3344
+ }
3345
+ /**
3346
+ * El commit de cada turno, cableado — y extraído por el motivo documentado en CLAUDE.md:
3347
+ * esta composición vivía en un cierre de `arrancarConsolaWeb` que todos sus tests doblan, y
3348
+ * es LA MISMA forma de fallo que ya se ha medido tres veces en `abrirParaTarea` (una lambda
3349
+ * escrita a mano que se deja un argumento, con TypeScript callado porque una función que
3350
+ * ignora parámetros es asignable). Aquí el argumento que se puede caer es el que sostiene la
3351
+ * atribución entera: sin `sesion`, el commit sale sin sello y la pestaña Revisión se cae al
3352
+ * respaldo para siempre, sin un solo síntoma.
3353
+ *
3354
+ * Dos decisiones que se quedan aquí y no en el vestíbulo:
3355
+ * - **DÓNDE se commitea**: solo en la copia que creó xonecode. En la carpeta que abrió una
3356
+ * persona —offline, o `./bin/xonecode` dentro de su repo— un commit por turno sería
3357
+ * ensuciarle el historial cada vez que habla con el agente.
3358
+ * - **Qué se DICE**: solo el fallo. Que no haya nada que commitear, que la carpeta no sea un
3359
+ * repo (todo proyecto offline) o que no sea del workspace no son avisos: son el caso
3360
+ * normal, y uno por turno enseñaría a ignorarlos.
3361
+ */
3362
+ export function commitDeTurnoCableado(opciones) {
3363
+ const commitear = opciones.commitear ?? commitDeTurno;
3364
+ return async (raiz, mensaje, sesion) => {
3365
+ if (!dentroDelWorkspace(raiz, opciones.base))
3366
+ return undefined;
3367
+ const hecho = await commitear(raiz, mensaje, sesion);
3368
+ return hecho.via === "fallo" ? `no se pudo commitear el turno: ${hecho.motivo}` : undefined;
3369
+ };
3370
+ }
3371
+ export function construirCorredorDeTareasCableado(opciones) {
3372
+ const disco = opciones.tareasFabrica === undefined ? undefined : opciones.tareasFabrica(opciones.informar);
3373
+ /**
3374
+ * El tope de concurrencia se LEE de `settings.json` en cada llamada, nunca se cachea —la
3375
+ * misma disciplina que `ajustesDeDispositivos` (más abajo) y que `sinAprobacion`: la
3376
+ * sección «Tareas» de Ajustes escribe con `guardarConcurrenciaDeTareas` mientras el
3377
+ * proceso vive, y una copia capturada al construir este corredor no la vería. Ausente en
3378
+ * disco = `CONCURRENCIA_POR_OMISION` (2), la misma omisión que ya usaba la variable en
3379
+ * memoria que esto sustituye.
3380
+ */
3381
+ const concurrenciaDeTareas = () => cargarSettings().settings.concurrenciaDeTareas ?? CONCURRENCIA_POR_OMISION;
3382
+ /**
3383
+ * El puente hacia `emitirTareas`. El corredor nace ANTES que el cable —`montarRutas`
3384
+ * necesita poder pedirle `revisar()` y `corriendoAqui()`—, pero su `alCambiar` (una
3385
+ * tarea que el CORREDOR mueve por su cuenta: empieza, aparca, termina) solo puede
3386
+ * alcanzar el cable una vez que existe. `arrancarConectado` es quien la rellena, antes
3387
+ * de arrancar. Sin este puente, el kanban solo se movería cuando alguien tocara algo
3388
+ * desde el navegador — la peor forma de estar roto: parece que funciona.
3389
+ */
3390
+ let emitirCambioDeTareas;
3391
+ const corredor = disco === undefined
3392
+ ? undefined
3393
+ : crearCorredorDeTareas({
3394
+ disco,
3395
+ // La costura de las tres piezas: la segunda puerta del vestíbulo (que no mueve el
3396
+ // cable), la consola que APARCA en vez de contestar por nadie, y el ejecutor de
3397
+ // siempre con las mismas barreras que el de una persona.
3398
+ // `sesion` reenviada: es lo que hace que reanudar (un reintento, o un feedback)
3399
+ // reabra el MISMO hilo en vez de uno en blanco. Ver `corredorDeTareas.ts`.
3400
+ // `sesion`, `adjuntos` y `tarea` reenviadas TAL CUAL, y las tres por el mismo
3401
+ // motivo: quien sabe de qué tarea es esta apertura es el corredor. La primera hace
3402
+ // que reanudar siga la MISMA conversación; la segunda es lo que monta `/adjuntos/`
3403
+ // en el backend del agente (`core/adjuntos.ts`); la tercera es lo que marca su
3404
+ // sesión en el índice del proyecto (`EntradaIndice.tarea`). Dejarse cualquiera no
3405
+ // da ningún síntoma —esto es una lambda que reenvía a mano, y TypeScript no se
3406
+ // queja de una función que ignora argumentos—: un hilo en blanco, unos adjuntos
3407
+ // que el agente no puede abrir, o una fila de la barra que miente para siempre.
3408
+ abrirParaTarea: async (raiz, sesion, adjuntos, tarea) => consolaParaTarea(await opciones.vestibulo.abrirParaTarea(raiz, sesion, adjuntos, tarea)),
3409
+ // La SIEMBRA de la marca en las sesiones que ya existían. Ver
3410
+ // `sesiones.ts#marcarTareaDeSesion`: solo añade, así que correrla en cada arranque
3411
+ // no puede convertir una sesión de tarea en una conversación.
3412
+ marcarSesionDeTarea: (raiz, sesion, tarea) => void marcarTareaDeSesion(raiz, sesion, tarea),
3413
+ // Se lee de disco en cada pasada: cambiar el tope en Ajustes se nota sin
3414
+ // reiniciar nada, en la siguiente ronda de planificación.
3415
+ concurrencia: concurrenciaDeTareas,
3416
+ /**
3417
+ * GANA LA PERSONA: en el proyecto que alguien tiene abierto no arranca ninguna
3418
+ * tarea. Una tarea y una persona sobre el mismo árbol no tienen aislamiento de
3419
+ * ninguna clase, y el cerrojo del corredor no protege de eso — protege de dos
3420
+ * corredores. Se pregunta en cada pasada, no al arrancar: abrir un proyecto no
3421
+ * reinicia nada.
3422
+ *
3423
+ * **TODAS las abiertas, no la que está en foco.** Desde que cambiar de sesión no
3424
+ * mata el turno anterior, una consola humana puede seguir viva en segundo plano
3425
+ * —con el agente escribiendo— mientras el navegador mira otro proyecto. Con
3426
+ * `proyectoAbierto()` esa raíz se habría contado como libre y una tarea habría
3427
+ * arrancado a escribir en la misma copia: el fallo ABIERTO de ese cambio, y el
3428
+ * síntoma no es un error sino un diff corrompido que nadie atribuye.
3429
+ */
3430
+ bloqueados: () => opciones.vestibulo.proyectosAbiertos().map((c) => c.raiz),
3431
+ /**
3432
+ * `sesion` sobrevive si y solo si hay algo que una persona pueda ABRIR, y esto es
3433
+ * lo que lo decide: la sesión está en el índice del proyecto —o sea que su
3434
+ * transcript se volcó y se lee desde la barra lateral— o no está. Es exactamente
3435
+ * la lista que la barra pinta, no una segunda fuente que pueda contradecirla.
3436
+ */
3437
+ sesionAbrible: (raiz, sesion) => opciones.vestibulo.sesionesDe(raiz).some((s) => s.id === sesion),
3438
+ // Y el hilo del agente de una sesión que no nombra nada abrible se olvida, igual
3439
+ // que al borrar una conversación: un checkpoint es la lista de mensajes entera y
3440
+ // crece, y ahí seguiría vivo e invisible desde la interfaz para siempre.
3441
+ olvidarHilo: opciones.olvidarHiloDeSesion,
3442
+ /**
3443
+ * La puerta de la ENTREGA: «terminada» ya no significa «el turno acabó». Las tres
3444
+ * condiciones las comprueba el código (`core/entrega.ts`) y el juez de QA opina, y
3445
+ * hacen falta las dos — es la regla que este repo ya tenía escrita para la subida
3446
+ * autónoma, y el mismo motivo por el que los avisos de honestidad son código y no
3447
+ * prompt.
3448
+ */
3449
+ juez: opciones.juez,
3450
+ revisable: opciones.revisable,
3451
+ /**
3452
+ * El puente hacia el cable, y NUNCA puede tumbar el lazo de tareas: un sumidero
3453
+ * muerto —o cualquier otra cosa que `emitirTareas` haga mal— es un problema del
3454
+ * cable, no de la tarea que el corredor acaba de escribir.
3455
+ * `escribirSiSigueSiendoNuestra` llama a este `alCambiar` SIN su propio `try`,
3456
+ * así que una excepción de aquí subiría hasta `aparcar`/`correr` y podría hacer
3457
+ * que una escritura que SÍ funcionó se cuente como fallida. Se atrapa aquí, en
3458
+ * el único sitio que sabe que lo que sigue es «avisar», no «guardar».
3459
+ *
3460
+ * El mensaje usa `error.message` y no `codigoDe` (la regla de `montarRutas` y de
3461
+ * `corredorDeTareas.ts`): esa función existe para tapar la ruta absoluta de un
3462
+ * fallo de FICHERO, y lo que puede fallar aquí es `emitirTareas()` — un recorrido
3463
+ * de sumideros SSE que ya se protege cada uno por su cuenta (`sumidero` en la
3464
+ * ruta `/eventos`), así que en producción esto no lanza nada con un disco detrás.
3465
+ * Lo único que llega hasta aquí es un sumidero de mentira que revienta a propósito.
3466
+ */
3467
+ alCambiar: () => {
3468
+ try {
3469
+ emitirCambioDeTareas?.();
3470
+ }
3471
+ catch (error) {
3472
+ opciones.informar(`no se pudo avisar del cambio en la cola de tareas (${error instanceof Error ? error.message : String(error)})`);
3473
+ }
3474
+ },
3475
+ informar: opciones.informar,
3476
+ });
3477
+ return {
3478
+ corredor,
3479
+ opcionesDeMontaje: {
3480
+ ...(disco === undefined ? {} : { colaDeTareas: disco }),
3481
+ ...(corredor === undefined ? {} : { corredorDeTareas: corredor }),
3482
+ concurrenciaDeTareas,
3483
+ // Persiste en `settings.json` — `guardarConcurrenciaDeTareas` acota a
3484
+ // `0..TOPE_DE_CONCURRENCIA_DE_TAREAS` por su cuenta, así que un valor fuera de rango
3485
+ // que llegara por el cable no deja basura en el fichero.
3486
+ guardarConcurrencia: (c) => {
3487
+ guardarConcurrenciaDeTareas(undefined, c);
3488
+ },
3489
+ },
3490
+ arrancarConectado: async (cable) => {
3491
+ emitirCambioDeTareas = cable.emitirTareas;
3492
+ await corredor?.arrancar();
3493
+ },
3494
+ };
3495
+ }
3496
+ /**
3497
+ * El cuerpo en BYTES, con su propio tope.
3498
+ *
3499
+ * No se reutiliza `leerCuerpo`: ese acota a `TOPE_DE_CUERPO` (1 MB) y devuelve `utf8`, o sea
3500
+ * las dos cosas equivocadas para un adjunto — un PNG de 3 MB se rechazaría, y pasarlo por
3501
+ * utf8 lo destrozaría. El tope se corta EN CUANTO se pasa y no al final: acumular 20 MB para
3502
+ * después decir que no caben sería pagar la memoria del rechazo.
3503
+ */
3504
+ async function leerCuerpoCrudo(peticion, tope) {
3505
+ const trozos = [];
3506
+ let total = 0;
3507
+ for await (const trozo of peticion) {
3508
+ const buffer = Buffer.from(trozo);
3509
+ total += buffer.length;
3510
+ if (total > tope)
3511
+ throw new Error("cuerpo demasiado grande");
3512
+ trozos.push(buffer);
3513
+ }
3514
+ return Buffer.concat(trozos);
3515
+ }
3516
+ async function leerCuerpo(peticion) {
3517
+ const trozos = [];
3518
+ let total = 0;
3519
+ for await (const trozo of peticion) {
3520
+ const buffer = Buffer.from(trozo);
3521
+ total += buffer.length;
3522
+ if (total > TOPE_DE_CUERPO)
3523
+ throw new Error("cuerpo demasiado grande");
3524
+ trozos.push(buffer);
3525
+ }
3526
+ return Buffer.concat(trozos).toString("utf8");
3527
+ }
3528
+ /**
3529
+ * Levanta la consola web y se queda. Devuelve el código de salida del proceso.
3530
+ *
3531
+ * El orden de las comprobaciones no es casual: primero lo que hace IMPOSIBLE arrancar
3532
+ * (falta el build del cliente → 70, fallo del entorno y no del proyecto), y después lo que
3533
+ * solo hay que DECIR (un proyecto offline en el cwd). Lo segundo informa y sigue, porque no
3534
+ * es un error: quien abrió aquí un proyecto offline puede querer la web para otro.
3535
+ *
3536
+ * **`--guion` sobre un proyecto offline lo abre solo, sin pasar por el alta.** Es la única
3537
+ * vía para ver la maqueta completa —barra con datos, transcript, compositor— sin
3538
+ * credenciales de CloudStudio: el alta de la web solo sabe de entornos y proyectos
3539
+ * REMOTOS (`vestibulo.ts`), así que un proyecto offline nunca llega a `proyectoAbierto()`
3540
+ * por ese camino, con o sin `--guion`. `vestibulo.abrirProyecto` (`vestibulo.ts#abrirDeVerdad`)
3541
+ * no toca red —es local, el mismo turno que corre `--cli`—, así que abrirlo aquí no es un
3542
+ * doble de nada: es la operación real, disparada por una bandera que ya existe y ya
3543
+ * significa «sin gastar ni conectar». Sin `--guion` esto NO se abre solo —sería magia, no
3544
+ * un modo declarado—, y el aviso de abajo sigue mandando a `--cli`.
3545
+ */
3546
+ /**
3547
+ * Cuántos nombres de fichero se mandan en el aviso de trabajo sin commitear. El alta se
3548
+ * reemite en los DOS flancos de cada turno, así que la lista viaja muchas veces; y la frase
3549
+ * que la pinta no puede llevar trescientos nombres de todas formas. Lo que no cabe se
3550
+ * cuenta: `total` va siempre entero.
3551
+ */
3552
+ export const FICHEROS_DEL_AVISO = 20;
3553
+ export async function arrancarConsolaWeb(opciones) {
3554
+ const escribir = opciones.escribir ?? ((texto) => void process.stdout.write(texto));
3555
+ const raizDelCliente = opciones.raizDelCliente ?? raizDelClientePorOmision();
3556
+ if (!existsSync(join(raizDelCliente, "index.html"))) {
3557
+ process.stderr.write(`${FALTA_EL_BUILD}\n`);
3558
+ return 70; // EX_SOFTWARE: falta una pieza del entorno, el proyecto no tiene la culpa
3559
+ }
3560
+ const offline = esProyectoOffline(opciones.cwd);
3561
+ if (offline && opciones.guion !== true) {
3562
+ escribir("este directorio es un proyecto offline: ábrelo con «xonecode --cli»\n");
3563
+ }
3564
+ const arrancar = opciones.crearServidor ?? arrancarServidor;
3565
+ const servidor = await arrancar({
3566
+ puerto: opciones.puerto,
3567
+ raizEstaticos: raizDelCliente,
3568
+ ...(opciones.anfitrion === undefined ? {} : { anfitrion: opciones.anfitrion }),
3569
+ });
3570
+ /**
3571
+ * El aviso que se ve por los DOS sitios, y es el mismo para el vestíbulo y para las rutas.
3572
+ *
3573
+ * El vestíbulo se captura perezosamente porque esta función se construye antes que él.
3574
+ * Medido antes de este arreglo: `montarRutas` recibía un `informar` que solo escribía en
3575
+ * el terminal, así que una URL rechazada o un `fetch failed` durante el alta salían por la
3576
+ * consola del proceso y NO llegaban al navegador — el `finally` re-anunciaba el alta, el
3577
+ * wizard repintaba el mismo paso, y el usuario no leía ni una palabra. En la piel que
3578
+ * ahora es la de omisión, y que vive en un navegador donde el terminal puede ni verse, eso
3579
+ * es un fallo mudo. Al revés también hace falta: en cuanto hay proyecto abierto, la consola
3580
+ * del vestíbulo ya no la mira nadie, y ahí caería «la consola del proyecto terminó con un
3581
+ * error».
3582
+ */
3583
+ let vestibulo;
3584
+ const informar = (texto) => {
3585
+ escribir(`${texto}\n`);
3586
+ vestibulo?.consola.consola.escribir(`${texto}\n`);
3587
+ };
3588
+ vestibulo = opciones.vestibulo ?? vestibuloReal(opciones, servidor, escribir, informar);
3589
+ const conVestibulo = vestibulo;
3590
+ /**
3591
+ * El corredor de tareas y su costura con el cable — `construirCorredorDeTareasCableado`
3592
+ * y no inline: es lo que la hace comprobable de verdad (ver su comentario). Se construye
3593
+ * ANTES de `montarRutas` porque las rutas necesitan poder pedirle una revisión —abrir o
3594
+ * cerrar un proyecto cambia qué se puede arrancar— y solo necesita el vestíbulo, que ya
3595
+ * está. Y ejecutar solo se ejecuta si hay cola: ver `OpcionesDeArranque.tareas`.
3596
+ */
3597
+ const { corredor, opcionesDeMontaje: opcionesDeTareas, arrancarConectado: arrancarCorredorConectado, } = construirCorredorDeTareasCableado({
3598
+ vestibulo: conVestibulo,
3599
+ tareasFabrica: opciones.tareas,
3600
+ informar,
3601
+ olvidarHiloDeSesion: async (raiz, hilo) => olvidarHilo(crearCheckpointerDeProyecto(raiz), hilo),
3602
+ /**
3603
+ * El juez de QA de las tareas, con el papel `afilado` — el que `core/modelos.ts` le
3604
+ * reserva.
3605
+ *
3606
+ * **Los `Modelos` se construyen en CADA consulta**, no una vez al arrancar, y es la
3607
+ * misma razón por la que `sinAprobacion` relee los settings en cada ronda: el asistente
3608
+ * de cuenta y `/provider` escriben la credencial y la elección de modelo mientras el
3609
+ * proceso vive, y un `Modelos` capturado al arrancar dejaría al juez con el reparto de
3610
+ * antes. Una consulta por tarea, así que leer el `config.json` ahí no cuesta nada.
3611
+ *
3612
+ * **Y se lee el del PROYECTO además del global, por la raíz de la tarea.** En la consola
3613
+ * web `FuentesDeEleccion.proyecto` no se rellena nunca —el vestíbulo sirve muchos
3614
+ * proyectos y las fuentes se construyen una vez al arrancar—, así que sin preguntarle al
3615
+ * disco por la raíz, un `config.json` de proyecto que apuntara el papel `afilado` a otro
3616
+ * modelo se ignoraba en silencio. Es la misma trampa que `cloudstudioDelProyecto` ya
3617
+ * resolvió así, y el mismo motivo: un ajuste escrito que no hace nada es peor que no
3618
+ * poder ponerlo, porque quien lo puso se cree servido.
3619
+ */
3620
+ juez: crearJuezDeTarea({
3621
+ invocar: (papel, prompt, raiz) => invocarConModelos(new Modelos(fuentesDelJuez(raiz), proveedoresPersonalizados))(papel, prompt, raiz),
3622
+ }),
3623
+ /**
3624
+ * Que lo escrito se pueda REVISAR es `cambiosDeSesion(...).via === "git"`: la MISMA
3625
+ * función que pinta la pestaña Revisión, no una parecida, así que la condición significa
3626
+ * literalmente «Revisión lo enseña». Sin aprobación previa ese diff es el único momento
3627
+ * en que una persona puede mirar lo que hizo una tarea, y entregar trabajo que nadie
3628
+ * puede ver sería dejar vacío el sitio del modal.
3629
+ *
3630
+ * Y NO es «el árbol de git está limpio»: eso está medido y sería falso siempre — una
3631
+ * tarea que escribe un fichero deja el árbol sucio por definición.
3632
+ *
3633
+ * De la MISMA respuesta sale si la sesión cambió algo, y solo se afirma cuando hay
3634
+ * marca: sin ella no es que no escribiera, es que no hay con qué mirarlo. Eso es lo que
3635
+ * permite entregar una tarea de solo lectura sin abrir un camino para entregar sin
3636
+ * verificar, y lo que impide decidirlo con `autorizadas` — que es la intención del
3637
+ * agente y no el hecho.
3638
+ */
3639
+ revisable: revisionConGit(cambiosDeSesion),
3640
+ });
3641
+ if (offline && opciones.guion === true) {
3642
+ const abierto = await vestibulo.abrirProyecto({ raiz: opciones.cwd });
3643
+ // Por los DOS sitios, como el resto de `informar` un poco más abajo: el terminal
3644
+ // (quien lanzó el proceso) Y el transcript del proyecto (la Trayectoria, que es
3645
+ // donde aterriza un acto de sistema — `anunciarAlta` más arriba documenta el mismo
3646
+ // reparto). Es la evidencia de que esto es de pega, por el mismo motivo por el que
3647
+ // la marca de doble (`core/ports.ts#ES_DOBLE`) es un símbolo que no se puede omitir
3648
+ // por descuido: un aviso que solo viviera en un sitio que nadie mira no avisa nada.
3649
+ const aviso = "proyecto offline abierto solo, por --guion: el agente que responde es de pega " +
3650
+ "(`ejecutarTurnoGuionizado`) y no hubo CloudStudio de por medio — nadie eligió " +
3651
+ "este proyecto en un alta.";
3652
+ escribir(`${aviso}\n`);
3653
+ abierto.consola.consola.escribir(`${aviso}\n`);
3654
+ }
3655
+ const cable = montarRutas(servidor, vestibulo, {
3656
+ informar,
3657
+ // El tope de ventana, tal cual llega: es la misma función que la barra del terminal, y
3658
+ // dos resoluciones serían dos porcentajes distintos para el mismo modelo.
3659
+ ...(opciones.topeDeContexto === undefined ? {} : { topeDeContexto: opciones.topeDeContexto }),
3660
+ ...(corredor === undefined ? {} : { revisarTareas: () => corredor.revisar() }),
3661
+ // El PUERTO de disco y el corredor, no operaciones sueltas: `montarRutas` resuelve
3662
+ // `crearTarea`/`accionDeTarea` con SU propio estado (`proyectos`, `entornoElegido`,
3663
+ // `vestibulo.raizDeProyecto`), que es la única forma de convertir un id de proyecto en
3664
+ // la raíz que la tarea necesita para poder correr. Ver el comentario de
3665
+ // `OpcionesDeMontaje.colaDeTareas`.
3666
+ ...opcionesDeTareas,
3667
+ // Los dos puertos del selector de modelos, con las piezas reales: quién tiene
3668
+ // credencial (`auth.json` o el entorno, leído desde el cwd) y el catálogo VIVO.
3669
+ hayCredencial: (proveedor) => hayCredencial(proveedor, opciones.cwd),
3670
+ // «Está en auth.json» es una pregunta distinta de «¿puedo usarlo?»: se lee el fichero,
3671
+ // sin mirar el entorno, porque es lo único que un botón de borrar puede cumplir.
3672
+ credencialEnFichero: (proveedor) => cargar(opciones.cwd).auth[proveedor] !== undefined,
3673
+ borrarCredencial,
3674
+ guardarCredencial,
3675
+ /**
3676
+ * El defecto se GUARDA en el `config.json` global (`agent/configEnDisco.ts#guardarModeloGlobal`),
3677
+ * que es el último escalón de la precedencia y el único que sobrevive al proceso. Se
3678
+ * escribe para los TRES papeles porque elegir un modelo en la interfaz es una frase
3679
+ * sobre el producto y no sobre un papel — el mismo criterio que `/modelo`, que fija los
3680
+ * tres en caliente.
3681
+ */
3682
+ guardarModeloGlobal: (papel, id) => guardarModeloGlobal(papel, id),
3683
+ /**
3684
+ * El que usarán las sesiones nuevas, resuelto contra el config GLOBAL (más el entorno,
3685
+ * que es de donde lo tomaría también una sesión).
3686
+ *
3687
+ * Viaja SIEMPRE, también con sesión abierta, porque son dos preguntas distintas: una
3688
+ * sesión puede llevar encima un `/modelo` en caliente —eso es `actual`— y el defecto
3689
+ * seguir siendo otro. Se relee en cada emisión y no se cachea: el fichero es del
3690
+ * usuario, se puede editar por fuera, y esta misma ventana lo acaba de reescribir.
3691
+ */
3692
+ modeloPorDefecto: () => {
3693
+ const { proveedor, modelo } = resolver({
3694
+ global: cargar(opciones.cwd).config.global,
3695
+ entorno: { XONECODE_MODELO: process.env.XONECODE_MODELO },
3696
+ }).trabajo;
3697
+ return `${proveedor}/${modelo}`;
3698
+ },
3699
+ cambiosDeSesion,
3700
+ parcheDeSesion,
3701
+ // El proyecto tal como lo ve el agente, para la pestaña Ficheros: mismo filtro, misma
3702
+ // barrera de rutas (`agent/arbolDeProyecto.ts`).
3703
+ arbolDelProyecto: async (raiz) => arbolDeProyecto(raiz),
3704
+ leerFichero: leerFicheroDeProyecto,
3705
+ leerArtefacto: leerArtefactoDeSesion,
3706
+ leerArtefactoCrudo,
3707
+ correrPasoDeReceta: (receta, paso, alSalirLinea) => correrPasoDeReceta(receta, paso, { alSalirLinea }),
3708
+ modelosDeMotor,
3709
+ // La máquina de verdad: adb/emulator del PATH o del SDK, xcrun solo en macOS y solo con
3710
+ // herramientas de desarrollo. Cada proceso con su tope.
3711
+ detectarDispositivos: () => detectarDispositivos({}, cargarSettings().settings.dispositivos ?? {}),
3712
+ // Se lee de disco en cada consulta, no se cachea: `settings.json` es del usuario.
3713
+ ajustesDeDispositivos: () => cargarSettings().settings.dispositivos ?? {},
3714
+ guardarAjustesDeDispositivos: (ajustes) => void guardarDispositivos(undefined, ajustes),
3715
+ instalarHerramienta: instalarHerramientaDeDispositivos,
3716
+ verificarDispositivo,
3717
+ /**
3718
+ * Las cuatro de «¿se puede lanzar y lánzalo?», compuestas con las funciones REALES.
3719
+ *
3720
+ * Es la trampa que este repo ha pagado nueve veces —`backendDeAgente`, el corredor sin
3721
+ * cablear, el `escribio` a fuego, la capa de proyecto del juez, `/adjuntos/`, `filaDeTarea`,
3722
+ * el prop de las pestañas por entorno, `opcionesDeSubagenteExterno` y
3723
+ * `ConsolaDeProyecto.consumo`—: una regla de producción que vive dentro de un cierre que
3724
+ * los tests doblan **no está probada, está escrita**. Aquí las cuatro van con las de
3725
+ * verdad y junto a `detectarDispositivos`, que es donde se lee qué es real y qué es un
3726
+ * doble en esta ejecución.
3727
+ */
3728
+ frameworkEnDispositivo: (dispositivo) => frameworkEnDispositivo(dispositivo),
3729
+ // La criba de balde primero (relativa, sin `.`/`..`/segmentos vacíos) y el disco después.
3730
+ // Con `join` sobre una ruta ya cribada: sin la criba, un `../../.env` de `app.xml`
3731
+ // preguntaría por un fichero de fuera del proyecto.
3732
+ existeEnProyecto: (raiz, rutaRelativa) => motivoDeRutaInaceptable(rutaRelativa) !== undefined || existsSync(join(raiz, rutaRelativa)),
3733
+ /**
3734
+ * La de verdad, y **sin construir sus dependencias aquí**: `lanzarEnDispositivo` trae su
3735
+ * propio `DependenciasDeLanzamiento` por omisión —los `spawn` reales, el empaquetado real,
3736
+ * el `hotswap` real— y lo único que se le pasa es `alFase`, que es el gancho por el que el
3737
+ * recorrido llega al cable. Montarlo entero desde aquí sería (a) una segunda lista de
3738
+ * dependencias que se queda vieja en cuanto el módulo añada una, y (b) el modo de fallo de
3739
+ * arriba: una composición de producción que solo existe en el cableado nadie la prueba.
3740
+ */
3741
+ lanzarEnDispositivo,
3742
+ catalogoDeModelos: async (proveedor) => {
3743
+ const modelos = await new CatalogoModelos(undefined, undefined, proveedoresPersonalizados).listar(proveedor);
3744
+ return modelos.map((m) => ({ id: m.id, ...(m.nombre === undefined ? {} : { nombre: m.nombre }) }));
3745
+ },
3746
+ // Se releen de disco en cada consulta, igual que los ajustes de dispositivos: esta
3747
+ // misma ventana los da de alta, y una lista capturada al montar las rutas se quedaría
3748
+ // vieja hasta reiniciar el proceso.
3749
+ proveedoresPersonalizados: proveedoresPersonalizados,
3750
+ guardarProveedor: (declarado) => guardarProveedorPersonalizado(declarado),
3751
+ borrarProveedor: (slug) => borrarProveedorPersonalizado(slug),
3752
+ /**
3753
+ * El AUMENTADOR de la ventana de crear una tarea (`agent/aumentador.ts`), con el papel
3754
+ * `trabajo` — es una tarea de redacción, no una clasificación.
3755
+ *
3756
+ * Tres cosas que se deciden aquí:
3757
+ * - **La composición vive en `augmentacionCableada` y no inline**, por lo mismo que
3758
+ * `revisionConGit` y `fuentesDelJuez`: es la quinta vez en esta tanda que hace falta
3759
+ * decirlo, y lo que se caería sin síntoma es la mitad del contexto del encargo.
3760
+ * - **Los `Modelos` se construyen en CADA llamada**, igual que los del juez: el
3761
+ * asistente de cuenta y `/provider` escriben la credencial mientras el proceso vive, y
3762
+ * uno capturado al arrancar se quedaría con el reparto de antes. Y con
3763
+ * `fuentesDelJuez(raiz)`, que es «las fuentes de ESE proyecto»: sin la capa de
3764
+ * proyecto, un `config.json` que apunte `trabajo` a otro modelo se ignoraría.
3765
+ * - **Con `--guion` se monta el DOBLE**, no el real: esa bandera significa «sin gastar
3766
+ * ni conectar», y el doble dice en el propio encargo que es de pega (`[DOBLE]`).
3767
+ */
3768
+ augmentar: augmentacionCableada({
3769
+ aumentador: opciones.guion === true
3770
+ ? new AumentadorGuionizado()
3771
+ : crearAumentador({
3772
+ invocar: (papel, prompt, raiz) => invocarParaAumentar(new Modelos(fuentesDelJuez(raiz), proveedoresPersonalizados))(papel, prompt, raiz),
3773
+ }),
3774
+ contexto: contextoDelProyecto,
3775
+ }),
3776
+ });
3777
+ // Con las rutas ya montadas: la reconciliación y el primer despacho cambian la cola, y
3778
+ // quien conecte después la recibe entera en su ráfaga de bienvenida.
3779
+ // `arrancarConectado` es la ÚNICA forma de arrancar el corredor: conecta el puente hacia
3780
+ // `cable.emitirTareas` y arranca en el orden correcto, así que no hay manera de llamarlo
3781
+ // mal desde aquí.
3782
+ await arrancarCorredorConectado(cable);
3783
+ /**
3784
+ * Con qué código corre ESTE proceso, antes que la URL.
3785
+ *
3786
+ * Existe por tres rondas perdidas: un cambio del servidor no se ve hasta parar y arrancar
3787
+ * el proceso, mientras que el cliente se lee del DISCO en cada petición, así que una
3788
+ * consola puede enseñar a la vez lo nuevo del cliente y lo viejo del servidor — y nadie
3789
+ * tenía forma de saber qué había vivo dentro. Va por opción para no atarlo a git en los
3790
+ * tests; ausente = no se dice nada, que es lo que hacía siempre.
3791
+ */
3792
+ const version = opciones.version?.();
3793
+ if (version !== undefined)
3794
+ escribir(`${lineaDeVersion(version)}\n`);
3795
+ escribir(`consola web en ${servidor.url}\n`);
3796
+ if (opciones.anfitrion !== undefined) {
3797
+ // Se dice SIEMPRE y con lo que hay detrás nombrado. Una puerta abierta que solo consta
3798
+ // en la línea de comandos que alguien tecleó hace media hora no consta.
3799
+ escribir(`ATENCIÓN: también se acepta «${opciones.anfitrion}» como Host, así que esta consola es alcanzable por ese túnel.\n` +
3800
+ `Detrás hay un agente que escribe ficheros en este equipo y las credenciales de ~/.xonecode. ` +
3801
+ `Lo único que lo separa de quien tenga la URL es el token.\n`);
3802
+ }
3803
+ if (opciones.abrir) {
3804
+ // Lo accesorio: la URL ya está impresa, así que un fallo aquí no puede tumbar nada.
3805
+ // `abrirEnSistema` escucha el `error` del spawn justo por esto.
3806
+ try {
3807
+ (opciones.abrirNavegador ?? abrirEnSistema)(new URL(servidor.url));
3808
+ }
3809
+ catch (error) {
3810
+ escribir(`no se pudo abrir el navegador (${error instanceof Error ? error.message : String(error)}); abre la URL a mano\n`);
3811
+ }
3812
+ }
3813
+ await (opciones.esperarCierre ?? esperarInterrupcion)();
3814
+ /**
3815
+ * El corredor PRIMERO, y el orden es load-bearing: `parar()` corta los turnos en vuelo y
3816
+ * los deja aparcados diciendo que la consola se cerró a mitad. Al revés, el
3817
+ * `cerrarLasDeTareas` del vestíbulo abortaría esos mismos turnos por debajo del corredor,
3818
+ * que lo contaría como un fallo del turno —«el turno falló (AbortError)»— cuando lo que
3819
+ * pasó es que alguien paró el proceso. El motivo se lee en el kanban, así que la
3820
+ * diferencia no es interna.
3821
+ */
3822
+ await corredor?.parar();
3823
+ await vestibulo.cerrar();
3824
+ await servidor.cerrar();
3825
+ return 0;
3826
+ }
3827
+ /**
3828
+ * El `modo` que declara el `.xonecode/config.json` de una raíz, o `undefined`.
3829
+ *
3830
+ * `undefined` es «no se sabe», no «offline»: cubre el fichero que no está, el JSON roto y
3831
+ * el valor que no es ninguno de los dos. Quien pregunta decide qué hacer con no saber —
3832
+ * `esProyectoOffline` lo trata como «no es offline» y la cabecera de la consola web no
3833
+ * pinta pastilla—, y ninguno de los dos afirma sobre lo que no ha leído.
3834
+ *
3835
+ * Del config no sale nada más: ni la URL del entorno, ni el proyecto, ni la rama. Es lo
3836
+ * mismo que ya hace `proyectosDeResultado` con la respuesta de CloudStudio (CLAUDE.md),
3837
+ * quedarse SOLO con lo que hace falta enseñar.
3838
+ */
3839
+ export function modoDeProyecto(raiz) {
3840
+ try {
3841
+ const crudo = JSON.parse(readFileSync(join(raiz, ".xonecode", "config.json"), "utf8"));
3842
+ if (typeof crudo !== "object" || crudo === null)
3843
+ return undefined;
3844
+ const modo = crudo.modo;
3845
+ return modo === "offline" || modo === "cloud" ? modo : undefined;
3846
+ }
3847
+ catch {
3848
+ return undefined;
3849
+ }
3850
+ }
3851
+ /** Un `.xonecode/config.json` con `modo: "offline"` en el cwd. Lo que no se pueda leer no
3852
+ * es un proyecto offline: no se afirma sobre lo que no se sabe. */
3853
+ function esProyectoOffline(cwd) {
3854
+ return modoDeProyecto(cwd) === "offline";
3855
+ }
3856
+ /**
3857
+ * La espera por omisión: hasta que alguien interrumpa. Sin ella `arrancarConsolaWeb`
3858
+ * devolvería en cuanto el servidor está en pie, y `bin.ts` hace `process.exit(codigo)` —
3859
+ * o sea que el servidor recién levantado moriría antes de servir una sola petición.
3860
+ */
3861
+ function esperarInterrupcion() {
3862
+ return new Promise((resolver) => {
3863
+ process.once("SIGINT", () => resolver());
3864
+ process.once("SIGTERM", () => resolver());
3865
+ });
3866
+ }
3867
+ /** El vestíbulo con todas sus piezas reales. Se construye DESPUÉS del servidor porque el
3868
+ * callback de OAuth necesita saber a qué URL devolver al navegador. */
3869
+ function vestibuloReal(opciones, servidor, escribir, informar) {
3870
+ // Lo PRIMERO, antes de leer nada de disco: si esto va a fallar, que falle sin haber
3871
+ // aplicado credenciales al proceso ni construido medio vestíbulo.
3872
+ const ejecutor = banderaDeEjecutor(opciones);
3873
+ const cargado = cargar(opciones.cwd);
3874
+ aplicarAuth(cargado.auth);
3875
+ // La lectura NO se hace aquí: se hace al abrir cada consola, que es lo que hace que una
3876
+ // sesión nueva vea el `config.json` de ahora y no el del arranque. Ver
3877
+ // `fuentesDeLaConsolaWeb`.
3878
+ const fuentes = () => fuentesDeLaConsolaWeb(opciones.cwd);
3879
+ const settings = cargarSettings().settings;
3880
+ const dependenciasDeProyecto = opciones.dependenciasDeProyecto;
3881
+ return crearVestibulo({
3882
+ informar,
3883
+ origenDeTrabajo: resolver(fuentes()).trabajo.origen,
3884
+ fuentes,
3885
+ // Se resuelve UNA vez, aquí, y no en cada `anunciarAlta`: `git config`/`os.userInfo`
3886
+ // no cambian a media conexión, y repetir el subproceso en cada anuncio del alta sería
3887
+ // gastar sin motivo. Nunca viaja hacia CloudStudio ni hacia ningún acto —
3888
+ // `agent/persona.ts` lo documenta—.
3889
+ nombre: nombreDePersona(opciones.cwd),
3890
+ catalogoModelos: new CatalogoModelos(),
3891
+ guardarCredencial,
3892
+ aplicarCredencial: aplicarCredencialAlProceso,
3893
+ // Los proveedores que YA tienen clave no se vuelven a pedir. Sin esto la omisión del
3894
+ // vestíbulo es `false` para todos —la dirección segura, pero molesta— y el asistente
3895
+ // pediría de nuevo una credencial que está escrita.
3896
+ hayCredencial: (proveedor) => hayCredencial(proveedor, opciones.cwd),
3897
+ guardarEntorno: (entorno) => guardarEntornoEnDisco(undefined, entorno),
3898
+ guardarModeloGlobal,
3899
+ guardarConfigDeProyecto: escribirProyectoEnDisco,
3900
+ // El «antes» de cada sesión: se fotografía al abrir el proyecto y se nombra cuando la
3901
+ // sesión tiene id. Ver `agent/sesionGit.ts` para por qué es una ref y no un tag.
3902
+ marcarSesion: fotoDeApertura,
3903
+ // Lo que ya había sin commitear al abrir, para poder decirlo. Comparte el hueco
3904
+ // declarado de `marcarSesion`: esta composición vive en un cierre que `vestibuloReal`
3905
+ // no expone y que ningún test construye —lee el `settings.json` REAL del usuario—, así
3906
+ // que lo que está probado es que el vestíbulo la usa por las dos puertas, no que aquí
3907
+ // siga puesta.
3908
+ sinCommitear: trabajoSinCommitear,
3909
+ // El commit del turno, con el sello de la sesión. La composición está EXTRAÍDA y probada
3910
+ // (`commitDeTurnoCableado`): el argumento que se cae en una lambda escrita a mano es
3911
+ // justo el que sostiene la atribución de Revisión.
3912
+ commitearTurno: commitDeTurnoCableado({ base: settings.workspace ?? baseDeWorkspacePorOmision() }),
3913
+ olvidarMarcaDeSesion: olvidarSesion,
3914
+ // La memoria del agente por hilo. `historica` deja de ser «se reabrió» para ser «no hay
3915
+ // checkpoint que cargar», y borrar una sesión se lleva también su checkpoint.
3916
+ hayMemoriaDeHilo: async (raiz, hilo) => hayCheckpoint(crearCheckpointerDeProyecto(raiz), hilo),
3917
+ olvidarMemoriaDeHilo: async (raiz, hilo) => olvidarHilo(crearCheckpointerDeProyecto(raiz), hilo),
3918
+ entornos: settings.entornos,
3919
+ ...(settings.workspace === undefined ? {} : { baseDeWorkspace: settings.workspace }),
3920
+ // La URL de la web para que la página del callback devuelva AQUÍ y no diga «vuelve a
3921
+ // la terminal», que en un navegador es falso.
3922
+ ...conexionDeVestibulo(servidor.url),
3923
+ // La descarga es la de siempre, la del `/sync bajar` del proyecto ya dado de alta:
3924
+ // `completarProyecto` escribe el `config.json` ENTERO antes de llamar aquí, así que el
3925
+ // sincronizador lee del disco exactamente lo que leería después. Nada de la
3926
+ // sincronización se toca ni se duplica.
3927
+ descargar: async ({ raiz }) => {
3928
+ const sincronizar = dependenciasDeProyecto?.(raiz).sincronizar;
3929
+ // Un `return` a secas aquí sería el no-op silencioso que este repo evita en todas
3930
+ // partes: el alta quedaría escrita y el usuario creyendo que su proyecto está bajado.
3931
+ if (sincronizar === undefined) {
3932
+ throw new Error("esta consola web se montó sin `dependenciasDeProyecto`: no hay con qué descargar");
3933
+ }
3934
+ const bajada = await sincronizar("bajar", raiz, undefined, escribir);
3935
+ if (bajada.tipo === "texto") {
3936
+ escribir(bajada.texto);
3937
+ return;
3938
+ }
3939
+ // El árbol sucio para la copia local. Bajar SOBRESCRIBE el disco, así que se dice
3940
+ // qué hay y no se baja nada — igual que en el alta de terminal.
3941
+ throw new Error(`hay trabajo local sin commitear (${bajada.pendientes.join(", ")}); la descarga sobrescribe el disco`);
3942
+ },
3943
+ // Sin `crearEjecutor` el vestíbulo cae en `ejecutarTurnoGuionizado` — el agente de
3944
+ // PEGA— y no habría forma de notarlo desde el navegador: los turnos correrían, las
3945
+ // fases se pintarían y nada de lo que dijera sería de un modelo. Se exige, igual que
3946
+ // `descargar` exige su sincronizador. `--guion` es la única forma de pedir el de pega,
3947
+ // y ahí se pide a propósito.
3948
+ ...ejecutor,
3949
+ ...(dependenciasDeProyecto === undefined ? {} : { dependenciasDeProyecto }),
3950
+ });
3951
+ }
3952
+ /**
3953
+ * O el ejecutor real, o el de pega PEDIDO con `--guion`, o un error. Lo que no hay es una
3954
+ * tercera opción muda.
3955
+ */
3956
+ function banderaDeEjecutor(opciones) {
3957
+ if (opciones.guion === true)
3958
+ return {};
3959
+ if (opciones.crearEjecutor === undefined) {
3960
+ throw new Error("esta consola web se montó sin `crearEjecutor`: correría el agente de pega sin decirlo (usa --guion si es lo que quieres)");
3961
+ }
3962
+ return { crearEjecutor: opciones.crearEjecutor };
3963
+ }
3964
+ //# sourceMappingURL=arranque.js.map