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