agents-city 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (449) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.es.md +2226 -0
  5. package/README.md +2196 -0
  6. package/benchmarks/committee/README.md +39 -0
  7. package/benchmarks/committee/metrics.py +51 -0
  8. package/benchmarks/committee/run.py +64 -0
  9. package/benchmarks/committee/traces/01-single.json +8 -0
  10. package/benchmarks/committee/traces/02-mesh.json +16 -0
  11. package/benchmarks/committee/traces/03-chair.json +15 -0
  12. package/benchmarks/latency/README.md +35 -0
  13. package/benchmarks/latency/fake-claude-cli.mjs +119 -0
  14. package/benchmarks/latency/fake-native-server.mjs +340 -0
  15. package/benchmarks/latency/fake-tui.py +91 -0
  16. package/benchmarks/latency/live.py +380 -0
  17. package/benchmarks/stress/README.md +20 -0
  18. package/benchmarks/stress/run.py +487 -0
  19. package/bin/agents +4 -0
  20. package/bin/agents-city.js +119 -0
  21. package/bin/benchmark +17 -0
  22. package/bin/bus +6 -0
  23. package/bin/cities +3 -0
  24. package/bin/city +86 -0
  25. package/bin/committee +6 -0
  26. package/bin/demo +200 -0
  27. package/bin/doctor +3 -0
  28. package/bin/exit +5 -0
  29. package/bin/hall +73 -0
  30. package/bin/hall.html +498 -0
  31. package/bin/logs +3 -0
  32. package/bin/navegador.mjs +243 -0
  33. package/bin/report.py +14 -0
  34. package/bin/reset +3 -0
  35. package/bin/road +3 -0
  36. package/bin/seat +10 -0
  37. package/bin/serve.py +1579 -0
  38. package/bin/setup.py +753 -0
  39. package/bin/shortcut +3 -0
  40. package/bin/skills +3 -0
  41. package/bin/test +133 -0
  42. package/bin/test-actualiza.py +130 -0
  43. package/bin/test-adapter.py +183 -0
  44. package/bin/test-admision.py +99 -0
  45. package/bin/test-atajos.py +301 -0
  46. package/bin/test-avatar.py +58 -0
  47. package/bin/test-benchmark.py +40 -0
  48. package/bin/test-broker.py +334 -0
  49. package/bin/test-cage.py +374 -0
  50. package/bin/test-card.py +402 -0
  51. package/bin/test-channel.py +379 -0
  52. package/bin/test-cities.py +453 -0
  53. package/bin/test-claude-runtime.py +252 -0
  54. package/bin/test-committee.py +340 -0
  55. package/bin/test-contracts.py +551 -0
  56. package/bin/test-crecimiento.py +164 -0
  57. package/bin/test-demo.py +308 -0
  58. package/bin/test-doctor.py +139 -0
  59. package/bin/test-domains.py +151 -0
  60. package/bin/test-evidencia.py +50 -0
  61. package/bin/test-exit.py +128 -0
  62. package/bin/test-hall-protocol.py +93 -0
  63. package/bin/test-launch.py +169 -0
  64. package/bin/test-live-feed.py +508 -0
  65. package/bin/test-navegador.py +140 -0
  66. package/bin/test-pairing.py +99 -0
  67. package/bin/test-parcels.py +283 -0
  68. package/bin/test-runtime-failures.py +252 -0
  69. package/bin/test-runtime-ui.py +488 -0
  70. package/bin/test-runtime.py +268 -0
  71. package/bin/test-rutas.py +80 -0
  72. package/bin/test-seat.py +1598 -0
  73. package/bin/test-security.py +150 -0
  74. package/bin/test-serve.py +1251 -0
  75. package/bin/test-stress.py +64 -0
  76. package/bin/test-widgets.py +143 -0
  77. package/bin/test-workspace.py +251 -0
  78. package/bin/testlib.py +118 -0
  79. package/bin/tokens.py +14 -0
  80. package/bin/update +3 -0
  81. package/bus/scripts/deploy.sh +60 -0
  82. package/bus/scripts/mint-token.sh +68 -0
  83. package/bus/scripts/setup-dev.sh +79 -0
  84. package/bus/scripts/test-channel.ts +8 -0
  85. package/bus/scripts/test-hub.ts +266 -0
  86. package/bus/scripts/test-local.sh +40 -0
  87. package/bus/scripts/test-queue.ts +132 -0
  88. package/bus/worker/package-lock.json +1971 -0
  89. package/bus/worker/package.json +17 -0
  90. package/bus/worker/src/index.ts +485 -0
  91. package/bus/worker/tsconfig.json +13 -0
  92. package/bus/worker/wrangler.toml +32 -0
  93. package/city/oven/README.md +59 -0
  94. package/city/oven/collect.py +43 -0
  95. package/city/oven/oven.html +4 -0
  96. package/city/scripts/history.py +120 -0
  97. package/city/scripts/seed.py +442 -0
  98. package/city/web/assets/modelos/License.txt +28 -0
  99. package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
  100. package/city/web/assets/modelos/building-a.glb +0 -0
  101. package/city/web/assets/modelos/building-b.glb +0 -0
  102. package/city/web/assets/modelos/building-c.glb +0 -0
  103. package/city/web/assets/modelos/building-d.glb +0 -0
  104. package/city/web/assets/modelos/building-e.glb +0 -0
  105. package/city/web/assets/modelos/building-f.glb +0 -0
  106. package/city/web/assets/modelos/building-g.glb +0 -0
  107. package/city/web/assets/modelos/building-h.glb +0 -0
  108. package/city/web/assets/sprites/building-a.png +0 -0
  109. package/city/web/assets/sprites/building-b.png +0 -0
  110. package/city/web/assets/sprites/building-c.png +0 -0
  111. package/city/web/assets/sprites/building-d.png +0 -0
  112. package/city/web/assets/sprites/building-e.png +0 -0
  113. package/city/web/assets/sprites/building-f.png +0 -0
  114. package/city/web/assets/sprites/building-g.png +0 -0
  115. package/city/web/assets/sprites/building-h.png +0 -0
  116. package/city/web/assets/sprites/building-i.png +0 -0
  117. package/city/web/assets/sprites/building-j.png +0 -0
  118. package/city/web/assets/sprites/building-k.png +0 -0
  119. package/city/web/assets/sprites/building-l.png +0 -0
  120. package/city/web/assets/sprites/building-m.png +0 -0
  121. package/city/web/assets/sprites/building-n.png +0 -0
  122. package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
  123. package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
  124. package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
  125. package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
  126. package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
  127. package/city/web/assets/sprites/catalogo.json +47 -0
  128. package/city/web/assets/sprites/detail-awning-wide.png +0 -0
  129. package/city/web/assets/sprites/detail-awning.png +0 -0
  130. package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
  131. package/city/web/assets/sprites/detail-overhang.png +0 -0
  132. package/city/web/assets/sprites/detail-parasol-a.png +0 -0
  133. package/city/web/assets/sprites/detail-parasol-b.png +0 -0
  134. package/city/web/assets/sprites/low-detail-building-a.png +0 -0
  135. package/city/web/assets/sprites/low-detail-building-b.png +0 -0
  136. package/city/web/assets/sprites/low-detail-building-c.png +0 -0
  137. package/city/web/assets/sprites/low-detail-building-d.png +0 -0
  138. package/city/web/assets/sprites/low-detail-building-e.png +0 -0
  139. package/city/web/assets/sprites/low-detail-building-f.png +0 -0
  140. package/city/web/assets/sprites/low-detail-building-g.png +0 -0
  141. package/city/web/assets/sprites/low-detail-building-h.png +0 -0
  142. package/city/web/assets/sprites/low-detail-building-i.png +0 -0
  143. package/city/web/assets/sprites/low-detail-building-j.png +0 -0
  144. package/city/web/assets/sprites/low-detail-building-k.png +0 -0
  145. package/city/web/assets/sprites/low-detail-building-l.png +0 -0
  146. package/city/web/assets/sprites/low-detail-building-m.png +0 -0
  147. package/city/web/assets/sprites/low-detail-building-n.png +0 -0
  148. package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
  149. package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
  150. package/city/web/assets/sprites/medidas.json +248 -0
  151. package/city/web/dist/city.js +939 -0
  152. package/city/web/dist/index.html +294 -0
  153. package/city/web/dist/modelos/License.txt +28 -0
  154. package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
  155. package/city/web/dist/modelos/building-a.glb +0 -0
  156. package/city/web/dist/modelos/building-b.glb +0 -0
  157. package/city/web/dist/modelos/building-c.glb +0 -0
  158. package/city/web/dist/modelos/building-d.glb +0 -0
  159. package/city/web/dist/modelos/building-e.glb +0 -0
  160. package/city/web/dist/modelos/building-f.glb +0 -0
  161. package/city/web/dist/modelos/building-g.glb +0 -0
  162. package/city/web/dist/modelos/building-h.glb +0 -0
  163. package/city/web/dist/sprites/building-a.png +0 -0
  164. package/city/web/dist/sprites/building-b.png +0 -0
  165. package/city/web/dist/sprites/building-c.png +0 -0
  166. package/city/web/dist/sprites/building-d.png +0 -0
  167. package/city/web/dist/sprites/building-e.png +0 -0
  168. package/city/web/dist/sprites/building-f.png +0 -0
  169. package/city/web/dist/sprites/building-g.png +0 -0
  170. package/city/web/dist/sprites/building-h.png +0 -0
  171. package/city/web/dist/sprites/building-i.png +0 -0
  172. package/city/web/dist/sprites/building-j.png +0 -0
  173. package/city/web/dist/sprites/building-k.png +0 -0
  174. package/city/web/dist/sprites/building-l.png +0 -0
  175. package/city/web/dist/sprites/building-m.png +0 -0
  176. package/city/web/dist/sprites/building-n.png +0 -0
  177. package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
  178. package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
  179. package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
  180. package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
  181. package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
  182. package/city/web/dist/sprites/catalogo.json +47 -0
  183. package/city/web/dist/sprites/detail-awning-wide.png +0 -0
  184. package/city/web/dist/sprites/detail-awning.png +0 -0
  185. package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
  186. package/city/web/dist/sprites/detail-overhang.png +0 -0
  187. package/city/web/dist/sprites/detail-parasol-a.png +0 -0
  188. package/city/web/dist/sprites/detail-parasol-b.png +0 -0
  189. package/city/web/dist/sprites/low-detail-building-a.png +0 -0
  190. package/city/web/dist/sprites/low-detail-building-b.png +0 -0
  191. package/city/web/dist/sprites/low-detail-building-c.png +0 -0
  192. package/city/web/dist/sprites/low-detail-building-d.png +0 -0
  193. package/city/web/dist/sprites/low-detail-building-e.png +0 -0
  194. package/city/web/dist/sprites/low-detail-building-f.png +0 -0
  195. package/city/web/dist/sprites/low-detail-building-g.png +0 -0
  196. package/city/web/dist/sprites/low-detail-building-h.png +0 -0
  197. package/city/web/dist/sprites/low-detail-building-i.png +0 -0
  198. package/city/web/dist/sprites/low-detail-building-j.png +0 -0
  199. package/city/web/dist/sprites/low-detail-building-k.png +0 -0
  200. package/city/web/dist/sprites/low-detail-building-l.png +0 -0
  201. package/city/web/dist/sprites/low-detail-building-m.png +0 -0
  202. package/city/web/dist/sprites/low-detail-building-n.png +0 -0
  203. package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
  204. package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
  205. package/city/web/dist/sprites/medidas.json +248 -0
  206. package/city/web/dist-hall/hall.js +1363 -0
  207. package/city/web/index.html +294 -0
  208. package/city/web/package.json +18 -0
  209. package/city/web/sella.py +21 -0
  210. package/city/web/src/activity-actors.ts +47 -0
  211. package/city/web/src/activity.ts +172 -0
  212. package/city/web/src/ayuntamiento.ts +580 -0
  213. package/city/web/src/draw.ts +640 -0
  214. package/city/web/src/game-speech.ts +79 -0
  215. package/city/web/src/hall.ts +1959 -0
  216. package/city/web/src/main.ts +2252 -0
  217. package/city/web/src/oven.ts +124 -0
  218. package/city/web/src/people.ts +244 -0
  219. package/city/web/src/presencia.ts +193 -0
  220. package/city/web/src/puertas.ts +180 -0
  221. package/city/web/src/repo-roles.ts +59 -0
  222. package/city/worker/package.json +16 -0
  223. package/city/worker/schema.sql +117 -0
  224. package/city/worker/src/index.ts +702 -0
  225. package/city/worker/src/square.ts +218 -0
  226. package/city/worker/wrangler.toml +58 -0
  227. package/demo/ada.md +50 -0
  228. package/demo/bruno.md +62 -0
  229. package/demo/camila.md +56 -0
  230. package/demo/city/ada.md +40 -0
  231. package/demo/city/city.yml +8 -0
  232. package/demo/city/parcels.yml +15 -0
  233. package/demo/city/roads.json +4 -0
  234. package/demo/city/units.yml +10 -0
  235. package/demo/clinica/city.yml +8 -0
  236. package/demo/clinica/parcels.yml +15 -0
  237. package/demo/clinica/roads.json +4 -0
  238. package/demo/clinica/units.yml +9 -0
  239. package/demo/clinica/vera.md +46 -0
  240. package/demo/dante.md +54 -0
  241. package/demo/despacho/city.yml +8 -0
  242. package/demo/despacho/marta.md +46 -0
  243. package/demo/despacho/parcels.yml +15 -0
  244. package/demo/despacho/roads.json +4 -0
  245. package/demo/despacho/units.yml +9 -0
  246. package/demo/elsa.md +50 -0
  247. package/demo/farid.md +52 -0
  248. package/demo/greta.md +50 -0
  249. package/demo/hugo.md +49 -0
  250. package/demo/iris.md +49 -0
  251. package/demo/jonas.md +51 -0
  252. package/demo/kira.md +51 -0
  253. package/demo/luca.md +49 -0
  254. package/demo/parcels.yml +92 -0
  255. package/demo/seed.py +20 -0
  256. package/demo/show.py +161 -0
  257. package/demo/stories.py +824 -0
  258. package/demo/units.yml +33 -0
  259. package/docs/agents-first.md +106 -0
  260. package/docs/glossary.md +55 -0
  261. package/docs/map-live-layers.md +73 -0
  262. package/docs/security.md +265 -0
  263. package/docs/self-host.md +151 -0
  264. package/docs/testing.md +205 -0
  265. package/package.json +73 -0
  266. package/plugin/.claude-plugin/plugin.json +62 -0
  267. package/plugin/.mcp.json +15 -0
  268. package/plugin/channel/activity-cli.ts +16 -0
  269. package/plugin/channel/adapter-prompts.ts +113 -0
  270. package/plugin/channel/adapter.js +4292 -0
  271. package/plugin/channel/adapter.ts +133 -0
  272. package/plugin/channel/bus.js +20188 -0
  273. package/plugin/channel/bus.ts +131 -0
  274. package/plugin/channel/city-config.ts +125 -0
  275. package/plugin/channel/claude-channel.ts +58 -0
  276. package/plugin/channel/cli-args.ts +75 -0
  277. package/plugin/channel/client.js +4200 -0
  278. package/plugin/channel/client.ts +38 -0
  279. package/plugin/channel/committee/activity.ts +256 -0
  280. package/plugin/channel/committee/collection.ts +154 -0
  281. package/plugin/channel/committee/decision.ts +175 -0
  282. package/plugin/channel/committee/floor.ts +135 -0
  283. package/plugin/channel/committee/guards.ts +30 -0
  284. package/plugin/channel/committee/history.ts +38 -0
  285. package/plugin/channel/committee/render.ts +102 -0
  286. package/plugin/channel/committee/service.ts +72 -0
  287. package/plugin/channel/committee/storage.ts +92 -0
  288. package/plugin/channel/committee/types.ts +163 -0
  289. package/plugin/channel/committee/view.ts +63 -0
  290. package/plugin/channel/committee-cli.ts +109 -0
  291. package/plugin/channel/delivery-metrics.ts +61 -0
  292. package/plugin/channel/delivery-queue.ts +118 -0
  293. package/plugin/channel/hub/activity-controller.ts +72 -0
  294. package/plugin/channel/hub/activity-feed.ts +169 -0
  295. package/plugin/channel/hub/committee-controller.ts +60 -0
  296. package/plugin/channel/hub/connections.ts +48 -0
  297. package/plugin/channel/hub/diagnostics.ts +67 -0
  298. package/plugin/channel/hub/envelope-validity.ts +109 -0
  299. package/plugin/channel/hub/envelopes.ts +66 -0
  300. package/plugin/channel/hub/lifecycle.ts +91 -0
  301. package/plugin/channel/hub/local-roads.ts +89 -0
  302. package/plugin/channel/hub/remote-roads.ts +165 -0
  303. package/plugin/channel/hub/road-controller.ts +99 -0
  304. package/plugin/channel/hub-client.ts +149 -0
  305. package/plugin/channel/local-hub.js +5995 -0
  306. package/plugin/channel/local-hub.ts +349 -0
  307. package/plugin/channel/map-reporter.ts +48 -0
  308. package/plugin/channel/package-lock.json +1731 -0
  309. package/plugin/channel/package.json +19 -0
  310. package/plugin/channel/protocol.ts +98 -0
  311. package/plugin/channel/road-cli.ts +18 -0
  312. package/plugin/channel/run.sh +15 -0
  313. package/plugin/channel/runtime/claude.ts +458 -0
  314. package/plugin/channel/runtime/codex-config.ts +77 -0
  315. package/plugin/channel/runtime/codex.ts +669 -0
  316. package/plugin/channel/runtime/command.ts +62 -0
  317. package/plugin/channel/runtime/factory.ts +15 -0
  318. package/plugin/channel/runtime/json-rpc.ts +154 -0
  319. package/plugin/channel/runtime/kimi.ts +277 -0
  320. package/plugin/channel/runtime/opencode.ts +248 -0
  321. package/plugin/channel/runtime/process.ts +84 -0
  322. package/plugin/channel/runtime/types.ts +45 -0
  323. package/plugin/channel/runtime-files.ts +73 -0
  324. package/plugin/channel/runtime-gateway.js +6396 -0
  325. package/plugin/channel/runtime-gateway.ts +358 -0
  326. package/plugin/channel/runtime-metrics.ts +52 -0
  327. package/plugin/channel/runtime-subscription.ts +180 -0
  328. package/plugin/channel/terminal-delivery.ts +131 -0
  329. package/plugin/channel/untrusted.ts +60 -0
  330. package/plugin/commands/committee.md +31 -0
  331. package/plugin/commands/exit.md +32 -0
  332. package/plugin/commands/goals.md +20 -0
  333. package/plugin/commands/join.md +18 -0
  334. package/plugin/commands/notice.md +20 -0
  335. package/plugin/commands/propose.md +19 -0
  336. package/plugin/commands/round.md +17 -0
  337. package/plugin/commands/session.md +22 -0
  338. package/plugin/commands/settings.md +25 -0
  339. package/plugin/commands/setup.md +21 -0
  340. package/plugin/commands/team.md +23 -0
  341. package/plugin/domains/custom.md +27 -0
  342. package/plugin/domains/finance.md +31 -0
  343. package/plugin/domains/healthcare.md +34 -0
  344. package/plugin/domains/legal.md +31 -0
  345. package/plugin/domains/marketing.md +33 -0
  346. package/plugin/domains/operations.md +31 -0
  347. package/plugin/domains/research.md +32 -0
  348. package/plugin/domains/sales.md +30 -0
  349. package/plugin/domains/software.md +37 -0
  350. package/plugin/hooks/activity.sh +6 -0
  351. package/plugin/hooks/digging.sh +68 -0
  352. package/plugin/hooks/growth.sh +42 -0
  353. package/plugin/hooks/hooks.json +106 -0
  354. package/plugin/hooks/notice-on-pr.sh +35 -0
  355. package/plugin/hooks/notice-on-stop.sh +80 -0
  356. package/plugin/hooks/notice-pending.sh +58 -0
  357. package/plugin/hooks/solo-en-ciudad.sh +23 -0
  358. package/plugin/hooks/tokens.sh +47 -0
  359. package/plugin/roles/examples/account-executive.md +25 -0
  360. package/plugin/roles/examples/ai-manager.md +34 -0
  361. package/plugin/roles/examples/associate.md +24 -0
  362. package/plugin/roles/examples/brand-lead.md +20 -0
  363. package/plugin/roles/examples/cfo.md +20 -0
  364. package/plugin/roles/examples/city-lead.md +25 -0
  365. package/plugin/roles/examples/clinical-director.md +26 -0
  366. package/plugin/roles/examples/clinical-ops.md +25 -0
  367. package/plugin/roles/examples/clinician.md +25 -0
  368. package/plugin/roles/examples/compliance.md +27 -0
  369. package/plugin/roles/examples/content.md +24 -0
  370. package/plugin/roles/examples/controller.md +27 -0
  371. package/plugin/roles/examples/cpto.md +41 -0
  372. package/plugin/roles/examples/customer-success.md +25 -0
  373. package/plugin/roles/examples/data-engineer.md +37 -0
  374. package/plugin/roles/examples/data.md +60 -0
  375. package/plugin/roles/examples/dev.md +37 -0
  376. package/plugin/roles/examples/devops.md +39 -0
  377. package/plugin/roles/examples/enablement.md +24 -0
  378. package/plugin/roles/examples/ethics.md +24 -0
  379. package/plugin/roles/examples/fin-analytics.md +27 -0
  380. package/plugin/roles/examples/health-compliance.md +25 -0
  381. package/plugin/roles/examples/health-data.md +26 -0
  382. package/plugin/roles/examples/knowledge.md +25 -0
  383. package/plugin/roles/examples/lifecycle.md +26 -0
  384. package/plugin/roles/examples/llm-engineer.md +34 -0
  385. package/plugin/roles/examples/managing-partner.md +20 -0
  386. package/plugin/roles/examples/methods.md +23 -0
  387. package/plugin/roles/examples/operations-lead.md +23 -0
  388. package/plugin/roles/examples/ops.md +24 -0
  389. package/plugin/roles/examples/patient-safety.md +26 -0
  390. package/plugin/roles/examples/performance.md +27 -0
  391. package/plugin/roles/examples/po.md +36 -0
  392. package/plugin/roles/examples/process-owner.md +23 -0
  393. package/plugin/roles/examples/product-design.md +39 -0
  394. package/plugin/roles/examples/program-manager.md +24 -0
  395. package/plugin/roles/examples/quality.md +25 -0
  396. package/plugin/roles/examples/research-director.md +24 -0
  397. package/plugin/roles/examples/research-ops.md +25 -0
  398. package/plugin/roles/examples/researcher.md +25 -0
  399. package/plugin/roles/examples/revenue-lead.md +25 -0
  400. package/plugin/roles/examples/revops.md +26 -0
  401. package/plugin/roles/examples/seo.md +27 -0
  402. package/plugin/roles/examples/specialist.md +24 -0
  403. package/plugin/scripts/actualiza.py +198 -0
  404. package/plugin/scripts/admision.py +165 -0
  405. package/plugin/scripts/apaga.py +232 -0
  406. package/plugin/scripts/atajos.py +506 -0
  407. package/plugin/scripts/avatar.py +211 -0
  408. package/plugin/scripts/broker.py +531 -0
  409. package/plugin/scripts/cage.py +492 -0
  410. package/plugin/scripts/capabilities.py +210 -0
  411. package/plugin/scripts/card.py +322 -0
  412. package/plugin/scripts/cities.py +520 -0
  413. package/plugin/scripts/city-env.sh +90 -0
  414. package/plugin/scripts/city-runtime.sh +87 -0
  415. package/plugin/scripts/city-session.sh +552 -0
  416. package/plugin/scripts/city_env.py +67 -0
  417. package/plugin/scripts/crecimiento.py +140 -0
  418. package/plugin/scripts/deliberations.py +48 -0
  419. package/plugin/scripts/doctor.py +294 -0
  420. package/plugin/scripts/domains.py +201 -0
  421. package/plugin/scripts/evidencia.py +64 -0
  422. package/plugin/scripts/find-repos.sh +111 -0
  423. package/plugin/scripts/gh.py +159 -0
  424. package/plugin/scripts/hall_protocol.py +147 -0
  425. package/plugin/scripts/hook_activity.py +139 -0
  426. package/plugin/scripts/launch.py +94 -0
  427. package/plugin/scripts/logs.py +98 -0
  428. package/plugin/scripts/pairing.py +185 -0
  429. package/plugin/scripts/parcels.py +132 -0
  430. package/plugin/scripts/read-card.py +55 -0
  431. package/plugin/scripts/report.py +201 -0
  432. package/plugin/scripts/reset.py +180 -0
  433. package/plugin/scripts/roads.py +230 -0
  434. package/plugin/scripts/roles.py +274 -0
  435. package/plugin/scripts/runtime_log.py +69 -0
  436. package/plugin/scripts/runtime_processes.py +132 -0
  437. package/plugin/scripts/rutas.py +83 -0
  438. package/plugin/scripts/seat.py +1696 -0
  439. package/plugin/scripts/tokens.py +194 -0
  440. package/plugin/scripts/trust-repos.py +59 -0
  441. package/plugin/scripts/ui.py +280 -0
  442. package/plugin/scripts/units.py +80 -0
  443. package/plugin/scripts/workspace.py +561 -0
  444. package/plugin/skills/city/SKILL.md +191 -0
  445. package/templates/blank.md +39 -0
  446. package/templates/finance.md +44 -0
  447. package/templates/legal.md +45 -0
  448. package/templates/marketing.md +48 -0
  449. package/templates/product.md +45 -0
package/bin/serve.py ADDED
@@ -0,0 +1,1579 @@
1
+ #!/usr/bin/env python3
2
+ """The local Hall server for one selected personal city.
3
+
4
+ Why a local server and not the Worker that serves the map: the Hall writes files
5
+ on this machine. A Worker cannot touch your disk, and it should not be able to.
6
+ So the page is served from here, by Python, next to the files it writes.
7
+
8
+ The Hall uses the same city, card, parcel, road and capability modules as the CLI.
9
+ The retired v1 multiple-person wizard is deliberately not served.
10
+
11
+ ./bin/hall open it
12
+ ./bin/hall --no-browser print the URL instead
13
+
14
+ It binds to 127.0.0.1 on a port the OS picks, and every request carries a token
15
+ minted for this run. Both matter: this process writes to your disk, and without
16
+ the token any page you happen to have open could POST to it.
17
+ """
18
+
19
+ import http.server
20
+ import json
21
+ import os
22
+ import re
23
+ import secrets
24
+ import shutil
25
+ import signal
26
+ import socketserver
27
+ import subprocess
28
+ import sys
29
+ import threading
30
+ import urllib.parse
31
+ import webbrowser
32
+
33
+ AQUI = os.path.dirname(os.path.abspath(__file__))
34
+ sys.path.insert(0, AQUI)
35
+ import setup as W # noqa: E402 legacy template catalogue used by the Hall
36
+
37
+ sys.path.insert(0, os.path.join(os.path.dirname(AQUI), "plugin", "scripts"))
38
+ import card # noqa: E402
39
+ import domains # noqa: E402
40
+ import gh # noqa: E402
41
+ import parcels # noqa: E402
42
+ import units # noqa: E402
43
+ import cities # noqa: E402
44
+ import roads # noqa: E402
45
+ import capabilities # noqa: E402
46
+ import deliberations # noqa: E402
47
+ import avatar # noqa: E402
48
+ import workspace # noqa: E402
49
+ import crecimiento # noqa: E402
50
+ import runtime_processes # noqa: E402
51
+ import actualiza # noqa: E402
52
+ import importlib.machinery as _mach
53
+ import importlib.util as _iu # noqa: E402
54
+
55
+ _s = _iu.spec_from_loader(
56
+ "seat",
57
+ _mach.SourceFileLoader(
58
+ "seat", os.path.join(os.path.dirname(AQUI), "plugin", "scripts", "seat.py")
59
+ ),
60
+ )
61
+ seat = _iu.module_from_spec(_s)
62
+ _s.loader.exec_module(seat)
63
+
64
+ FICHA = "PASE" # the token's name, in one place
65
+ PASE = secrets.token_urlsafe(24)
66
+
67
+ # The demo cities' guided committees, when this Hall is serving one of them.
68
+ # One process at most; /api/demo is its remote control and refuses real cities.
69
+ # Every packaged demo city carries the prefix; nothing real ever does.
70
+ DEMO_ID_PREFIX = "city_demo_"
71
+ DEMO_SHOW = {"proc": None, "pausado": False}
72
+
73
+ try:
74
+ sys.path.insert(0, os.path.join(os.path.dirname(AQUI), "demo"))
75
+ from stories import STORIES as DEMO_STORIES # noqa: E402
76
+ except ImportError: # an install without the demo still serves every city
77
+ DEMO_STORIES = {}
78
+
79
+
80
+ def es_demo(datos):
81
+ return cities.identidad(datos).startswith(DEMO_ID_PREFIX)
82
+
83
+
84
+ # Growth walks the agent's workspace and every mount, and /api/estado is hit on
85
+ # every page load and after every sheet edit. Ninety seconds of memory keeps the
86
+ # sheet honest enough while sparing the walk on each click.
87
+ _CRECIDO = {}
88
+ _CRECIENDO = threading.Lock()
89
+
90
+
91
+ def _historia_git(ruta, desde):
92
+ """(merges, plain commits, commits since `desde`) in ONE walk of a repo.
93
+
94
+ Three `rev-list --count` calls asked git to walk the same history three
95
+ times, three processes deep; one `log` carrying each commit's timestamp and
96
+ parents answers all three questions from the same pass. On a 57k-commit repo
97
+ that is 0.61s instead of 0.83s, and one process instead of three.
98
+ """
99
+ salida = subprocess.run(
100
+ ["git", "-C", ruta, "log", "--format=%ct %p", "HEAD"],
101
+ capture_output=True,
102
+ text=True,
103
+ timeout=30,
104
+ )
105
+ if salida.returncode != 0:
106
+ return 0, 0, 0
107
+ fusiones = sueltos = recientes = 0
108
+ for linea in salida.stdout.splitlines():
109
+ cuando, _, padres = linea.partition(" ")
110
+ if len(padres.split()) > 1:
111
+ fusiones += 1
112
+ else:
113
+ sueltos += 1
114
+ try:
115
+ if int(cuando) >= desde:
116
+ recientes += 1
117
+ except ValueError:
118
+ pass
119
+ return fusiones, sueltos, recientes
120
+
121
+
122
+ def contador_git_local(a, datos):
123
+ """The code counter the sheet injects into `crecimiento.crece`: local git
124
+ history from the repos this agent actually reaches, no network, no token.
125
+ Floors are merge commits (a merged PR lands as exactly one), bricks the
126
+ plain commits, activity the last thirty days. Without this, `_code`
127
+ honestly answers zero and every code agent's sheet showed an empty house
128
+ no matter how much it had built. The disk index a legacy agent needs is
129
+ memoised inside capabilities, so N agents cost one find-repos scan.
130
+ """
131
+ import time
132
+
133
+ if a.legacy:
134
+ rutas = [capabilities.ruta_de(a.nombre)]
135
+ else:
136
+ rutas = [a.workspace] + list(workspace.mount_targets(a, datos) or [])
137
+ desde = int(time.time()) - crecimiento.DIAS_RECIENTE * 24 * 3600
138
+ prs = commits = act = 0
139
+ vistos = set()
140
+ for r in rutas:
141
+ real = os.path.realpath(r) if r else ""
142
+ if not real or real in vistos or not os.path.exists(os.path.join(real, ".git")):
143
+ continue
144
+ vistos.add(real)
145
+ f, s, rec = _historia_git(real, desde)
146
+ prs += f
147
+ commits += s
148
+ act += rec
149
+ return prs, commits, act
150
+
151
+
152
+ _SKILLS = {}
153
+
154
+
155
+ def skills_de_ciudad(datos):
156
+ """Live skill discovery, remembered while the card has not changed.
157
+
158
+ Discovery reads a SKILL.md for every mount of every agent, and /api/estado
159
+ runs on every page load and after every mount, agent-add and skill install.
160
+ The card's mtime is the honest key: everything discovery depends on — who
161
+ the agents are and what they mount — is written there, so a card that has
162
+ not moved cannot have a different answer, and one that has invalidates
163
+ immediately rather than after a timeout.
164
+ """
165
+ owner = cities.lee_clave(datos, "owner") or seat.quien_soy()
166
+ ficha = os.path.join(datos, f"{owner}.md")
167
+ try:
168
+ sello = (os.path.realpath(datos), os.path.getmtime(ficha))
169
+ except OSError:
170
+ return capabilities.descubre_ciudad(datos)
171
+ if _SKILLS.get("sello") == sello:
172
+ return _SKILLS["valor"]
173
+ valor = capabilities.descubre_ciudad(datos)
174
+ _SKILLS["sello"], _SKILLS["valor"] = sello, valor
175
+ return valor
176
+
177
+
178
+ def olvida_skills():
179
+ """Forget the discovery memo: a skill was installed or removed, and that
180
+ changes the answer without touching the card."""
181
+ _SKILLS.pop("sello", None)
182
+
183
+
184
+ def olvida_crecimiento(datos, slug):
185
+ """Drop one agent's remembered growth: it just changed for a known reason.
186
+
187
+ Mounting a folder changes exactly what growth counts, and a sheet that
188
+ answers with the pre-mount number for the next ninety seconds looks like
189
+ the mount did not take."""
190
+ _CRECIDO.pop((os.path.realpath(datos), slug), None)
191
+
192
+
193
+ def _crecimiento_cacheado(a, datos, vida=90):
194
+ import time
195
+
196
+ clave = (os.path.realpath(datos), a.slug)
197
+ momento, valor = _CRECIDO.get(clave, (0, None))
198
+ if valor is not None and time.monotonic() - momento < vida:
199
+ return valor
200
+ # One walk at a time, and the loser of the race reads the winner's answer:
201
+ # this is a directory walk plus git behind a threaded server, so two tabs
202
+ # loading at once would otherwise both pay for it.
203
+ with _CRECIENDO:
204
+ momento, valor = _CRECIDO.get(clave, (0, None))
205
+ if valor is not None and time.monotonic() - momento < vida:
206
+ return valor
207
+ try:
208
+ valor = crecimiento.crece(a, datos, contador_git_local)
209
+ except (OSError, ValueError, subprocess.SubprocessError):
210
+ valor = {"floors": 0, "bricks": 0, "activity30": 0, "signal": "unavailable"}
211
+ _CRECIDO[clave] = (time.monotonic(), valor)
212
+ return valor
213
+
214
+
215
+ def ficha_de_agente(a, texto, datos, ventanas):
216
+ """One agent's character sheet, every number real: identity and kind, the
217
+ engine it starts with (specific-then-default, same resolution the launcher
218
+ uses), the engine's traffic light, and how its house has grown. Growth may
219
+ cost a directory walk, so a failure degrades to honest zeros rather than a
220
+ broken Hall."""
221
+ modelo = card.campo(texto, f"model.{a.slug}") or card.campo(texto, "model")
222
+ esfuerzo = card.campo(texto, f"effort.{a.slug}") or card.campo(texto, "effort")
223
+ semilla = card.campo(texto, f"avatar.{a.slug}")
224
+ crecido = _crecimiento_cacheado(a, datos)
225
+ return {
226
+ "name": a.nombre,
227
+ "slug": a.slug,
228
+ "kind": a.clase,
229
+ "role": a.rol,
230
+ "runtime": a.runtime,
231
+ "model": modelo,
232
+ "effort": esfuerzo,
233
+ "avatar_seed": semilla,
234
+ "avatar": avatar.data_uri(a.nombre, a.clase, semilla=semilla, rol=a.rol),
235
+ "cli": estado_del_cli(a, ventanas),
236
+ "legacy": a.legacy,
237
+ "mounts": montajes_de_agente(a, datos),
238
+ "growth": crecido,
239
+ }
240
+
241
+
242
+ def montajes_de_agente(a, datos):
243
+ """What this agent actually works on: the mounts materialised in its
244
+ workspace, or the sources its card declares when nothing is on disk yet.
245
+
246
+ The sheet used to carry only how MANY there were, which is the one thing a
247
+ person cannot act on — you cannot unmount a number."""
248
+ if a.legacy:
249
+ return [{"label": a.slug, "target": a.workspace, "fixed": True}]
250
+ en_disco = workspace.mounts_en_disco(datos, a.slug)
251
+ if en_disco:
252
+ return [{"label": e, "target": t, "fixed": False} for e, t in en_disco]
253
+ return [
254
+ {"label": card.ventana(os.path.basename(str(m).rstrip("/"))), "target": m, "fixed": False}
255
+ for m in a.mounts
256
+ ]
257
+
258
+
259
+ def binario_del_agente(a):
260
+ """The executable behind this agent's runtime: the first token of the
261
+ configured command, basename only — a path or flags never leak into which()."""
262
+ crudo = (a.runtime or "claude").strip()
263
+ if crudo.startswith("terminal:"):
264
+ crudo = crudo[len("terminal:"):]
265
+ piezas = crudo.split()
266
+ return os.path.basename(piezas[0]) if piezas else "claude"
267
+
268
+
269
+ def estado_del_cli(a, ventanas):
270
+ """The engine's traffic light, derived and never guessed: green is this
271
+ agent's own window alive in the city's tmux session, yellow is the binary
272
+ present on this machine with nobody in it, red is not installed at all."""
273
+ binario = binario_del_agente(a)
274
+ return {
275
+ "binary": binario,
276
+ "installed": bool(shutil.which(binario)),
277
+ "connected": a.slug in ventanas or a.nombre in ventanas,
278
+ }
279
+
280
+
281
+ def ventanas_vivas(owner, datos):
282
+ """The window names alive in this city's tmux session, or nothing.
283
+
284
+ The `=` prefix asks tmux for an exact session match: bare `-t home` also
285
+ matches a session called `home-2`, and the green dot must never be lit by
286
+ a different city's windows."""
287
+ try:
288
+ salida = subprocess.run(
289
+ [
290
+ "tmux",
291
+ "list-windows",
292
+ "-t",
293
+ "=" + cities.sesion(owner, datos),
294
+ "-F",
295
+ "#{window_name}",
296
+ ],
297
+ capture_output=True,
298
+ text=True,
299
+ timeout=3,
300
+ )
301
+ return set(salida.stdout.split()) if salida.returncode == 0 else set()
302
+ except (OSError, subprocess.SubprocessError):
303
+ return set()
304
+
305
+
306
+ def hogar_de_agente(a, datos):
307
+ """Where one agent's own files live: its workspace for an agents-first
308
+ agent, its repo on disk for a legacy one. Empty when nothing exists yet —
309
+ callers decide whether to create (a workspace) or refuse (a missing repo).
310
+ """
311
+ if not a.legacy:
312
+ return a.workspace
313
+ return capabilities.ruta_de(a.nombre)
314
+
315
+
316
+ def hogar_escribible(a, datos, crear=True):
317
+ """The agent's home, ready to be written into: created when it is a
318
+ workspace, refused when it is a repo that is not on this disk. Returns
319
+ (home, error-message) — exactly one of them truthy."""
320
+ hogar = hogar_de_agente(a, datos)
321
+ if not hogar:
322
+ return "", "this agent's repo is not on disk; clone it first"
323
+ if crear and not a.legacy:
324
+ workspace.crea_workspace(datos, a.slug)
325
+ return hogar, ""
326
+
327
+
328
+ raiz_de_skills = workspace.raiz_de_skills
329
+
330
+
331
+ def skills_con_gestion(a, hogar, skills):
332
+ """The discovered skills, with the ones the Hall itself could remove marked:
333
+ exactly the folders under the agent home's own `.claude/skills` — the one
334
+ place installs go. `dir` is the folder name, which is what removal is keyed
335
+ on; a SKILL.md display name is free to differ from it."""
336
+ raiz = raiz_de_skills(hogar) if hogar else ""
337
+ fuera = []
338
+ for s in skills:
339
+ s = dict(s)
340
+ real = os.path.realpath(s.get("manifest", ""))
341
+ if raiz and real.startswith(raiz + os.sep):
342
+ carpeta = real[len(raiz) + 1:].split(os.sep, 1)[0]
343
+ if card.rol_seguro(carpeta, defecto="") == carpeta:
344
+ s["removable"] = True
345
+ s["dir"] = carpeta
346
+ fuera.append(s)
347
+ return fuera
348
+
349
+
350
+ # The zip guards and the skills root live in workspace.py: the Hall's upload and
351
+ # the wizard's question are two doors onto one deliberate write, and containment
352
+ # rules that exist twice are containment rules that drift apart.
353
+ _zip_inseguro = workspace.zip_inseguro
354
+ _nombre_de_skill = workspace._nombre_de_skill_en_zip
355
+ _extrae_skill = workspace.extrae_zip
356
+
357
+
358
+ def _instala_skill(archivo, entradas, base, nombre, hogar):
359
+ """Extract a validated zip into the home's verified skills root.
360
+
361
+ Returns (home, '', 0) or ('', why, http-code). The try covers what a zip's
362
+ own shape can still break — entries where a file and a directory collide,
363
+ a full disk, a permission wall — because a handler that dies mid-write
364
+ leaves half a folder that 409s every retry forever."""
365
+ raiz = raiz_de_skills(hogar)
366
+ if not raiz:
367
+ return "", "this agent's .claude/skills is a link out of its home", 409
368
+ destino = os.path.join(raiz, nombre)
369
+ if os.path.lexists(destino):
370
+ return "", f"{nombre} already exists there", 409
371
+ try:
372
+ os.makedirs(destino)
373
+ mal = _extrae_skill(archivo, entradas, base, destino)
374
+ except OSError as error:
375
+ mal = f"could not write the skill: {error}"
376
+ if mal:
377
+ shutil.rmtree(destino, ignore_errors=True)
378
+ return "", mal, 400
379
+ return destino, "", 0
380
+
381
+
382
+ def historia_del_demo(datos):
383
+ """The story a demo city replays: its domain AS WRITTEN in city.yml.
384
+
385
+ Not the normalised registry domain — that maps anything it does not know
386
+ (medicina included) to software, which is exactly how the clinic once
387
+ replayed Aurora's night instead of its own morning.
388
+ """
389
+ historia = lee_clave(os.path.join(datos, "city.yml"), "domain")
390
+ return historia if historia in DEMO_STORIES else "software"
391
+
392
+ # Where people actually keep their code. The curses wizard offered `~/code` and
393
+ # nothing else, which is wrong on most machines and silently scans a folder that
394
+ # is not there. Offer the ones that exist, most-repos first, and let them type.
395
+ CANDIDATAS = [
396
+ "code",
397
+ "codigo",
398
+ "src",
399
+ "dev",
400
+ "devel",
401
+ "projects",
402
+ "proyectos",
403
+ "work",
404
+ "trabajo",
405
+ "repos",
406
+ "git",
407
+ "Developer",
408
+ "Documents",
409
+ ]
410
+
411
+
412
+ def glob_fichas(datos):
413
+ import glob as _g
414
+
415
+ return [f for f in _g.glob(os.path.join(datos, "*.md")) if card.lee(f).get("user")]
416
+
417
+
418
+ def lee_clave(fichero, clave, defecto=""):
419
+ try:
420
+ import re as _re
421
+
422
+ m = _re.search(rf"^{clave}:[ \t]*(.*)$", open(fichero).read(), _re.M)
423
+ return (m.group(1).strip() if m else defecto) or defecto
424
+ except OSError:
425
+ return defecto
426
+
427
+
428
+ def lista_con(datos):
429
+ real = os.path.realpath(datos)
430
+ fuera = [dict(c, actual=(c["ruta"] == real)) for c in cities.lista(seat.quien_soy())]
431
+ if not any(c["actual"] for c in fuera):
432
+ fuera.insert(0, {"ruta": real, "nombre": cities.nombre(real), "actual": True})
433
+ return fuera
434
+
435
+
436
+ def mapa_vivo(datos=""):
437
+ """Where the map is being served locally, or ''.
438
+
439
+ The map is a Cloudflare Worker so the whole team can see it deployed; a Worker
440
+ cannot write your disk, which is why the hall is this Python process instead.
441
+ They cannot be one server — but locally they can be one web: the hall finds the
442
+ map and frames it. bin/city takes 8787 or the next free port, so probe a few.
443
+ """
444
+ import urllib.request as _u
445
+
446
+ esperada = cities.identidad(datos or DESTINO)
447
+ for puerto in range(8787, 8797):
448
+ try:
449
+ with _u.urlopen(f"http://127.0.0.1:{puerto}/api/identity", timeout=0.25) as r:
450
+ identidad = json.load(r).get("cityId", "")
451
+ if r.status == 200 and identidad == esperada:
452
+ return f"http://127.0.0.1:{puerto}"
453
+ except Exception:
454
+ continue
455
+ return ""
456
+
457
+
458
+ def actividad_viva(datos):
459
+ """Read-only browser door into this city's authenticated local bus.
460
+
461
+ The Hall's own unguessable PASE protects the endpoint that returns this
462
+ short-lived token. The hub additionally accepts only localhost browser
463
+ origins, and rotates the token on every restart.
464
+ """
465
+ try:
466
+ endpoint = json.load(
467
+ open(
468
+ os.path.join(runtime_processes.ruta(datos), "endpoint.json"),
469
+ encoding="utf-8",
470
+ )
471
+ )
472
+ if endpoint.get("cityId") != cities.identidad(datos):
473
+ raise ValueError("wrong city")
474
+ if os.path.realpath(endpoint.get("dataDir", "")) != os.path.realpath(datos):
475
+ raise ValueError("wrong data folder")
476
+ pid = int(endpoint.get("pid", 0))
477
+ os.kill(pid, 0)
478
+ token = str(endpoint.get("spectatorToken") or "")
479
+ parsed = urllib.parse.urlparse(str(endpoint.get("url") or ""))
480
+ if (
481
+ not token
482
+ or parsed.scheme != "ws"
483
+ or parsed.hostname not in {"127.0.0.1", "localhost"}
484
+ or parsed.path != "/ws"
485
+ ):
486
+ raise ValueError("not a local spectator endpoint")
487
+ query = urllib.parse.parse_qs(parsed.query)
488
+ query["mode"] = ["spectator"]
489
+ query["token"] = [token]
490
+ query_string = urllib.parse.urlencode(query, doseq=True)
491
+ url = urllib.parse.urlunparse(parsed._replace(query=query_string))
492
+ return {
493
+ "online": True,
494
+ "url": url,
495
+ "city": endpoint.get("cityAddress", ""),
496
+ "started_at": endpoint.get("startedAt", ""),
497
+ }
498
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
499
+ return {"online": False, "url": "", "city": "", "started_at": ""}
500
+
501
+
502
+ def carpetas_probables():
503
+ """Existing folders under $HOME that look like where the work lives."""
504
+ casa = os.path.expanduser("~")
505
+ fuera = []
506
+ for n in CANDIDATAS:
507
+ ruta = os.path.join(casa, n)
508
+ if not os.path.isdir(ruta):
509
+ continue
510
+ try:
511
+ hijos = [x for x in os.listdir(ruta) if not x.startswith(".")]
512
+ gits = sum(1 for x in hijos if os.path.isdir(os.path.join(ruta, x, ".git")))
513
+ except OSError:
514
+ continue
515
+ fuera.append({"ruta": ruta, "nombre": n, "items": len(hijos), "gits": gits})
516
+ fuera.sort(key=lambda c: (-c["gits"], -c["items"]))
517
+ return fuera
518
+
519
+
520
+ def catalogo_roles(dominio):
521
+ """The roles this work domain offers, with the one-line remit from each
522
+ role's own file. Same source as the curses screen, so the two cannot drift."""
523
+ plants = {p["id"]: p for p in W.plantillas()}
524
+ tpl = plants.get(domains.canonico(dominio)) or plants.get("software")
525
+ tabla = {r: (nombre, resumen) for r, nombre, resumen, _ in W.ROLES}
526
+ fuera = []
527
+ for r, por_defecto in tpl["roles"]:
528
+ nombre, resumen = tabla.get(r, (r.replace("-", " ").title(), ""))
529
+ fuera.append(
530
+ {
531
+ "id": r,
532
+ "name": nombre,
533
+ "summary": resumen or W.leeRol(r),
534
+ "trade": W.oficio(r),
535
+ "on": por_defecto,
536
+ "architect": r in ARQUITECTOS,
537
+ }
538
+ )
539
+ return fuera
540
+
541
+
542
+ def catalogo_roles_agente():
543
+ """Every built-in specialty a repo agent may adopt, across work domains.
544
+
545
+ Repo roles are deliberately not filtered by the city's domain: a portfolio
546
+ repo in a software city may be represented by an SEO specialist. Authority
547
+ is not part of this list; every one of these actors remains a bus member and
548
+ the owner seat remains the chair.
549
+ """
550
+ pertenencia = {}
551
+ for pack in domains.catalogo():
552
+ for rol, _ in pack["roles"]:
553
+ pertenencia.setdefault(rol, []).append(pack["id"])
554
+ fuera = []
555
+ for rol, nombre, resumen, _ in W.ROLES:
556
+ if rol not in pertenencia:
557
+ continue
558
+ fuera.append(
559
+ {
560
+ "id": rol,
561
+ "name": nombre,
562
+ "summary": resumen,
563
+ "trade": W.oficio(rol),
564
+ "domains": pertenencia[rol],
565
+ }
566
+ )
567
+ return fuera
568
+
569
+
570
+ import roles as _roles # noqa: E402
571
+
572
+ ARQUITECTOS = _roles.ARQUITECTOS # one set: whoever sets the goals
573
+
574
+
575
+ class Manejador(http.server.BaseHTTPRequestHandler):
576
+ server_version = "AgentsCitySetup/1"
577
+
578
+ # ── plumbing ───────────────────────────────────────────────────────────
579
+ def log_message(self, format, *args): # noqa: A002 the base class names it this
580
+ """Quiet by default: the browser is the interface, and a request log
581
+ underneath it reads like something went wrong."""
582
+ if os.environ.get("CITY_SETUP_DEBUG"):
583
+ sys.stderr.write(" %s\n" % (format % args))
584
+
585
+ def responde(self, cuerpo, tipo="application/json", codigo=200):
586
+ if not isinstance(cuerpo, bytes):
587
+ cuerpo = json.dumps(cuerpo).encode()
588
+ self.send_response(codigo)
589
+ self.send_header("Content-Type", tipo)
590
+ self.send_header("Content-Length", str(len(cuerpo)))
591
+ self.send_header("Cache-Control", "no-store")
592
+ # This process writes to disk. No other origin gets to reach it, and no
593
+ # page gets to embed it.
594
+ self.send_header("X-Frame-Options", "DENY")
595
+ self.send_header("Referrer-Policy", "no-referrer")
596
+ self.end_headers()
597
+ try:
598
+ self.wfile.write(cuerpo)
599
+ except (BrokenPipeError, ConnectionResetError):
600
+ pass
601
+
602
+ def autorizado(self, q):
603
+ """The token, plus the two checks that stop another page on this machine
604
+ from driving this server: the Host has to be our loopback address, and any
605
+ Origin that arrives has to be us."""
606
+ if not secrets.compare_digest(
607
+ (q.get(FICHA, [""])[0] or self.headers.get("X-City-Pase", "")), PASE
608
+ ):
609
+ return False
610
+ anfitrion = (self.headers.get("Host") or "").split(":")[0]
611
+ if anfitrion not in ("127.0.0.1", "localhost"):
612
+ return False
613
+ origen = self.headers.get("Origin")
614
+ if origen and origen not in (
615
+ f"http://127.0.0.1:{self.server.server_port}",
616
+ f"http://localhost:{self.server.server_port}",
617
+ ):
618
+ return False
619
+ return True
620
+
621
+ def resuelve_conocida(self, pedida):
622
+ """Resolve only cities already in this Hall; never arbitrary folders."""
623
+ usuario = seat.quien_soy()
624
+ actual = seat.donde_viven_las_fichas()
625
+ candidatas = cities.lista(usuario)
626
+ if not any(os.path.realpath(c["ruta"]) == os.path.realpath(actual) for c in candidatas):
627
+ candidatas.append(
628
+ {
629
+ "ruta": actual,
630
+ "nombre": cities.nombre(actual),
631
+ "slug": cities.slug_ciudad(actual),
632
+ "id": cities.identidad(actual),
633
+ }
634
+ )
635
+ buscada = str(pedida).lower()
636
+ return next(
637
+ (
638
+ c["ruta"]
639
+ for c in candidatas
640
+ if buscada
641
+ in {
642
+ str(c.get("ruta", "")).lower(),
643
+ str(c.get("nombre", "")).lower(),
644
+ str(c.get("slug", "")).lower(),
645
+ str(c.get("id", "")).lower(),
646
+ }
647
+ ),
648
+ "",
649
+ )
650
+
651
+ def ciudad(self, q):
652
+ """The requested known city, or the selected city as a safe fallback."""
653
+ pedida = q.get("city", [""])[0]
654
+ if pedida:
655
+ resuelta = self.resuelve_conocida(pedida)
656
+ if resuelta:
657
+ cities.asegura_metadata(resuelta, seat.quien_soy())
658
+ return resuelta
659
+ datos = seat.donde_viven_las_fichas()
660
+ cities.asegura_metadata(datos, seat.quien_soy())
661
+ return datos
662
+
663
+ # ── routes ─────────────────────────────────────────────────────────────
664
+ # One method per endpoint; these two tables are the whole routing. The
665
+ # old do_GET/do_POST were this repo's god functions — every endpoint
666
+ # inline, complexity 19 and 22, and a new route meant scrolling a wall.
667
+ GETS = {
668
+ "/api/estado": "g_estado",
669
+ "/api/instrucciones": "g_instrucciones",
670
+ "/api/live": "g_live",
671
+ "/api/misrepos": "g_misrepos",
672
+ "/api/domains": "g_domains",
673
+ "/api/roles": "g_roles",
674
+ }
675
+ POSTS = {
676
+ "/api/ficha": "p_ficha",
677
+ "/api/unidades": "p_unidades",
678
+ "/api/parcelas": "p_parcelas",
679
+ "/api/mapa": "p_mapa",
680
+ "/api/sesion": "p_sesion",
681
+ "/api/ciudades": "p_ciudades",
682
+ "/api/roads": "p_roads",
683
+ "/api/agente": "p_agente",
684
+ "/api/agentes": "p_agentes",
685
+ "/api/montaje": "p_montaje",
686
+ "/api/motor": "p_motor",
687
+ "/api/instrucciones": "p_instrucciones",
688
+ "/api/skill": "p_skill",
689
+ "/api/demo": "p_demo",
690
+ "/api/salir": "p_salir",
691
+ }
692
+
693
+ def do_GET(self):
694
+ ruta, _, cadena = self.path.partition("?")
695
+ q = urllib.parse.parse_qs(cadena)
696
+ if not self.autorizado(q):
697
+ return self.responde({"error": "not for you"}, codigo=403)
698
+
699
+ if ruta == "/":
700
+ pagina = open(os.path.join(AQUI, "hall.html"), "rb").read()
701
+ pagina = pagina.replace(b"__PASE__", PASE.encode())
702
+ return self.responde(pagina, "text/html; charset=utf-8")
703
+ if ruta == "/wizard":
704
+ return self.responde(
705
+ {"error": "the v1 multiple-person wizard was retired; use the Hall"}, codigo=404
706
+ )
707
+
708
+ if ruta == "/hall.js":
709
+ # The hall's code, TypeScript built by city/web's own esbuild. From
710
+ # dist-hall/, never dist/: dist/ is what the deployed Worker serves to
711
+ # the whole team, and the hall drives an API that writes this disk.
712
+ if not os.path.isfile(HALL_JS):
713
+ return self.responde(
714
+ {
715
+ "error": "the hall is not built yet — ./bin/hall builds it, "
716
+ "or: cd city/web && npm install && npm run build"
717
+ },
718
+ codigo=404,
719
+ )
720
+ return self.responde(open(HALL_JS, "rb").read(), "text/javascript; charset=utf-8")
721
+
722
+ if ruta in self.GETS:
723
+ return getattr(self, self.GETS[ruta])(q)
724
+ return self.responde({"error": "no such thing"}, codigo=404)
725
+
726
+ def do_POST(self):
727
+ ruta, _, cadena = self.path.partition("?")
728
+ q = urllib.parse.parse_qs(cadena)
729
+ if not self.autorizado(q):
730
+ return self.responde({"error": "not for you"}, codigo=403)
731
+ try:
732
+ largo = int(self.headers.get("Content-Length") or 0)
733
+ cuerpo = json.loads(self.rfile.read(largo) or b"{}")
734
+ except (ValueError, json.JSONDecodeError) as e:
735
+ return self.responde({"error": f"unreadable body: {e}"}, codigo=400)
736
+ if ruta in self.POSTS:
737
+ return getattr(self, self.POSTS[ruta])(q, cuerpo)
738
+ return self.responde({"error": "no such thing"}, codigo=404)
739
+
740
+ def g_estado(self, q):
741
+ datos = self.ciudad(q)
742
+ owner = cities.lee_clave(datos, "owner") or seat.quien_soy()
743
+ tarjetas = []
744
+ # A face, a kind and the full character sheet per agent. Built here
745
+ # because the agent list needs the card text this response drops. One
746
+ # sheet render per agent: the roster portrait IS the sheet's avatar,
747
+ # so the map's face can never drift from the card's.
748
+ retratos = {"seat": avatar.data_uri("seat", "coordinator", rol="chair")}
749
+ agentes = []
750
+ ventanas = ventanas_vivas(owner, datos)
751
+ habilidades = skills_de_ciudad(datos)
752
+ for f in sorted(glob_fichas(datos)):
753
+ c = card.lee(f)
754
+ if c.get("user") == owner:
755
+ texto = c.get("texto") or ""
756
+ try:
757
+ normalizados = workspace.agentes(texto, datos)
758
+ except ValueError:
759
+ # A malformed card (two names, one slug) still gets listed
760
+ # as a card; only its agent sheets have nothing safe to say.
761
+ normalizados = []
762
+ for a in normalizados:
763
+ if a.nombre in retratos:
764
+ continue
765
+ hoja = ficha_de_agente(a, texto, datos, ventanas)
766
+ agentes.append(hoja)
767
+ retratos[a.nombre] = hoja["avatar"]
768
+ info = habilidades.get(a.nombre)
769
+ if info:
770
+ hogar = info["path"] if a.legacy else a.workspace
771
+ info["skills"] = skills_con_gestion(a, hogar, info["skills"])
772
+ c.pop("texto", None)
773
+ tarjetas.append(c)
774
+ ps, lab, _ = parcels.lee(os.path.join(datos, "parcels.yml"))
775
+ sesiones = [
776
+ l.split(":")[0] for l in gh.sh(["tmux", "ls", "-F", "#{session_name}"]).splitlines()
777
+ ]
778
+ return self.responde(
779
+ {
780
+ "datos": datos,
781
+ "casa": os.path.expanduser("~"),
782
+ "yo": owner,
783
+ "address": cities.direccion(owner, datos),
784
+ "city_id": cities.identidad(datos),
785
+ "city_name": cities.nombre(datos),
786
+ "domain": domains.de_ciudad(datos),
787
+ # Compatibility for older built Hall bundles. New code reads domain.
788
+ "kind": domains.de_ciudad(datos),
789
+ "grow": lee_clave(os.path.join(datos, "city.yml"), "grow_command"),
790
+ "tarjetas": tarjetas,
791
+ "unidades": units.propias(os.path.join(datos, "units.yml")),
792
+ "parcelas": ps,
793
+ "lab": sorted(lab),
794
+ "gh": gh.conectado(),
795
+ "tmux": sesiones,
796
+ "plugin": os.path.isdir(os.path.expanduser("~/.claude/plugins/cache/agents-city")),
797
+ "mapa": mapa_vivo(datos),
798
+ "sesion": cities.sesion(owner, datos),
799
+ # The list always contains the city being looked at, registered or
800
+ # not — you can be standing in a city the registry has not met yet.
801
+ # Registration is earned by writing to one, never by looking.
802
+ "ciudades": lista_con(datos),
803
+ "roads": roads.lee(datos),
804
+ "invitation": roads.invitacion(datos, owner),
805
+ "skills": habilidades,
806
+ "deliberations": deliberations.lista(datos),
807
+ "live_bus": actividad_viva(datos),
808
+ "avatars": retratos,
809
+ "agents": agentes,
810
+ "demo": es_demo(datos),
811
+ # A person who opened the Hall deliberately is exactly who
812
+ # should hear that a newer version exists; the check is cached
813
+ # for a day and never runs on a plain terminal command.
814
+ "update": actualiza.aviso(),
815
+ "paleta": [{"hex": c_, "nombre": n} for c_, n in W.PALETA],
816
+ }
817
+ )
818
+
819
+ def g_live(self, q):
820
+ return self.responde(actividad_viva(self.ciudad(q)))
821
+
822
+ def g_misrepos(self, q):
823
+ # The whole disk by remote (find-repos.sh's cached index), with the
824
+ # ones this person has commits in already marked — the seat's own
825
+ # question, served to a page instead of a terminal.
826
+ quien = q.get("user", [seat.quien_soy()])[0]
827
+ # ?refresh=1 rebuilds the disk index first. The cache lasts a day, and
828
+ # the repo somebody just cloned is exactly the one they came to tick.
829
+ if q.get("refresh", [""])[0]:
830
+ gh.sh(
831
+ [
832
+ os.path.join(os.path.dirname(AQUI), "plugin", "scripts", "find-repos.sh"),
833
+ "--refresh",
834
+ ],
835
+ timeout=600,
836
+ )
837
+ todos = seat.repos_del_disco()
838
+ correo = gh.sh(["git", "config", "user.email"]).strip()
839
+ mios = seat.mios(quien, correo, todos)
840
+ return self.responde(
841
+ {
842
+ "repos": [
843
+ {"nombre": n, "ruta": r, "cuando": seat.ultimo_commit(r), "mio": n in mios}
844
+ for n, r in todos
845
+ ]
846
+ }
847
+ )
848
+
849
+ def g_arranque(self, q):
850
+ plants = W.plantillas()
851
+ return self.responde(
852
+ {
853
+ "kinds": plants,
854
+ "destino": DESTINO,
855
+ "casa": os.path.expanduser("~"),
856
+ "carpetas": carpetas_probables(),
857
+ # Only whether the CLI is there. Whether it is logged in is the
858
+ # orgs endpoint's answer, and asking it here would make every
859
+ # page load wait on a network call.
860
+ "gh": bool(shutil.which("gh")),
861
+ "existe": os.path.isdir(DESTINO),
862
+ "paleta": [{"hex": c, "nombre": n} for c, n in W.PALETA],
863
+ "arquitectos": sorted(ARQUITECTOS),
864
+ }
865
+ )
866
+
867
+ def g_roles(self, q):
868
+ if q.get("scope", [""])[0] == "agent":
869
+ return self.responde({"roles": catalogo_roles_agente()})
870
+ elegido = q.get("domain", q.get("kind", ["software"]))[0]
871
+ return self.responde({"roles": catalogo_roles(elegido)})
872
+
873
+ def g_domains(self, _q):
874
+ return self.responde(
875
+ {
876
+ "domains": [
877
+ {"id": p["id"], "name": p["name"], "summary": p["summary"]}
878
+ for p in domains.catalogo()
879
+ ]
880
+ }
881
+ )
882
+
883
+ def g_orgs(self, q):
884
+ return self.responde({"orgs": W.orgs_de_gh()})
885
+
886
+ def g_repos(self, q):
887
+ org = q.get("org", [""])[0]
888
+ if org:
889
+ hallados = [
890
+ {"nombre": n, "detalle": d, "fecha": f, "ruta": ""}
891
+ for n, d, f in W.repos_de_org(org)
892
+ ]
893
+ return self.responde({"repos": hallados})
894
+ base = os.path.expanduser(q.get("ruta", [""])[0])
895
+ if not base or not os.path.isdir(base):
896
+ return self.responde({"repos": [], "error": f"{base or '(empty)'} is not a folder"})
897
+ # Two different third columns, and saying so beats guessing in the page:
898
+ # a git repo reports its last commit, any-folder reports what is inside.
899
+ if q.get("modo", ["git"])[0] == "git":
900
+ crudo = W.repos_del_disco(base)
901
+ repos = [{"nombre": n, "ruta": r, "detalle": "", "fecha": f} for n, r, f in crudo]
902
+ else:
903
+ crudo = W.carpetas_del_disco(base)
904
+ repos = [{"nombre": n, "ruta": r, "detalle": d, "fecha": ""} for n, r, d in crudo]
905
+ return self.responde({"repos": repos})
906
+
907
+ def g_gente(self, q):
908
+ org = q.get("org", [""])[0]
909
+ if org:
910
+ return self.responde(
911
+ {
912
+ "gente": [
913
+ {"user": u, "nombre": u, "detalle": "org member"}
914
+ for u in W.gente_de_org(org)
915
+ ]
916
+ }
917
+ )
918
+ rutas = [os.path.expanduser(r) for r in q.get("ruta", []) if r]
919
+ return self.responde(
920
+ {
921
+ "gente": [
922
+ {"user": u, "nombre": n, "detalle": f"{v} commits"}
923
+ for u, n, v in W.gente_de_repos(rutas)
924
+ ]
925
+ }
926
+ )
927
+
928
+ def p_escribe(self, q, cuerpo):
929
+ d = {
930
+ "destino": os.path.expanduser(cuerpo.get("destino") or DESTINO),
931
+ "unidades": cuerpo.get("unidades") or [],
932
+ "roles": cuerpo.get("roles") or [],
933
+ "repos": cuerpo.get("repos") or [],
934
+ "gente": cuerpo.get("gente") or [],
935
+ "org": cuerpo.get("org") or "",
936
+ "rutas": {k: os.path.expanduser(v) for k, v in (cuerpo.get("rutas") or {}).items()},
937
+ "kind": cuerpo.get("kind") or "product",
938
+ "grow_cmd": cuerpo.get("grow_cmd") or "",
939
+ }
940
+ if cuerpo.get("objetivo", {}).get("title"):
941
+ d["objetivo"] = cuerpo["objetivo"]
942
+ try:
943
+ hechos = W.escribe(d)
944
+ except OSError as e:
945
+ return self.responde({"error": str(e)}, codigo=500)
946
+ RESULTADO.append((d, hechos))
947
+ return self.responde({"hechos": hechos, "destino": d["destino"]})
948
+
949
+ def p_ficha(self, q, cuerpo):
950
+ # One owner seat, written by the same code as every other door. The old
951
+ # endpoint trusted ``body.user`` and could silently add a second person to
952
+ # one city; cities are autonomous seats, not team containers.
953
+ datos = self.ciudad(q)
954
+ quien = cities.lee_clave(datos, "owner") or seat.quien_soy()
955
+ rol = str(cuerpo.get("role") or "dev")
956
+ dominio = domains.canonico(cuerpo.get("domain") or domains.de_ciudad(datos))
957
+ if not domains.obtiene(dominio):
958
+ return self.responde({"error": f"unknown domain: {dominio}"}, codigo=400)
959
+ # The roster is NOT this endpoint's business: /api/agentes and
960
+ # /api/montaje own it, and the wizard owns it in the terminal. What is
961
+ # already on the card is carried over verbatim, so saving your seat
962
+ # never silently empties your city.
963
+ ficha_previa = os.path.join(datos, f"{quien}.md")
964
+ try:
965
+ texto_previo = card.lee(ficha_previa).get("texto") or ""
966
+ roster = [workspace.como_ficha(a) for a in workspace.agentes(texto_previo, datos)]
967
+ except (OSError, ValueError):
968
+ roster = []
969
+ # Absent means unchanged, present means replace. This endpoint rewrites
970
+ # the whole card, so anything it does not carry over is destroyed: a
971
+ # page that saves your role must not silently drop the goal you set in
972
+ # the terminal (the roster above is the same rule).
973
+ if "objetivo" not in cuerpo:
974
+ obj = card.objetivo(texto_previo, quien)
975
+ else:
976
+ obj = cuerpo.get("objetivo") or None
977
+ if obj is not None and not isinstance(obj, dict):
978
+ obj = None
979
+ if obj and not str(obj.get("title", "")).strip():
980
+ obj = None
981
+ try:
982
+ seat.escribe_ficha(
983
+ ficha_previa, quien, rol, roster, obj, cities.slug_ciudad(datos)
984
+ )
985
+ hechos = []
986
+ if cities.gestionada(datos, quien):
987
+ hechos = seat.escribe_suelo(datos, [a["nombre"] for a in roster], dominio)
988
+ domains.selecciona(datos, dominio)
989
+ hechos += domains.materializa(datos, dominio, rol)
990
+ for rol_agente in sorted({a["rol"] for a in roster} - {"blank", rol}):
991
+ hechos += domains.materializa(datos, dominio, rol_agente)
992
+ except OSError as e:
993
+ return self.responde({"error": str(e)}, codigo=500)
994
+ cities.registra(datos)
995
+ return self.responde(
996
+ {
997
+ "ok": True,
998
+ "user": quien,
999
+ "hechos": hechos,
1000
+ "attach": f"tmux attach -t {cities.sesion(quien, datos)}",
1001
+ }
1002
+ )
1003
+
1004
+ def p_unidades(self, q, cuerpo):
1005
+ datos = self.ciudad(q)
1006
+ lista = cuerpo.get("unidades")
1007
+ if not isinstance(lista, list) or not all(
1008
+ isinstance(u, dict) and u.get("id") for u in lista
1009
+ ):
1010
+ return self.responde(
1011
+ {"error": "unidades has to be a list of {id, name, color}"}, codigo=400
1012
+ )
1013
+ try:
1014
+ units.escribe(os.path.join(datos, "units.yml"), lista)
1015
+ except OSError as e:
1016
+ return self.responde({"error": str(e)}, codigo=500)
1017
+ cities.registra(datos)
1018
+ return self.responde(
1019
+ {"ok": True, "unidades": units.propias(os.path.join(datos, "units.yml"))}
1020
+ )
1021
+
1022
+ def p_parcelas(self, q, cuerpo):
1023
+ datos = self.ciudad(q)
1024
+ porRepo = cuerpo.get("repos")
1025
+ if not isinstance(porRepo, dict):
1026
+ return self.responde({"error": "repos has to be {repo: [rows]}"}, codigo=400)
1027
+ try:
1028
+ parcels.escribe(
1029
+ os.path.join(datos, "parcels.yml"),
1030
+ porRepo,
1031
+ lab=[str(x) for x in (cuerpo.get("lab") or [])],
1032
+ )
1033
+ except OSError as e:
1034
+ return self.responde({"error": str(e)}, codigo=500)
1035
+ cities.registra(datos)
1036
+ ps, lab, _ = parcels.lee(os.path.join(datos, "parcels.yml"))
1037
+ return self.responde({"ok": True, "parcelas": ps, "lab": sorted(lab)})
1038
+
1039
+ def p_ciudades(self, q, cuerpo):
1040
+ nombre = " ".join(str(cuerpo.get("name") or "").split())
1041
+ if not nombre:
1042
+ return self.responde({"error": "name is required"}, codigo=400)
1043
+ usuario = seat.quien_soy()
1044
+ try:
1045
+ datos = cities.crea(usuario, nombre)
1046
+ except (OSError, ValueError) as e:
1047
+ return self.responde({"error": str(e)}, codigo=400)
1048
+ return self.responde(
1049
+ {"ok": True, "city": datos, "address": cities.direccion(usuario, datos)}
1050
+ )
1051
+
1052
+ def p_roads(self, q, cuerpo):
1053
+ origen = self.ciudad(q)
1054
+ accion = str(cuerpo.get("action") or "")
1055
+ objetivo = str(cuerpo.get("target") or "")
1056
+ usuario = seat.quien_soy()
1057
+ try:
1058
+ if accion == "connect":
1059
+ destino = self.resuelve_conocida(objetivo)
1060
+ if not destino:
1061
+ return self.responde(
1062
+ {"error": "target city is not present locally"}, codigo=404
1063
+ )
1064
+ roads.conecta(origen, destino, usuario)
1065
+ elif accion == "disconnect":
1066
+ camino = next(
1067
+ (
1068
+ r
1069
+ for r in roads.lee(origen)
1070
+ if r["id"] == objetivo or r["address"] == objetivo
1071
+ ),
1072
+ None,
1073
+ )
1074
+ if not camino:
1075
+ return self.responde({"error": "road does not exist"}, codigo=404)
1076
+ destino = roads.destino_local(camino, usuario)
1077
+ if destino:
1078
+ roads.desconecta_local(origen, destino)
1079
+ else:
1080
+ roads.desconecta(origen, camino["id"])
1081
+ else:
1082
+ return self.responde({"error": "action must be connect or disconnect"}, codigo=400)
1083
+ except (OSError, ValueError) as e:
1084
+ return self.responde({"error": str(e)}, codigo=400)
1085
+ return self.responde({"ok": True, "roads": roads.lee(origen)})
1086
+
1087
+ def p_mapa(self, q, cuerpo):
1088
+ # Bake and serve the map, detached. First run builds the front end and
1089
+ # seeds the local D1, so the page polls /api/estado until it is up.
1090
+ datos = self.ciudad(q)
1091
+ if mapa_vivo(datos):
1092
+ return self.responde({"ok": True, "mapa": mapa_vivo(datos)})
1093
+ guion = os.path.join(os.path.dirname(AQUI), "bin", "city")
1094
+ if not os.path.isfile(guion):
1095
+ return self.responde(
1096
+ {"error": "bin/city is not next to this hall — the map needs the clone"}, codigo=500
1097
+ )
1098
+ subprocess.Popen(
1099
+ [guion, datos],
1100
+ stdout=subprocess.DEVNULL,
1101
+ stderr=subprocess.DEVNULL,
1102
+ start_new_session=True,
1103
+ )
1104
+ return self.responde({"ok": True, "mapa": ""})
1105
+
1106
+ def p_sesion(self, q, cuerpo):
1107
+ # Build the tmux session, detached: the browser cannot hold a terminal,
1108
+ # so the page shows the attach command instead. The script's final
1109
+ # `tmux attach` fails headless and that is fine — the windows exist.
1110
+ datos = self.ciudad(q)
1111
+ quien = cities.lee_clave(datos, "owner") or seat.quien_soy()
1112
+ guion = os.path.join(os.path.dirname(AQUI), "plugin", "scripts", "city-session.sh")
1113
+ subprocess.Popen(
1114
+ [guion, quien, "--claude"],
1115
+ env=dict(
1116
+ os.environ,
1117
+ AGENTS_CITY_DATA=datos,
1118
+ AGENTS_CITY_HOME=cities.raiz(),
1119
+ AGENTS_CITY_USER=quien,
1120
+ CITY_ADDRESS=cities.direccion(quien, datos),
1121
+ ),
1122
+ stdout=subprocess.DEVNULL,
1123
+ stderr=subprocess.DEVNULL,
1124
+ start_new_session=True,
1125
+ )
1126
+ return self.responde(
1127
+ {"ok": True, "attach": f"tmux attach -t {cities.sesion(quien, datos)}"}
1128
+ )
1129
+
1130
+ # Every tunable on the sheet: payload field -> (card key prefix, validator,
1131
+ # refusal). A validator is a regex or a set; both mean "an alias shape or
1132
+ # nothing, never a shell fragment". `runtime` maps to `runs.` — the launcher's
1133
+ # own key — and the web deliberately cannot reach the terminal: fallback.
1134
+ CAMPOS_DE_AGENTE = {
1135
+ "model": ("model", re.compile(r"[a-z0-9][a-z0-9.-]{0,63}"), "that is not a model alias"),
1136
+ "effort": ("effort", frozenset(("low", "medium", "high", "xhigh", "max")),
1137
+ "effort is low..max or empty"),
1138
+ "runtime": ("runs", frozenset(("claude",) + seat.OTROS_MOTORES),
1139
+ "runtime is claude, codex, opencode, kimi or empty"),
1140
+ "avatar": ("avatar", re.compile(r"[a-z0-9-]{1,16}"), "an avatar seed is short and plain"),
1141
+ }
1142
+
1143
+ def p_agentes(self, q, cuerpo):
1144
+ """Add one agent to this city, from the web: name, kind and role.
1145
+
1146
+ The same roster the wizard builds one question at a time, reachable
1147
+ from the Hall — a person who never opens a terminal must be able to say
1148
+ who works in their city, not just tune whoever is already there. Its
1149
+ engine, mounts and skills are the sheet's own controls afterwards.
1150
+ """
1151
+ datos = self.ciudad(q)
1152
+ owner = cities.lee_clave(datos, "owner") or seat.quien_soy()
1153
+ ficha = os.path.join(datos, f"{owner}.md")
1154
+ texto = card.lee(ficha).get("texto") or ""
1155
+ if not texto:
1156
+ return self.responde({"error": "this city has no owner card yet"}, codigo=409)
1157
+ nombre = " ".join(str(cuerpo.get("name") or "").split())
1158
+ slug = card.ventana(nombre)
1159
+ if not nombre or not card.ventana_valida(slug):
1160
+ return self.responde({"error": "an agent needs a plain name"}, codigo=400)
1161
+ clase = str(cuerpo.get("kind") or workspace.CLASE_DEFECTO).strip().lower()
1162
+ if clase not in workspace.CLASES:
1163
+ return self.responde(
1164
+ {"error": "kind is code, knowledge or coordinator"}, codigo=400
1165
+ )
1166
+ rol = card.rol_seguro(str(cuerpo.get("role") or "blank"), defecto="")
1167
+ if not rol:
1168
+ return self.responde({"error": "that is not a role id"}, codigo=400)
1169
+ try:
1170
+ ya = workspace.agentes(texto, datos)
1171
+ except ValueError as e:
1172
+ return self.responde({"error": str(e)}, codigo=409)
1173
+ if any(x.slug == slug for x in ya):
1174
+ return self.responde({"error": f"{slug} is already an agent here"}, codigo=409)
1175
+
1176
+ # A legacy `repos:` card is upgraded in place rather than half-migrated:
1177
+ # every repo it listed is written back as the agent it always was, and
1178
+ # the new one joins them. Nothing that was working stops working. The
1179
+ # keys come from workspace, which owns what a roster looks like on a
1180
+ # card — the wizard writes the very same ones after its seven questions.
1181
+ roster = [workspace.como_ficha(x) for x in ya]
1182
+ roster.append(
1183
+ {"nombre": nombre, "slug": slug, "clase": clase, "rol": rol,
1184
+ "mounts": [], "motor": {}, "skills": []}
1185
+ )
1186
+ for clave, valor in workspace.claves_de_roster(roster).items():
1187
+ card.pon_campo(ficha, clave, valor)
1188
+ workspace.crea_workspace(datos, slug)
1189
+ return self.responde({"ok": True, "agent": slug, "name": nombre})
1190
+
1191
+ def p_montaje(self, q, cuerpo):
1192
+ """Add or remove one of an agent's mounts — a repo, a worktree, or a
1193
+ folder of documents — from the Hall.
1194
+
1195
+ The card is what the launcher and the cage read, so both the symlink and
1196
+ the card key move together; a mount that exists on disk but not on the
1197
+ card would vanish on the next sync.
1198
+ """
1199
+ a, ficha, datos = self._agente(q, cuerpo.get("agent"))
1200
+ if not a:
1201
+ return self.responde({"error": "no such agent here"}, codigo=404)
1202
+ if a.legacy:
1203
+ return self.responde(
1204
+ {"error": "this agent is a legacy repo; add an agents-first one to mount folders"},
1205
+ codigo=409,
1206
+ )
1207
+ quitar = str(cuerpo.get("remove") or "").strip()
1208
+ anadir = str(cuerpo.get("add") or "").strip()
1209
+ fuentes = list(a.mounts)
1210
+ if quitar:
1211
+ reales = {e: t for e, t in workspace.mounts_en_disco(datos, a.slug)}
1212
+ etiqueta = card.ventana(quitar)
1213
+ if etiqueta not in reales:
1214
+ return self.responde({"error": f"{quitar} is not mounted here"}, codigo=404)
1215
+ destino = reales[etiqueta]
1216
+ workspace.desmonta(datos, a.slug, etiqueta)
1217
+ fuentes = [
1218
+ m for m in fuentes
1219
+ if os.path.realpath(os.path.expanduser(m)) != destino
1220
+ and card.ventana(os.path.basename(str(m).rstrip("/"))) != etiqueta
1221
+ ]
1222
+ elif anadir:
1223
+ destino = os.path.realpath(os.path.expanduser(anadir))
1224
+ if not os.path.isdir(destino):
1225
+ return self.responde({"error": f"there is no folder at {destino}"}, codigo=400)
1226
+ try:
1227
+ workspace.monta(datos, a.slug, destino)
1228
+ except (OSError, ValueError) as e:
1229
+ return self.responde({"error": str(e)}, codigo=400)
1230
+ if destino not in [os.path.realpath(os.path.expanduser(m)) for m in fuentes]:
1231
+ fuentes.append(destino)
1232
+ else:
1233
+ return self.responde({"error": "add or remove a folder"}, codigo=400)
1234
+ # An empty value REMOVES the key: `mounts.x: []` is a leftover that says
1235
+ # "this agent declares no mounts" in a longer, staler way.
1236
+ lista = ("[" + ", ".join(fuentes) + "]") if fuentes else ""
1237
+ card.pon_campo(ficha, f"mounts.{a.slug}", lista)
1238
+ # What this agent works on just changed, which is exactly what growth
1239
+ # counts: remembering the old number for 90s would read as a failed mount.
1240
+ olvida_crecimiento(datos, a.slug)
1241
+ return self.responde(
1242
+ {
1243
+ "ok": True,
1244
+ "agent": a.slug,
1245
+ "mounts": [
1246
+ {"label": e, "target": t}
1247
+ for e, t in workspace.mounts_en_disco(datos, a.slug)
1248
+ ],
1249
+ }
1250
+ )
1251
+
1252
+ def p_agente(self, q, cuerpo):
1253
+ """Tune one agent from its character sheet: model, effort, runtime and
1254
+ avatar seed, written to the card keys the launcher already resolves. An
1255
+ empty value removes the key — back to the owner's default, which is the
1256
+ right silence. Applies the next time the session opens."""
1257
+ a, ficha, datos = self._agente(q, cuerpo.get("agent"))
1258
+ if not a:
1259
+ return self.responde({"error": "no such agent here"}, codigo=404)
1260
+ # Validate the whole body first, write after: a 400 answer must never
1261
+ # have left half an edit already persisted on the card.
1262
+ escrituras = []
1263
+ for campo, (clave, valida, queja) in self.CAMPOS_DE_AGENTE.items():
1264
+ if campo not in cuerpo:
1265
+ continue
1266
+ valor = str(cuerpo.get(campo) or "").strip().lower()
1267
+ bien = (valor in valida) if isinstance(valida, frozenset) else bool(
1268
+ valida.fullmatch(valor)
1269
+ )
1270
+ if valor and not bien:
1271
+ return self.responde({"error": queja}, codigo=400)
1272
+ escrituras.append((f"{clave}.{a.slug}", valor))
1273
+ for clave, valor in escrituras:
1274
+ card.pon_campo(ficha, clave, valor)
1275
+
1276
+ # The runtime may have just changed; re-normalise so the sheet answers
1277
+ # with the new engine, its traffic light included.
1278
+ texto = card.lee(ficha).get("texto") or ""
1279
+ owner = cities.lee_clave(datos, "owner") or seat.quien_soy()
1280
+ actual = {x.slug: x for x in workspace.agentes(texto, datos)}.get(a.slug, a)
1281
+ return self.responde(
1282
+ {
1283
+ "ok": True,
1284
+ "agent": ficha_de_agente(actual, texto, datos, ventanas_vivas(owner, datos)),
1285
+ }
1286
+ )
1287
+
1288
+
1289
+ def _agente(self, q, nombre):
1290
+ """Resolve one agent by slug on the selected city, or (None, ...).
1291
+
1292
+ The shape check mirrors card.ventana's slugs (up to 80 chars), not
1293
+ card.rol_seguro's 64 — a long-named agent that renders a full sheet
1294
+ must not 404 on every one of that sheet's actions."""
1295
+ datos = self.ciudad(q)
1296
+ owner = cities.lee_clave(datos, "owner") or seat.quien_soy()
1297
+ ficha = os.path.join(datos, f"{owner}.md")
1298
+ texto = card.lee(ficha).get("texto") or ""
1299
+ if not texto:
1300
+ return None, None, None
1301
+ try:
1302
+ agentes = {a.slug: a for a in workspace.agentes(texto, datos)}
1303
+ except ValueError:
1304
+ return None, None, None
1305
+ pedido = str(nombre or "").strip().lower()
1306
+ if not card.ventana_valida(pedido):
1307
+ pedido = ""
1308
+ return agentes.get(pedido), ficha, datos
1309
+
1310
+ def g_instrucciones(self, q):
1311
+ """One agent's instruction file, as it is on disk. CLAUDE.md is read by
1312
+ the Claude runtime; AGENTS.md by Codex, OpenCode and friends — the Hall
1313
+ labels them so nobody edits a file their engine never opens."""
1314
+ a, _, datos = self._agente(q, q.get("agent", [""])[0])
1315
+ archivo = q.get("file", [""])[0]
1316
+ if archivo not in workspace.INSTRUCCIONES:
1317
+ return self.responde({"error": "file is CLAUDE.md or AGENTS.md"}, codigo=400)
1318
+ if not a:
1319
+ return self.responde({"error": "no such agent here"}, codigo=404)
1320
+ hogar = hogar_de_agente(a, datos)
1321
+ ruta = os.path.join(hogar, archivo) if hogar else ""
1322
+ existe = bool(ruta) and os.path.isfile(ruta)
1323
+ contenido = ""
1324
+ if existe:
1325
+ try:
1326
+ contenido = open(ruta, encoding="utf-8").read()
1327
+ except UnicodeDecodeError:
1328
+ # Never pretend the file is absent: `exists: false` would let
1329
+ # the editor save over it and destroy what it could not read.
1330
+ return self.responde(
1331
+ {"error": f"{archivo} is not UTF-8 text — edit it with your own editor"},
1332
+ codigo=409,
1333
+ )
1334
+ except OSError:
1335
+ existe = False
1336
+ return self.responde(
1337
+ {
1338
+ "agent": a.slug,
1339
+ "file": archivo,
1340
+ "reader": workspace.INSTRUCCIONES[archivo],
1341
+ "exists": existe,
1342
+ "home": hogar,
1343
+ "content": contenido,
1344
+ }
1345
+ )
1346
+
1347
+ def p_instrucciones(self, q, cuerpo):
1348
+ """Write one agent's instruction file, atomically, into its own home —
1349
+ an agents-first workspace or the agent's own repo, never anywhere else."""
1350
+ a, _, datos = self._agente(q, cuerpo.get("agent"))
1351
+ archivo = str(cuerpo.get("file") or "")
1352
+ contenido = cuerpo.get("content")
1353
+ if archivo not in workspace.INSTRUCCIONES:
1354
+ return self.responde({"error": "file is CLAUDE.md or AGENTS.md"}, codigo=400)
1355
+ if not isinstance(contenido, str) or len(contenido) > 256_000:
1356
+ return self.responde({"error": "content is text, under 256 KB"}, codigo=400)
1357
+ if not a:
1358
+ return self.responde({"error": "no such agent here"}, codigo=404)
1359
+ hogar, mal = hogar_escribible(a, datos)
1360
+ if mal:
1361
+ return self.responde({"error": mal}, codigo=409)
1362
+ cities.escribe_atomico(os.path.join(hogar, archivo), contenido)
1363
+ return self.responde({"ok": True, "agent": a.slug, "file": archivo})
1364
+
1365
+ def p_skill(self, q, cuerpo):
1366
+ """Install one skill zip into one agent's own home — the owner's upload,
1367
+ the agent's `.claude/skills/`, and nothing else.
1368
+
1369
+ Recognition stays read-only; this is the one deliberate write, and it is
1370
+ paranoid on purpose: no absolute paths, no `..`, no symlinks, a size cap,
1371
+ and every extracted path re-checked against the destination after
1372
+ resolution. Skills are the Claude runtime's format — other engines
1373
+ ignore them, and the sheet says so."""
1374
+ import base64
1375
+ import io
1376
+ import zipfile
1377
+
1378
+ a, _, datos = self._agente(q, cuerpo.get("agent"))
1379
+ if not a:
1380
+ return self.responde({"error": "no such agent here"}, codigo=404)
1381
+ if cuerpo.get("remove"):
1382
+ return self._quita_skill(a, datos, cuerpo)
1383
+ crudo = str(cuerpo.get("zip") or "")
1384
+ if not crudo or len(crudo) > 14_000_000: # ~10 MB decoded
1385
+ return self.responde({"error": "a skill zip, base64, under 10 MB"}, codigo=400)
1386
+ try:
1387
+ binario = base64.b64decode(crudo, validate=True)
1388
+ archivo = zipfile.ZipFile(io.BytesIO(binario))
1389
+ except (ValueError, zipfile.BadZipFile):
1390
+ return self.responde({"error": "that is not a readable zip"}, codigo=400)
1391
+
1392
+ entradas = archivo.infolist()
1393
+ mal = _zip_inseguro(entradas)
1394
+ if mal:
1395
+ return self.responde({"error": mal}, codigo=400)
1396
+ nombre_skill, base_zip, rutas_zip = _nombre_de_skill(entradas, cuerpo.get("name"))
1397
+ if not nombre_skill:
1398
+ return self.responde({"error": "the skill needs a plain folder name"}, codigo=400)
1399
+ if "SKILL.md" not in rutas_zip:
1400
+ return self.responde({"error": "a skill is a folder with a SKILL.md"}, codigo=400)
1401
+
1402
+ hogar, mal = hogar_escribible(a, datos)
1403
+ if mal:
1404
+ return self.responde({"error": mal}, codigo=409)
1405
+ destino, mal, codigo = _instala_skill(archivo, entradas, base_zip, nombre_skill, hogar)
1406
+ if mal:
1407
+ return self.responde({"error": mal}, codigo=codigo)
1408
+ olvida_skills()
1409
+ return self.responde({"ok": True, "agent": a.slug, "skill": nombre_skill, "home": destino})
1410
+
1411
+ def p_motor(self, q, cuerpo):
1412
+ """The test button: run the agent's engine for real and report. Never a
1413
+ guess — `--version` against the actual binary, plus the login state for
1414
+ Claude. A missing binary is an honest answer, not an HTTP error."""
1415
+ a, _, datos = self._agente(q, cuerpo.get("agent"))
1416
+ if not a:
1417
+ return self.responde({"error": "no such agent here"}, codigo=404)
1418
+ binario = binario_del_agente(a)
1419
+ ruta = shutil.which(binario)
1420
+ if not ruta:
1421
+ return self.responde(
1422
+ {"ok": False, "binary": binario, "detail": f"{binario} is not on this machine"}
1423
+ )
1424
+ try:
1425
+ salida = subprocess.run(
1426
+ [ruta, "--version"], capture_output=True, text=True, timeout=10
1427
+ )
1428
+ except (OSError, subprocess.SubprocessError) as error:
1429
+ return self.responde({"ok": False, "binary": binario, "detail": str(error)})
1430
+ version = (salida.stdout or salida.stderr).strip().splitlines()
1431
+ detalle = ""
1432
+ if binario in ("claude", "claude-code") and salida.returncode == 0:
1433
+ try:
1434
+ auth = subprocess.run(
1435
+ [ruta, "auth", "status"], capture_output=True, text=True, timeout=10
1436
+ )
1437
+ detalle = (
1438
+ "logged in"
1439
+ if re.search(r'"loggedIn"\s*:\s*true', auth.stdout)
1440
+ else "not logged in"
1441
+ )
1442
+ except (OSError, subprocess.SubprocessError):
1443
+ detalle = "auth status unavailable"
1444
+ return self.responde(
1445
+ {
1446
+ "ok": salida.returncode == 0,
1447
+ "binary": binario,
1448
+ "version": version[0] if version else "",
1449
+ "detail": detalle,
1450
+ }
1451
+ )
1452
+
1453
+ def _quita_skill(self, a, datos, cuerpo):
1454
+ """Remove one skill folder from the agent's own `.claude/skills/` — the
1455
+ exact place the Hall installs into, and nowhere else. A skill living
1456
+ elsewhere in a repo (its root SKILL.md, a committed layout) is the
1457
+ repo's property and this endpoint refuses to know it exists."""
1458
+ nombre = card.rol_seguro(str(cuerpo.get("remove") or ""), defecto="")
1459
+ if not nombre:
1460
+ return self.responde({"error": "that is not a skill name"}, codigo=400)
1461
+ hogar, mal = hogar_escribible(a, datos, crear=False)
1462
+ if mal:
1463
+ return self.responde({"error": mal}, codigo=409)
1464
+ raiz = raiz_de_skills(hogar)
1465
+ if not raiz:
1466
+ return self.responde(
1467
+ {"error": "this agent's .claude/skills is a link out of its home"}, codigo=409
1468
+ )
1469
+ if os.path.islink(os.path.join(raiz, nombre)):
1470
+ return self.responde({"error": "that skill is a link, not a folder"}, codigo=400)
1471
+ destino = os.path.realpath(os.path.join(raiz, nombre))
1472
+ if not destino.startswith(raiz + os.sep):
1473
+ return self.responde({"error": "that name escapes the skills folder"}, codigo=400)
1474
+ if not os.path.isdir(destino):
1475
+ return self.responde(
1476
+ {"error": f"no skill called {nombre} in this agent's home"}, codigo=404
1477
+ )
1478
+ shutil.rmtree(destino)
1479
+ olvida_skills()
1480
+ return self.responde({"ok": True, "agent": a.slug, "removed": nombre})
1481
+
1482
+ def p_demo(self, q, cuerpo):
1483
+ """The demo's remote control: replay, pause and resume the guided
1484
+ committee. It runs once when the first spectator connects, and anybody
1485
+ whose map was still baking missed it with no way back — these verbs are
1486
+ that way back. They refuse any city that is not the packaged Aurora
1487
+ demo: a real city's committee is real, and a replay there would be
1488
+ publishing fiction onto a real bus."""
1489
+ datos = self.ciudad(q)
1490
+ if not es_demo(datos):
1491
+ return self.responde({"error": "only a packaged demo can be replayed"}, codigo=403)
1492
+ accion = str(cuerpo.get("action") or "")
1493
+ if accion not in ("restart", "pause", "resume", "status"):
1494
+ return self.responde({"error": f"unknown action: {accion}"}, codigo=400)
1495
+
1496
+ proc = DEMO_SHOW["proc"]
1497
+ vivo = proc is not None and proc.poll() is None
1498
+ if accion == "restart":
1499
+ if vivo:
1500
+ # A paused process cannot handle SIGTERM: wake it first.
1501
+ proc.send_signal(signal.SIGCONT)
1502
+ proc.terminate()
1503
+ try:
1504
+ proc.wait(timeout=3)
1505
+ except subprocess.TimeoutExpired:
1506
+ proc.kill()
1507
+ guion = os.path.join(os.path.dirname(AQUI), "demo", "show.py")
1508
+ historia = historia_del_demo(datos)
1509
+ DEMO_SHOW["proc"] = subprocess.Popen(
1510
+ [sys.executable, guion, "--no-wait", "--step", "2", "--story", historia],
1511
+ stdout=subprocess.DEVNULL,
1512
+ stderr=subprocess.DEVNULL,
1513
+ )
1514
+ DEMO_SHOW["pausado"] = False
1515
+ elif accion == "pause" and vivo and not DEMO_SHOW["pausado"]:
1516
+ # A real pause: the storyteller process is stopped mid-sentence and
1517
+ # resumes exactly where it was, because it is SIGSTOP, not a flag.
1518
+ proc.send_signal(signal.SIGSTOP)
1519
+ DEMO_SHOW["pausado"] = True
1520
+ elif accion == "resume" and vivo and DEMO_SHOW["pausado"]:
1521
+ proc.send_signal(signal.SIGCONT)
1522
+ DEMO_SHOW["pausado"] = False
1523
+
1524
+ proc = DEMO_SHOW["proc"]
1525
+ vivo = proc is not None and proc.poll() is None
1526
+ return self.responde(
1527
+ {"ok": True, "running": vivo, "paused": bool(DEMO_SHOW["pausado"] and vivo)}
1528
+ )
1529
+
1530
+ def p_salir(self, q, cuerpo):
1531
+ threading.Timer(0.4, self.server.shutdown).start()
1532
+ return self.responde({"ok": True})
1533
+
1534
+
1535
+ class Servidor(socketserver.ThreadingMixIn, http.server.HTTPServer):
1536
+ daemon_threads = True
1537
+ allow_reuse_address = True
1538
+
1539
+ def server_bind(self):
1540
+ # Bind without the stdlib's socket.getfqdn() call: it is a reverse DNS
1541
+ # lookup for a cosmetic server_name, and on a machine with a slow or
1542
+ # unanswered resolver it stalls the Hall's start for tens of seconds.
1543
+ socketserver.TCPServer.server_bind(self)
1544
+ self.server_name, self.server_port = self.server_address[:2]
1545
+
1546
+
1547
+ DESTINO = os.path.expanduser("~/agents-city-data")
1548
+ HALL_JS = os.path.join(os.path.dirname(AQUI), "city", "web", "dist-hall", "hall.js")
1549
+ RESULTADO = []
1550
+
1551
+
1552
+ def sirve(destino, abrir=True, pagina="hall"):
1553
+ """Serve the selected city's local Hall until it is stopped."""
1554
+ global DESTINO
1555
+ DESTINO = destino
1556
+ with Servidor(("127.0.0.1", 0), Manejador) as s:
1557
+ puerto = s.server_port
1558
+ url = f"http://127.0.0.1:{puerto}/?{FICHA}={PASE}"
1559
+ titulo = "The town hall"
1560
+ print(f"\n {titulo}\n")
1561
+ print(f" {url}\n")
1562
+ if abrir:
1563
+ # A browser that will not open is not an error worth stopping for: the
1564
+ # URL is right there above.
1565
+ try:
1566
+ webbrowser.open(url)
1567
+ except Exception:
1568
+ pass
1569
+ print(" (opening it — if nothing happened, paste the address above)\n")
1570
+ print(" ctrl-c to stop\n")
1571
+ try:
1572
+ s.serve_forever()
1573
+ except KeyboardInterrupt:
1574
+ print("\n Stopped.\n")
1575
+ return RESULTADO[-1] if RESULTADO else (None, None)
1576
+
1577
+
1578
+ if __name__ == "__main__":
1579
+ sirve(sys.argv[1] if len(sys.argv) > 1 else DESTINO)