agents-city 0.3.0-beta.21

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