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.
- package/.claude-plugin/marketplace.json +16 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.es.md +2226 -0
- package/README.md +2196 -0
- package/benchmarks/committee/README.md +39 -0
- package/benchmarks/committee/metrics.py +51 -0
- package/benchmarks/committee/run.py +64 -0
- package/benchmarks/committee/traces/01-single.json +8 -0
- package/benchmarks/committee/traces/02-mesh.json +16 -0
- package/benchmarks/committee/traces/03-chair.json +15 -0
- package/benchmarks/latency/README.md +35 -0
- package/benchmarks/latency/fake-claude-cli.mjs +119 -0
- package/benchmarks/latency/fake-native-server.mjs +340 -0
- package/benchmarks/latency/fake-tui.py +91 -0
- package/benchmarks/latency/live.py +380 -0
- package/benchmarks/stress/README.md +20 -0
- package/benchmarks/stress/run.py +487 -0
- package/bin/agents +4 -0
- package/bin/agents-city.js +119 -0
- package/bin/benchmark +17 -0
- package/bin/bus +6 -0
- package/bin/cities +3 -0
- package/bin/city +86 -0
- package/bin/committee +6 -0
- package/bin/demo +200 -0
- package/bin/doctor +3 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +498 -0
- package/bin/logs +3 -0
- package/bin/navegador.mjs +243 -0
- package/bin/report.py +14 -0
- package/bin/reset +3 -0
- package/bin/road +3 -0
- package/bin/seat +10 -0
- package/bin/serve.py +1579 -0
- package/bin/setup.py +753 -0
- package/bin/shortcut +3 -0
- package/bin/skills +3 -0
- package/bin/test +133 -0
- package/bin/test-actualiza.py +130 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-atajos.py +301 -0
- package/bin/test-avatar.py +58 -0
- package/bin/test-benchmark.py +40 -0
- package/bin/test-broker.py +334 -0
- package/bin/test-cage.py +374 -0
- package/bin/test-card.py +402 -0
- package/bin/test-channel.py +379 -0
- package/bin/test-cities.py +453 -0
- package/bin/test-claude-runtime.py +252 -0
- package/bin/test-committee.py +340 -0
- package/bin/test-contracts.py +551 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +139 -0
- package/bin/test-domains.py +151 -0
- package/bin/test-evidencia.py +50 -0
- package/bin/test-exit.py +128 -0
- package/bin/test-hall-protocol.py +93 -0
- package/bin/test-launch.py +169 -0
- package/bin/test-live-feed.py +508 -0
- package/bin/test-navegador.py +140 -0
- package/bin/test-pairing.py +99 -0
- package/bin/test-parcels.py +283 -0
- package/bin/test-runtime-failures.py +252 -0
- package/bin/test-runtime-ui.py +488 -0
- package/bin/test-runtime.py +268 -0
- package/bin/test-rutas.py +80 -0
- package/bin/test-seat.py +1598 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1251 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +251 -0
- package/bin/testlib.py +118 -0
- package/bin/tokens.py +14 -0
- package/bin/update +3 -0
- package/bus/scripts/deploy.sh +60 -0
- package/bus/scripts/mint-token.sh +68 -0
- package/bus/scripts/setup-dev.sh +79 -0
- package/bus/scripts/test-channel.ts +8 -0
- package/bus/scripts/test-hub.ts +266 -0
- package/bus/scripts/test-local.sh +40 -0
- package/bus/scripts/test-queue.ts +132 -0
- package/bus/worker/package-lock.json +1971 -0
- package/bus/worker/package.json +17 -0
- package/bus/worker/src/index.ts +485 -0
- package/bus/worker/tsconfig.json +13 -0
- package/bus/worker/wrangler.toml +32 -0
- package/city/oven/README.md +59 -0
- package/city/oven/collect.py +43 -0
- package/city/oven/oven.html +4 -0
- package/city/scripts/history.py +120 -0
- package/city/scripts/seed.py +442 -0
- package/city/web/assets/modelos/License.txt +28 -0
- package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/assets/modelos/building-a.glb +0 -0
- package/city/web/assets/modelos/building-b.glb +0 -0
- package/city/web/assets/modelos/building-c.glb +0 -0
- package/city/web/assets/modelos/building-d.glb +0 -0
- package/city/web/assets/modelos/building-e.glb +0 -0
- package/city/web/assets/modelos/building-f.glb +0 -0
- package/city/web/assets/modelos/building-g.glb +0 -0
- package/city/web/assets/modelos/building-h.glb +0 -0
- package/city/web/assets/sprites/building-a.png +0 -0
- package/city/web/assets/sprites/building-b.png +0 -0
- package/city/web/assets/sprites/building-c.png +0 -0
- package/city/web/assets/sprites/building-d.png +0 -0
- package/city/web/assets/sprites/building-e.png +0 -0
- package/city/web/assets/sprites/building-f.png +0 -0
- package/city/web/assets/sprites/building-g.png +0 -0
- package/city/web/assets/sprites/building-h.png +0 -0
- package/city/web/assets/sprites/building-i.png +0 -0
- package/city/web/assets/sprites/building-j.png +0 -0
- package/city/web/assets/sprites/building-k.png +0 -0
- package/city/web/assets/sprites/building-l.png +0 -0
- package/city/web/assets/sprites/building-m.png +0 -0
- package/city/web/assets/sprites/building-n.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
- package/city/web/assets/sprites/catalogo.json +47 -0
- package/city/web/assets/sprites/detail-awning-wide.png +0 -0
- package/city/web/assets/sprites/detail-awning.png +0 -0
- package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
- package/city/web/assets/sprites/detail-overhang.png +0 -0
- package/city/web/assets/sprites/detail-parasol-a.png +0 -0
- package/city/web/assets/sprites/detail-parasol-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-c.png +0 -0
- package/city/web/assets/sprites/low-detail-building-d.png +0 -0
- package/city/web/assets/sprites/low-detail-building-e.png +0 -0
- package/city/web/assets/sprites/low-detail-building-f.png +0 -0
- package/city/web/assets/sprites/low-detail-building-g.png +0 -0
- package/city/web/assets/sprites/low-detail-building-h.png +0 -0
- package/city/web/assets/sprites/low-detail-building-i.png +0 -0
- package/city/web/assets/sprites/low-detail-building-j.png +0 -0
- package/city/web/assets/sprites/low-detail-building-k.png +0 -0
- package/city/web/assets/sprites/low-detail-building-l.png +0 -0
- package/city/web/assets/sprites/low-detail-building-m.png +0 -0
- package/city/web/assets/sprites/low-detail-building-n.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/assets/sprites/medidas.json +248 -0
- package/city/web/dist/city.js +939 -0
- package/city/web/dist/index.html +294 -0
- package/city/web/dist/modelos/License.txt +28 -0
- package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/dist/modelos/building-a.glb +0 -0
- package/city/web/dist/modelos/building-b.glb +0 -0
- package/city/web/dist/modelos/building-c.glb +0 -0
- package/city/web/dist/modelos/building-d.glb +0 -0
- package/city/web/dist/modelos/building-e.glb +0 -0
- package/city/web/dist/modelos/building-f.glb +0 -0
- package/city/web/dist/modelos/building-g.glb +0 -0
- package/city/web/dist/modelos/building-h.glb +0 -0
- package/city/web/dist/sprites/building-a.png +0 -0
- package/city/web/dist/sprites/building-b.png +0 -0
- package/city/web/dist/sprites/building-c.png +0 -0
- package/city/web/dist/sprites/building-d.png +0 -0
- package/city/web/dist/sprites/building-e.png +0 -0
- package/city/web/dist/sprites/building-f.png +0 -0
- package/city/web/dist/sprites/building-g.png +0 -0
- package/city/web/dist/sprites/building-h.png +0 -0
- package/city/web/dist/sprites/building-i.png +0 -0
- package/city/web/dist/sprites/building-j.png +0 -0
- package/city/web/dist/sprites/building-k.png +0 -0
- package/city/web/dist/sprites/building-l.png +0 -0
- package/city/web/dist/sprites/building-m.png +0 -0
- package/city/web/dist/sprites/building-n.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
- package/city/web/dist/sprites/catalogo.json +47 -0
- package/city/web/dist/sprites/detail-awning-wide.png +0 -0
- package/city/web/dist/sprites/detail-awning.png +0 -0
- package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
- package/city/web/dist/sprites/detail-overhang.png +0 -0
- package/city/web/dist/sprites/detail-parasol-a.png +0 -0
- package/city/web/dist/sprites/detail-parasol-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-c.png +0 -0
- package/city/web/dist/sprites/low-detail-building-d.png +0 -0
- package/city/web/dist/sprites/low-detail-building-e.png +0 -0
- package/city/web/dist/sprites/low-detail-building-f.png +0 -0
- package/city/web/dist/sprites/low-detail-building-g.png +0 -0
- package/city/web/dist/sprites/low-detail-building-h.png +0 -0
- package/city/web/dist/sprites/low-detail-building-i.png +0 -0
- package/city/web/dist/sprites/low-detail-building-j.png +0 -0
- package/city/web/dist/sprites/low-detail-building-k.png +0 -0
- package/city/web/dist/sprites/low-detail-building-l.png +0 -0
- package/city/web/dist/sprites/low-detail-building-m.png +0 -0
- package/city/web/dist/sprites/low-detail-building-n.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/dist/sprites/medidas.json +248 -0
- package/city/web/dist-hall/hall.js +1363 -0
- package/city/web/index.html +294 -0
- package/city/web/package.json +18 -0
- package/city/web/sella.py +21 -0
- package/city/web/src/activity-actors.ts +47 -0
- package/city/web/src/activity.ts +172 -0
- package/city/web/src/ayuntamiento.ts +580 -0
- package/city/web/src/draw.ts +640 -0
- package/city/web/src/game-speech.ts +79 -0
- package/city/web/src/hall.ts +1959 -0
- package/city/web/src/main.ts +2252 -0
- package/city/web/src/oven.ts +124 -0
- package/city/web/src/people.ts +244 -0
- package/city/web/src/presencia.ts +193 -0
- package/city/web/src/puertas.ts +180 -0
- package/city/web/src/repo-roles.ts +59 -0
- package/city/worker/package.json +16 -0
- package/city/worker/schema.sql +117 -0
- package/city/worker/src/index.ts +702 -0
- package/city/worker/src/square.ts +218 -0
- package/city/worker/wrangler.toml +58 -0
- package/demo/ada.md +50 -0
- package/demo/bruno.md +62 -0
- package/demo/camila.md +56 -0
- package/demo/city/ada.md +40 -0
- package/demo/city/city.yml +8 -0
- package/demo/city/parcels.yml +15 -0
- package/demo/city/roads.json +4 -0
- package/demo/city/units.yml +10 -0
- package/demo/clinica/city.yml +8 -0
- package/demo/clinica/parcels.yml +15 -0
- package/demo/clinica/roads.json +4 -0
- package/demo/clinica/units.yml +9 -0
- package/demo/clinica/vera.md +46 -0
- package/demo/dante.md +54 -0
- package/demo/despacho/city.yml +8 -0
- package/demo/despacho/marta.md +46 -0
- package/demo/despacho/parcels.yml +15 -0
- package/demo/despacho/roads.json +4 -0
- package/demo/despacho/units.yml +9 -0
- package/demo/elsa.md +50 -0
- package/demo/farid.md +52 -0
- package/demo/greta.md +50 -0
- package/demo/hugo.md +49 -0
- package/demo/iris.md +49 -0
- package/demo/jonas.md +51 -0
- package/demo/kira.md +51 -0
- package/demo/luca.md +49 -0
- package/demo/parcels.yml +92 -0
- package/demo/seed.py +20 -0
- package/demo/show.py +161 -0
- package/demo/stories.py +824 -0
- package/demo/units.yml +33 -0
- package/docs/agents-first.md +106 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +265 -0
- package/docs/self-host.md +151 -0
- package/docs/testing.md +205 -0
- package/package.json +73 -0
- package/plugin/.claude-plugin/plugin.json +62 -0
- package/plugin/.mcp.json +15 -0
- package/plugin/channel/activity-cli.ts +16 -0
- package/plugin/channel/adapter-prompts.ts +113 -0
- package/plugin/channel/adapter.js +4292 -0
- package/plugin/channel/adapter.ts +133 -0
- package/plugin/channel/bus.js +20188 -0
- package/plugin/channel/bus.ts +131 -0
- package/plugin/channel/city-config.ts +125 -0
- package/plugin/channel/claude-channel.ts +58 -0
- package/plugin/channel/cli-args.ts +75 -0
- package/plugin/channel/client.js +4200 -0
- package/plugin/channel/client.ts +38 -0
- package/plugin/channel/committee/activity.ts +256 -0
- package/plugin/channel/committee/collection.ts +154 -0
- package/plugin/channel/committee/decision.ts +175 -0
- package/plugin/channel/committee/floor.ts +135 -0
- package/plugin/channel/committee/guards.ts +30 -0
- package/plugin/channel/committee/history.ts +38 -0
- package/plugin/channel/committee/render.ts +102 -0
- package/plugin/channel/committee/service.ts +72 -0
- package/plugin/channel/committee/storage.ts +92 -0
- package/plugin/channel/committee/types.ts +163 -0
- package/plugin/channel/committee/view.ts +63 -0
- package/plugin/channel/committee-cli.ts +109 -0
- package/plugin/channel/delivery-metrics.ts +61 -0
- package/plugin/channel/delivery-queue.ts +118 -0
- package/plugin/channel/hub/activity-controller.ts +72 -0
- package/plugin/channel/hub/activity-feed.ts +169 -0
- package/plugin/channel/hub/committee-controller.ts +60 -0
- package/plugin/channel/hub/connections.ts +48 -0
- package/plugin/channel/hub/diagnostics.ts +67 -0
- package/plugin/channel/hub/envelope-validity.ts +109 -0
- package/plugin/channel/hub/envelopes.ts +66 -0
- package/plugin/channel/hub/lifecycle.ts +91 -0
- package/plugin/channel/hub/local-roads.ts +89 -0
- package/plugin/channel/hub/remote-roads.ts +165 -0
- package/plugin/channel/hub/road-controller.ts +99 -0
- package/plugin/channel/hub-client.ts +149 -0
- package/plugin/channel/local-hub.js +5995 -0
- package/plugin/channel/local-hub.ts +349 -0
- package/plugin/channel/map-reporter.ts +48 -0
- package/plugin/channel/package-lock.json +1731 -0
- package/plugin/channel/package.json +19 -0
- package/plugin/channel/protocol.ts +98 -0
- package/plugin/channel/road-cli.ts +18 -0
- package/plugin/channel/run.sh +15 -0
- package/plugin/channel/runtime/claude.ts +458 -0
- package/plugin/channel/runtime/codex-config.ts +77 -0
- package/plugin/channel/runtime/codex.ts +669 -0
- package/plugin/channel/runtime/command.ts +62 -0
- package/plugin/channel/runtime/factory.ts +15 -0
- package/plugin/channel/runtime/json-rpc.ts +154 -0
- package/plugin/channel/runtime/kimi.ts +277 -0
- package/plugin/channel/runtime/opencode.ts +248 -0
- package/plugin/channel/runtime/process.ts +84 -0
- package/plugin/channel/runtime/types.ts +45 -0
- package/plugin/channel/runtime-files.ts +73 -0
- package/plugin/channel/runtime-gateway.js +6396 -0
- package/plugin/channel/runtime-gateway.ts +358 -0
- package/plugin/channel/runtime-metrics.ts +52 -0
- package/plugin/channel/runtime-subscription.ts +180 -0
- package/plugin/channel/terminal-delivery.ts +131 -0
- package/plugin/channel/untrusted.ts +60 -0
- package/plugin/commands/committee.md +31 -0
- package/plugin/commands/exit.md +32 -0
- package/plugin/commands/goals.md +20 -0
- package/plugin/commands/join.md +18 -0
- package/plugin/commands/notice.md +20 -0
- package/plugin/commands/propose.md +19 -0
- package/plugin/commands/round.md +17 -0
- package/plugin/commands/session.md +22 -0
- package/plugin/commands/settings.md +25 -0
- package/plugin/commands/setup.md +21 -0
- package/plugin/commands/team.md +23 -0
- package/plugin/domains/custom.md +27 -0
- package/plugin/domains/finance.md +31 -0
- package/plugin/domains/healthcare.md +34 -0
- package/plugin/domains/legal.md +31 -0
- package/plugin/domains/marketing.md +33 -0
- package/plugin/domains/operations.md +31 -0
- package/plugin/domains/research.md +32 -0
- package/plugin/domains/sales.md +30 -0
- package/plugin/domains/software.md +37 -0
- package/plugin/hooks/activity.sh +6 -0
- package/plugin/hooks/digging.sh +68 -0
- package/plugin/hooks/growth.sh +42 -0
- package/plugin/hooks/hooks.json +106 -0
- package/plugin/hooks/notice-on-pr.sh +35 -0
- package/plugin/hooks/notice-on-stop.sh +80 -0
- package/plugin/hooks/notice-pending.sh +58 -0
- package/plugin/hooks/solo-en-ciudad.sh +23 -0
- package/plugin/hooks/tokens.sh +47 -0
- package/plugin/roles/examples/account-executive.md +25 -0
- package/plugin/roles/examples/ai-manager.md +34 -0
- package/plugin/roles/examples/associate.md +24 -0
- package/plugin/roles/examples/brand-lead.md +20 -0
- package/plugin/roles/examples/cfo.md +20 -0
- package/plugin/roles/examples/city-lead.md +25 -0
- package/plugin/roles/examples/clinical-director.md +26 -0
- package/plugin/roles/examples/clinical-ops.md +25 -0
- package/plugin/roles/examples/clinician.md +25 -0
- package/plugin/roles/examples/compliance.md +27 -0
- package/plugin/roles/examples/content.md +24 -0
- package/plugin/roles/examples/controller.md +27 -0
- package/plugin/roles/examples/cpto.md +41 -0
- package/plugin/roles/examples/customer-success.md +25 -0
- package/plugin/roles/examples/data-engineer.md +37 -0
- package/plugin/roles/examples/data.md +60 -0
- package/plugin/roles/examples/dev.md +37 -0
- package/plugin/roles/examples/devops.md +39 -0
- package/plugin/roles/examples/enablement.md +24 -0
- package/plugin/roles/examples/ethics.md +24 -0
- package/plugin/roles/examples/fin-analytics.md +27 -0
- package/plugin/roles/examples/health-compliance.md +25 -0
- package/plugin/roles/examples/health-data.md +26 -0
- package/plugin/roles/examples/knowledge.md +25 -0
- package/plugin/roles/examples/lifecycle.md +26 -0
- package/plugin/roles/examples/llm-engineer.md +34 -0
- package/plugin/roles/examples/managing-partner.md +20 -0
- package/plugin/roles/examples/methods.md +23 -0
- package/plugin/roles/examples/operations-lead.md +23 -0
- package/plugin/roles/examples/ops.md +24 -0
- package/plugin/roles/examples/patient-safety.md +26 -0
- package/plugin/roles/examples/performance.md +27 -0
- package/plugin/roles/examples/po.md +36 -0
- package/plugin/roles/examples/process-owner.md +23 -0
- package/plugin/roles/examples/product-design.md +39 -0
- package/plugin/roles/examples/program-manager.md +24 -0
- package/plugin/roles/examples/quality.md +25 -0
- package/plugin/roles/examples/research-director.md +24 -0
- package/plugin/roles/examples/research-ops.md +25 -0
- package/plugin/roles/examples/researcher.md +25 -0
- package/plugin/roles/examples/revenue-lead.md +25 -0
- package/plugin/roles/examples/revops.md +26 -0
- package/plugin/roles/examples/seo.md +27 -0
- package/plugin/roles/examples/specialist.md +24 -0
- package/plugin/scripts/actualiza.py +198 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +492 -0
- package/plugin/scripts/capabilities.py +210 -0
- package/plugin/scripts/card.py +322 -0
- package/plugin/scripts/cities.py +520 -0
- package/plugin/scripts/city-env.sh +90 -0
- package/plugin/scripts/city-runtime.sh +87 -0
- package/plugin/scripts/city-session.sh +552 -0
- package/plugin/scripts/city_env.py +67 -0
- package/plugin/scripts/crecimiento.py +140 -0
- package/plugin/scripts/deliberations.py +48 -0
- package/plugin/scripts/doctor.py +294 -0
- package/plugin/scripts/domains.py +201 -0
- package/plugin/scripts/evidencia.py +64 -0
- package/plugin/scripts/find-repos.sh +111 -0
- package/plugin/scripts/gh.py +159 -0
- package/plugin/scripts/hall_protocol.py +147 -0
- package/plugin/scripts/hook_activity.py +139 -0
- package/plugin/scripts/launch.py +94 -0
- package/plugin/scripts/logs.py +98 -0
- package/plugin/scripts/pairing.py +185 -0
- package/plugin/scripts/parcels.py +132 -0
- package/plugin/scripts/read-card.py +55 -0
- package/plugin/scripts/report.py +201 -0
- package/plugin/scripts/reset.py +180 -0
- package/plugin/scripts/roads.py +230 -0
- package/plugin/scripts/roles.py +274 -0
- package/plugin/scripts/runtime_log.py +69 -0
- package/plugin/scripts/runtime_processes.py +132 -0
- package/plugin/scripts/rutas.py +83 -0
- package/plugin/scripts/seat.py +1696 -0
- package/plugin/scripts/tokens.py +194 -0
- package/plugin/scripts/trust-repos.py +59 -0
- package/plugin/scripts/ui.py +280 -0
- package/plugin/scripts/units.py +80 -0
- package/plugin/scripts/workspace.py +561 -0
- package/plugin/skills/city/SKILL.md +191 -0
- package/templates/blank.md +39 -0
- package/templates/finance.md +44 -0
- package/templates/legal.md +45 -0
- package/templates/marketing.md +48 -0
- 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)
|