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
|
@@ -0,0 +1,1867 @@
|
|
|
1
|
+
/* The town hall's front end.
|
|
2
|
+
*
|
|
3
|
+
* Typed and built by the same esbuild and the same `tsc --strict` as the map,
|
|
4
|
+
* because the first version was 400 lines of untyped template literals inline in
|
|
5
|
+
* the HTML — and it shipped broken once, on an escaped apostrophe the compiler
|
|
6
|
+
* would have caught before any browser saw it.
|
|
7
|
+
*
|
|
8
|
+
* It builds to dist-hall/, not dist/: dist/ is what the deployed Worker serves to
|
|
9
|
+
* the active city, and the hall is local by design — it drives an API that writes
|
|
10
|
+
* this machine's disk. The Python server (bin/serve.py) is the only thing that
|
|
11
|
+
* ever serves this file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { renderRepoRoleEditor, RepoRoleOption, selectedRepoRoles } from './repo-roles';
|
|
15
|
+
import {
|
|
16
|
+
ActivityEvent,
|
|
17
|
+
MAP_ACTIVITY_PROTOCOL,
|
|
18
|
+
isActivityEvent as esActividad,
|
|
19
|
+
isMapNavMessage as esNavDeMapa,
|
|
20
|
+
isPresenceEvent,
|
|
21
|
+
isSpeechEvent,
|
|
22
|
+
} from './activity';
|
|
23
|
+
|
|
24
|
+
// ── what the API answers ─────────────────────────────────────────────────────
|
|
25
|
+
interface Objetivo {
|
|
26
|
+
user?: string;
|
|
27
|
+
title: string;
|
|
28
|
+
signal: string;
|
|
29
|
+
command: string;
|
|
30
|
+
manual?: string;
|
|
31
|
+
baseline: string;
|
|
32
|
+
target: string;
|
|
33
|
+
by: string;
|
|
34
|
+
}
|
|
35
|
+
interface Tarjeta {
|
|
36
|
+
user: string;
|
|
37
|
+
name: string;
|
|
38
|
+
role: string;
|
|
39
|
+
agent: string;
|
|
40
|
+
repos: string[];
|
|
41
|
+
repo_roles: Record<string, string>;
|
|
42
|
+
goals_defined: boolean;
|
|
43
|
+
objetivo: Objetivo | null;
|
|
44
|
+
}
|
|
45
|
+
interface Unidad {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
color: string;
|
|
49
|
+
}
|
|
50
|
+
interface ParcelaFila {
|
|
51
|
+
id: string;
|
|
52
|
+
repo: string;
|
|
53
|
+
ruta: string;
|
|
54
|
+
unidad: string;
|
|
55
|
+
nombre: string;
|
|
56
|
+
}
|
|
57
|
+
interface Color {
|
|
58
|
+
hex: string;
|
|
59
|
+
nombre: string;
|
|
60
|
+
}
|
|
61
|
+
interface Ciudad {
|
|
62
|
+
ruta: string;
|
|
63
|
+
nombre: string;
|
|
64
|
+
slug?: string;
|
|
65
|
+
id?: string;
|
|
66
|
+
actual: boolean;
|
|
67
|
+
}
|
|
68
|
+
interface Road {
|
|
69
|
+
id: string;
|
|
70
|
+
name: string;
|
|
71
|
+
owner: string;
|
|
72
|
+
address: string;
|
|
73
|
+
local?: boolean;
|
|
74
|
+
domain?: string;
|
|
75
|
+
role?: string;
|
|
76
|
+
}
|
|
77
|
+
interface SkillInfo {
|
|
78
|
+
name: string;
|
|
79
|
+
description: string;
|
|
80
|
+
manifest: string;
|
|
81
|
+
provider: string;
|
|
82
|
+
/** Present only when this skill lives in the agent home's own
|
|
83
|
+
* `.claude/skills` — the one place the Hall installs into and may remove
|
|
84
|
+
* from. `dir` is the folder name removal is keyed on; the display name
|
|
85
|
+
* above is the SKILL.md's word and free to differ. */
|
|
86
|
+
removable?: boolean;
|
|
87
|
+
dir?: string;
|
|
88
|
+
}
|
|
89
|
+
interface AgentCapabilities {
|
|
90
|
+
path: string;
|
|
91
|
+
role?: string;
|
|
92
|
+
skills: SkillInfo[];
|
|
93
|
+
}
|
|
94
|
+
/** One agent's character sheet, as the server builds it: identity, engine and
|
|
95
|
+
* growth — every number real, none invented for the card to look fuller. */
|
|
96
|
+
interface FichaAgente {
|
|
97
|
+
name: string;
|
|
98
|
+
slug: string;
|
|
99
|
+
kind: string;
|
|
100
|
+
role: string;
|
|
101
|
+
runtime: string;
|
|
102
|
+
model: string;
|
|
103
|
+
effort: string;
|
|
104
|
+
avatar_seed: string;
|
|
105
|
+
avatar: string;
|
|
106
|
+
cli: { binary: string; installed: boolean; connected: boolean };
|
|
107
|
+
legacy: boolean;
|
|
108
|
+
mounts: number;
|
|
109
|
+
growth: { floors: number; bricks: number; activity30: number; signal: string };
|
|
110
|
+
}
|
|
111
|
+
interface Deliberation {
|
|
112
|
+
id: string;
|
|
113
|
+
status: string;
|
|
114
|
+
question: string;
|
|
115
|
+
desired_outcome: string;
|
|
116
|
+
participants: string[];
|
|
117
|
+
received: number;
|
|
118
|
+
total: number;
|
|
119
|
+
revision: number;
|
|
120
|
+
decision: string;
|
|
121
|
+
contributors: string[];
|
|
122
|
+
verifier: string;
|
|
123
|
+
verification: string;
|
|
124
|
+
updated_at: string;
|
|
125
|
+
act: string;
|
|
126
|
+
}
|
|
127
|
+
interface LiveBus {
|
|
128
|
+
online: boolean;
|
|
129
|
+
url: string;
|
|
130
|
+
city: string;
|
|
131
|
+
started_at: string;
|
|
132
|
+
}
|
|
133
|
+
interface Invitation {
|
|
134
|
+
version: number;
|
|
135
|
+
id: string;
|
|
136
|
+
name: string;
|
|
137
|
+
owner: string;
|
|
138
|
+
address: string;
|
|
139
|
+
domain: string;
|
|
140
|
+
role?: string;
|
|
141
|
+
}
|
|
142
|
+
interface Estado {
|
|
143
|
+
datos: string;
|
|
144
|
+
casa: string;
|
|
145
|
+
yo: string;
|
|
146
|
+
address: string;
|
|
147
|
+
city_id: string;
|
|
148
|
+
city_name: string;
|
|
149
|
+
domain: string;
|
|
150
|
+
kind: string;
|
|
151
|
+
grow: string;
|
|
152
|
+
tarjetas: Tarjeta[];
|
|
153
|
+
unidades: Unidad[];
|
|
154
|
+
parcelas: ParcelaFila[];
|
|
155
|
+
lab: string[];
|
|
156
|
+
gh: boolean;
|
|
157
|
+
tmux: string[];
|
|
158
|
+
plugin: boolean;
|
|
159
|
+
mapa: string;
|
|
160
|
+
paleta: Color[];
|
|
161
|
+
sesion: string;
|
|
162
|
+
ciudades: Ciudad[];
|
|
163
|
+
roads: Road[];
|
|
164
|
+
invitation: Invitation;
|
|
165
|
+
skills: Record<string, AgentCapabilities>;
|
|
166
|
+
deliberations: Deliberation[];
|
|
167
|
+
live_bus: LiveBus;
|
|
168
|
+
/** name → identicon data URI, kind-tinted. Server-drawn (avatar.py) so the
|
|
169
|
+
* face is the same one everywhere, forever. */
|
|
170
|
+
avatars?: Record<string, string>;
|
|
171
|
+
/** The normalised agent list, as full character sheets. */
|
|
172
|
+
agents?: FichaAgente[];
|
|
173
|
+
/** True only for the packaged Aurora demo: unlocks the replay controls. */
|
|
174
|
+
demo?: boolean;
|
|
175
|
+
}
|
|
176
|
+
interface RolInfo {
|
|
177
|
+
id: string;
|
|
178
|
+
name: string;
|
|
179
|
+
summary: string;
|
|
180
|
+
trade: string;
|
|
181
|
+
}
|
|
182
|
+
interface DomainInfo {
|
|
183
|
+
id: string;
|
|
184
|
+
name: string;
|
|
185
|
+
summary: string;
|
|
186
|
+
}
|
|
187
|
+
interface RepoLocal {
|
|
188
|
+
nombre: string;
|
|
189
|
+
ruta: string;
|
|
190
|
+
cuando: string;
|
|
191
|
+
mio: boolean;
|
|
192
|
+
}
|
|
193
|
+
interface Respuesta {
|
|
194
|
+
ok?: boolean;
|
|
195
|
+
error?: string;
|
|
196
|
+
user?: string;
|
|
197
|
+
attach?: string;
|
|
198
|
+
city?: string;
|
|
199
|
+
roads?: Road[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
declare global {
|
|
203
|
+
interface Window {
|
|
204
|
+
PASE: string;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── plumbing ─────────────────────────────────────────────────────────────────
|
|
209
|
+
const PASE = window.PASE;
|
|
210
|
+
const CIUDAD = new URLSearchParams(location.search).get('city') ?? '';
|
|
211
|
+
|
|
212
|
+
async function api<T>(ruta: string, opts?: RequestInit): Promise<T> {
|
|
213
|
+
let u = ruta + (ruta.includes('?') ? '&' : '?') + 'PASE=' + encodeURIComponent(PASE);
|
|
214
|
+
if (CIUDAD) u += '&city=' + encodeURIComponent(CIUDAD);
|
|
215
|
+
const r = await fetch(u, {
|
|
216
|
+
headers: { 'X-City-Pase': PASE, 'Content-Type': 'application/json' },
|
|
217
|
+
...opts,
|
|
218
|
+
});
|
|
219
|
+
return r.json() as Promise<T>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** querySelector that refuses to hand back null: a missing element here is a bug
|
|
223
|
+
* in this file, and an early loud throw beats a silent dead button. */
|
|
224
|
+
function q<T extends Element>(sel: string, raiz: ParentNode = document): T {
|
|
225
|
+
const el = raiz.querySelector<T>(sel);
|
|
226
|
+
if (!el) throw new Error(`no element matches ${sel}`);
|
|
227
|
+
return el;
|
|
228
|
+
}
|
|
229
|
+
function todos<T extends Element>(sel: string, raiz: ParentNode = document): T[] {
|
|
230
|
+
return [...raiz.querySelectorAll<T>(sel)];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function esc(s: unknown): string {
|
|
234
|
+
return String(s ?? '').replace(
|
|
235
|
+
/[&<>"']/g,
|
|
236
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const corto = (p: string): string => p.replace(E.casa, '~');
|
|
240
|
+
|
|
241
|
+
let toastTimer = 0;
|
|
242
|
+
function toast(msg: string, mal = false): void {
|
|
243
|
+
const t = q<HTMLElement>('#toast');
|
|
244
|
+
t.textContent = msg;
|
|
245
|
+
t.className = 'ver' + (mal ? ' mal' : '');
|
|
246
|
+
clearTimeout(toastTimer);
|
|
247
|
+
toastTimer = window.setTimeout(() => {
|
|
248
|
+
t.className = '';
|
|
249
|
+
}, 3200);
|
|
250
|
+
}
|
|
251
|
+
const espera = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
252
|
+
|
|
253
|
+
// ── state ────────────────────────────────────────────────────────────────────
|
|
254
|
+
let E: Estado;
|
|
255
|
+
// A city is a place, not a dashboard. Open on the map so the centre always
|
|
256
|
+
// belongs to the city; configuration and the full transcript stay in the two
|
|
257
|
+
// Hall sidebars. The other sections remain explicit destinations in the rail.
|
|
258
|
+
let SECCION = 'mapa';
|
|
259
|
+
const SECCIONES: Array<[string, string]> = [
|
|
260
|
+
['resumen', 'Overview'],
|
|
261
|
+
['mapa', 'The map'],
|
|
262
|
+
['puesto', 'My seat'],
|
|
263
|
+
['barrios', 'Districts & houses'],
|
|
264
|
+
['red', 'Roads'],
|
|
265
|
+
['committee', 'Committee'],
|
|
266
|
+
['gente', 'Agents & skills'],
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
async function refresca(): Promise<void> {
|
|
270
|
+
E = await api<Estado>('/api/estado');
|
|
271
|
+
pinta();
|
|
272
|
+
pintaDemoControles();
|
|
273
|
+
sincronizaActividad(E.live_bus);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The demo's remote control, in the live rail: replay, pause, resume. The
|
|
278
|
+
* guided committee plays once when the first spectator connects, and anybody
|
|
279
|
+
* whose map was still baking missed it — these buttons are the way back. Real
|
|
280
|
+
* cities never see them, and the server refuses them anyway.
|
|
281
|
+
*/
|
|
282
|
+
function pintaDemoControles(): void {
|
|
283
|
+
const header = document.querySelector<HTMLElement>('#livePanel header');
|
|
284
|
+
const previa = document.getElementById('demoControles');
|
|
285
|
+
if (!header || !E.demo) {
|
|
286
|
+
previa?.remove();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (previa) return; // already wired; the buttons keep their own state
|
|
290
|
+
const caja = document.createElement('div');
|
|
291
|
+
caja.id = 'demoControles';
|
|
292
|
+
caja.innerHTML =
|
|
293
|
+
`<span>guided committee</span>` +
|
|
294
|
+
`<button id="demoReplay" type="button" title="Play the guided committee from the top">⟳ replay</button>` +
|
|
295
|
+
`<button id="demoPausa" type="button" title="Pause or resume mid-scene">⏸ pause</button>`;
|
|
296
|
+
header.appendChild(caja);
|
|
297
|
+
const pausa = q<HTMLButtonElement>('#demoPausa', caja);
|
|
298
|
+
const manda = (action: string) =>
|
|
299
|
+
api<{ ok?: boolean; running?: boolean; paused?: boolean }>('/api/demo', {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
body: JSON.stringify({ action }),
|
|
302
|
+
});
|
|
303
|
+
q<HTMLButtonElement>('#demoReplay', caja).onclick = async () => {
|
|
304
|
+
const r = await manda('restart');
|
|
305
|
+
pausa.textContent = '⏸ pause';
|
|
306
|
+
if (!r.ok) toast('Could not restart the demo', true);
|
|
307
|
+
};
|
|
308
|
+
pausa.onclick = async () => {
|
|
309
|
+
const antes = pausa.textContent?.includes('resume') ?? false;
|
|
310
|
+
const r = await manda(antes ? 'resume' : 'pause');
|
|
311
|
+
if (!r.running) {
|
|
312
|
+
pausa.textContent = '⏸ pause';
|
|
313
|
+
toast('Nothing playing — hit replay', true);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
pausa.textContent = r.paused ? '▶ resume' : '⏸ pause';
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── live, read-only WebSocket feed ──────────────────────────────────────────
|
|
321
|
+
let liveSocket: WebSocket | null = null;
|
|
322
|
+
let liveUrl = '';
|
|
323
|
+
let liveEvents: ActivityEvent[] = [];
|
|
324
|
+
let liveConnected = false;
|
|
325
|
+
let liveRetry = 0;
|
|
326
|
+
let liveSelectedThread: string | null = null;
|
|
327
|
+
let liveFilterTouched = false;
|
|
328
|
+
let liveShowWork = false;
|
|
329
|
+
let pendingMapActivity: ActivityEvent | null = null;
|
|
330
|
+
|
|
331
|
+
function sincronizaActividad(endpoint: LiveBus): void {
|
|
332
|
+
if (!endpoint?.online || !endpoint.url) {
|
|
333
|
+
liveConnected = false;
|
|
334
|
+
pintaActividad();
|
|
335
|
+
programaDescubrimiento();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (
|
|
339
|
+
endpoint.url === liveUrl &&
|
|
340
|
+
liveSocket &&
|
|
341
|
+
(liveSocket.readyState === WebSocket.OPEN || liveSocket.readyState === WebSocket.CONNECTING)
|
|
342
|
+
) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const anterior = liveSocket;
|
|
346
|
+
if (anterior) {
|
|
347
|
+
anterior.onclose = null;
|
|
348
|
+
anterior.close();
|
|
349
|
+
}
|
|
350
|
+
liveUrl = endpoint.url;
|
|
351
|
+
const socket = new WebSocket(endpoint.url);
|
|
352
|
+
liveSocket = socket;
|
|
353
|
+
liveConnected = false;
|
|
354
|
+
pintaActividad();
|
|
355
|
+
socket.onopen = () => {
|
|
356
|
+
if (liveSocket !== socket) return;
|
|
357
|
+
liveConnected = true;
|
|
358
|
+
liveRetry = 0;
|
|
359
|
+
pintaActividad();
|
|
360
|
+
};
|
|
361
|
+
socket.onmessage = (message) => {
|
|
362
|
+
if (liveSocket !== socket) return;
|
|
363
|
+
let value: Record<string, unknown>;
|
|
364
|
+
try {
|
|
365
|
+
value = JSON.parse(String(message.data)) as Record<string, unknown>;
|
|
366
|
+
} catch {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (value.type === 'activity.state') {
|
|
370
|
+
liveEvents = Array.isArray(value.events)
|
|
371
|
+
? (value.events as ActivityEvent[]).filter(esActividad)
|
|
372
|
+
: [];
|
|
373
|
+
liveEvents.sort((a, b) => a.seq - b.seq);
|
|
374
|
+
if (liveSelectedThread === null) liveSelectedThread = newestConversationThread();
|
|
375
|
+
pintaActividad(true);
|
|
376
|
+
} else if (value.type === 'activity.event' && esActividad(value.event)) {
|
|
377
|
+
const event = value.event;
|
|
378
|
+
if (!liveEvents.some((item) => item.id === event.id)) liveEvents.push(event);
|
|
379
|
+
liveEvents = liveEvents.sort((a, b) => a.seq - b.seq).slice(-200);
|
|
380
|
+
if (!liveFilterTouched && event.kind === 'committee.opened' && event.thread) {
|
|
381
|
+
liveSelectedThread = event.thread;
|
|
382
|
+
}
|
|
383
|
+
pintaActividad(true);
|
|
384
|
+
enviaActividadAlMapa(event);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
socket.onclose = () => {
|
|
388
|
+
if (liveSocket !== socket) return;
|
|
389
|
+
liveSocket = null;
|
|
390
|
+
liveConnected = false;
|
|
391
|
+
pintaActividad();
|
|
392
|
+
programaDescubrimiento();
|
|
393
|
+
};
|
|
394
|
+
socket.onerror = () => {};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The Hall keeps the full transcript in its right rail. The map gets the same
|
|
398
|
+
* semantic event for a short, anchored game bubble — and the lifecycle beats,
|
|
399
|
+
* which never become bubbles but drive the map's live lights. */
|
|
400
|
+
function enviaActividadAlMapa(event: ActivityEvent): void {
|
|
401
|
+
const esComite = event.kind.startsWith('committee.');
|
|
402
|
+
if (!isSpeechEvent(event) && !isPresenceEvent(event) && !esComite) return;
|
|
403
|
+
const frame = document.querySelector<HTMLIFrameElement>('#cityMapFrame');
|
|
404
|
+
if (!frame) return;
|
|
405
|
+
if (frame.dataset.ready !== '1') {
|
|
406
|
+
pendingMapActivity = event;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const origin = new URL(frame.src, location.href).origin;
|
|
410
|
+
frame.contentWindow?.postMessage(
|
|
411
|
+
{ protocol: MAP_ACTIVITY_PROTOCOL, type: 'activity.event', event },
|
|
412
|
+
origin,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function programaDescubrimiento(): void {
|
|
417
|
+
if (liveRetry) return;
|
|
418
|
+
liveRetry = window.setTimeout(async () => {
|
|
419
|
+
liveRetry = 0;
|
|
420
|
+
try {
|
|
421
|
+
sincronizaActividad(await api<LiveBus>('/api/live'));
|
|
422
|
+
} catch {
|
|
423
|
+
programaDescubrimiento();
|
|
424
|
+
}
|
|
425
|
+
}, 1_500);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function pintaActividad(seguir = false): void {
|
|
429
|
+
const dot = document.querySelector<HTMLElement>('#liveDot');
|
|
430
|
+
const state = document.querySelector<HTMLElement>('#liveState');
|
|
431
|
+
const list = document.querySelector<HTMLOListElement>('#liveEvents');
|
|
432
|
+
const filter = document.querySelector<HTMLSelectElement>('#liveFilter');
|
|
433
|
+
const context = document.querySelector<HTMLElement>('#liveContext');
|
|
434
|
+
const workToggle = document.querySelector<HTMLButtonElement>('#liveWorkToggle');
|
|
435
|
+
if (!dot || !state || !list || !filter || !context || !workToggle) return;
|
|
436
|
+
dot.classList.toggle('on', liveConnected);
|
|
437
|
+
state.textContent = liveConnected
|
|
438
|
+
? 'websocket live'
|
|
439
|
+
: liveUrl
|
|
440
|
+
? 'reconnecting'
|
|
441
|
+
: 'session offline';
|
|
442
|
+
|
|
443
|
+
const opened = new Map<string, string>();
|
|
444
|
+
for (const event of liveEvents) {
|
|
445
|
+
if (
|
|
446
|
+
event.thread &&
|
|
447
|
+
(event.kind === 'committee.opened' || event.kind === 'conversation.user') &&
|
|
448
|
+
!esPromptDeTransporte(event) &&
|
|
449
|
+
!opened.has(event.thread)
|
|
450
|
+
) {
|
|
451
|
+
opened.set(event.thread, event.summary);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
filter.innerHTML =
|
|
455
|
+
'<option value="">all conversations</option>' +
|
|
456
|
+
[...opened.entries()]
|
|
457
|
+
.reverse()
|
|
458
|
+
.map(
|
|
459
|
+
([thread, question]) =>
|
|
460
|
+
`<option value="${esc(thread)}">${esc(question.slice(0, 54) || thread)}</option>`,
|
|
461
|
+
)
|
|
462
|
+
.join('');
|
|
463
|
+
const selected = liveSelectedThread || '';
|
|
464
|
+
filter.value = [...filter.options].some((option) => option.value === selected) ? selected : '';
|
|
465
|
+
filter.onchange = () => {
|
|
466
|
+
liveSelectedThread = filter.value;
|
|
467
|
+
liveFilterTouched = true;
|
|
468
|
+
pintaActividad(true);
|
|
469
|
+
};
|
|
470
|
+
workToggle.classList.toggle('on', liveShowWork);
|
|
471
|
+
workToggle.setAttribute('aria-pressed', String(liveShowWork));
|
|
472
|
+
workToggle.textContent = liveShowWork ? 'hide work' : 'show work';
|
|
473
|
+
workToggle.onclick = () => {
|
|
474
|
+
liveShowWork = !liveShowWork;
|
|
475
|
+
pintaActividad(true);
|
|
476
|
+
};
|
|
477
|
+
pintaContexto(filter.value, context);
|
|
478
|
+
|
|
479
|
+
const scoped = filter.value
|
|
480
|
+
? liveEvents.filter((event) => event.thread === filter.value)
|
|
481
|
+
: liveEvents;
|
|
482
|
+
const visible = scoped.filter(
|
|
483
|
+
(event) =>
|
|
484
|
+
!esPromptDeTransporte(event) &&
|
|
485
|
+
(liveShowWork || !esRuidoDeTrabajo(event) || event.tone === 'error'),
|
|
486
|
+
);
|
|
487
|
+
if (!visible.length) {
|
|
488
|
+
list.innerHTML = `<li class="liveEmpty">${
|
|
489
|
+
liveConnected
|
|
490
|
+
? 'The bus is live. Questions, positions and moderated replies will appear here.'
|
|
491
|
+
: 'Start the city session. Its visible conversation will appear here as it happens.'
|
|
492
|
+
}</li>`;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const wasNearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 90;
|
|
496
|
+
list.innerHTML = visible.map(renderActividad).join('');
|
|
497
|
+
if (seguir || wasNearBottom) list.scrollTop = list.scrollHeight;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function renderActividad(event: ActivityEvent): string {
|
|
501
|
+
const when = new Date(event.at);
|
|
502
|
+
const time = Number.isNaN(when.getTime())
|
|
503
|
+
? ''
|
|
504
|
+
: when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
505
|
+
const side = event.role === 'chair' ? 'chair' : event.role === 'member' ? 'member' : 'system';
|
|
506
|
+
const role = rolOperativo(event.actor);
|
|
507
|
+
const route =
|
|
508
|
+
event.target && !['committee', 'seat'].includes(event.target)
|
|
509
|
+
? `to ${event.target}`
|
|
510
|
+
: event.target === 'committee'
|
|
511
|
+
? 'to the committee'
|
|
512
|
+
: '';
|
|
513
|
+
const summary = event.summary.trim();
|
|
514
|
+
const compact = resumenVisible(summary);
|
|
515
|
+
const long = compact !== summary;
|
|
516
|
+
const details = event.details.length
|
|
517
|
+
? `<details class="liveEvidence"><summary>${event.details.length} ${
|
|
518
|
+
event.details.length === 1 ? 'detail' : 'details'
|
|
519
|
+
}</summary><ul>${event.details.map((detail) => `<li>${esc(detail)}</li>`).join('')}</ul></details>`
|
|
520
|
+
: '';
|
|
521
|
+
const full = long
|
|
522
|
+
? `<details class="liveFull"><summary>read full message</summary><p>${esc(summary)}</p></details>`
|
|
523
|
+
: '';
|
|
524
|
+
return `<li class="liveTurn ${side} ${esc(event.tone)}">
|
|
525
|
+
<div class="liveAvatar${E?.avatars?.[event.actor] ? ' conCara' : ''}"
|
|
526
|
+
style="--actor-hue:${tonoActor(event.actor)}" aria-hidden="true">
|
|
527
|
+
${cara(event.actor, event.role === 'chair')}
|
|
528
|
+
</div>
|
|
529
|
+
<article class="liveBubble">
|
|
530
|
+
<div class="liveMeta"><span class="liveActor">${esc(event.actor)}</span>
|
|
531
|
+
${role ? `<span class="liveRole">${esc(role)}</span>` : ''}
|
|
532
|
+
${route ? `<span>${esc(route)}</span>` : ''}<span class="liveWhen">${esc(time)}</span></div>
|
|
533
|
+
<div class="liveHeading">${icono(event.kind)}<h3>${esc(tituloVisible(event))}</h3></div>
|
|
534
|
+
<p>${esc(compact)}</p>${full}${details}
|
|
535
|
+
</article>
|
|
536
|
+
</li>`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function newestConversationThread(): string {
|
|
540
|
+
return (
|
|
541
|
+
[...liveEvents]
|
|
542
|
+
.reverse()
|
|
543
|
+
.find(
|
|
544
|
+
(event) =>
|
|
545
|
+
Boolean(event.thread) &&
|
|
546
|
+
(event.kind === 'committee.opened' ||
|
|
547
|
+
(event.kind === 'conversation.user' && !esPromptDeTransporte(event))),
|
|
548
|
+
)?.thread || ''
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function pintaContexto(thread: string, container: HTMLElement): void {
|
|
553
|
+
const deliberation = thread ? E?.deliberations?.find((item) => item.id === thread) : undefined;
|
|
554
|
+
const conversation = thread ? liveEvents.filter((event) => event.thread === thread) : [];
|
|
555
|
+
const opened = conversation.find((event) => event.kind === 'committee.opened');
|
|
556
|
+
if (!deliberation && !opened) {
|
|
557
|
+
container.innerHTML = '';
|
|
558
|
+
container.hidden = true;
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const invited =
|
|
562
|
+
opened?.details
|
|
563
|
+
.find((detail) => detail.startsWith('Invited:'))
|
|
564
|
+
?.slice('Invited:'.length)
|
|
565
|
+
.split(',')
|
|
566
|
+
.map((actor) => actor.trim())
|
|
567
|
+
.filter(Boolean) || [];
|
|
568
|
+
const participants = deliberation?.participants || invited;
|
|
569
|
+
const received =
|
|
570
|
+
deliberation?.received ??
|
|
571
|
+
new Set(
|
|
572
|
+
conversation
|
|
573
|
+
.filter((event) => event.kind === 'committee.position.submitted')
|
|
574
|
+
.map((event) => event.actor),
|
|
575
|
+
).size;
|
|
576
|
+
const total = deliberation?.total ?? participants.length;
|
|
577
|
+
const status = conversation.at(-1)?.phase || deliberation?.status || 'starting';
|
|
578
|
+
const actors = ['seat', ...participants];
|
|
579
|
+
container.hidden = false;
|
|
580
|
+
container.innerHTML = `<div class="livePeople">${actors
|
|
581
|
+
.map(
|
|
582
|
+
(actor) =>
|
|
583
|
+
`<span class="livePerson${E?.avatars?.[actor] ? ' conCara' : ''}" title="${esc(
|
|
584
|
+
actor === 'seat' ? 'seat · chair' : `${actor} · ${rolOperativo(actor) || 'member'}`,
|
|
585
|
+
)}" style="--actor-hue:${tonoActor(actor)}">${cara(actor, actor === 'seat')}</span>`,
|
|
586
|
+
)
|
|
587
|
+
.join('')}</div><span class="liveContextText"><b>seat moderates</b> · ${esc(
|
|
588
|
+
received,
|
|
589
|
+
)}/${esc(total)} positions · ${esc(status)}</span>`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function esPromptDeTransporte(event: ActivityEvent): boolean {
|
|
593
|
+
if (event.kind !== 'conversation.user') return false;
|
|
594
|
+
const summary = event.summary.trimStart();
|
|
595
|
+
return (
|
|
596
|
+
summary.startsWith('[Agents City authenticated local bus]') ||
|
|
597
|
+
(summary.startsWith('<channel') && summary.slice(0, 500).includes('plugin:city:city-bus'))
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function esRuidoDeTrabajo(event: ActivityEvent): boolean {
|
|
602
|
+
return (
|
|
603
|
+
event.kind === 'work.command.started' ||
|
|
604
|
+
event.kind === 'work.command.completed' ||
|
|
605
|
+
event.kind === 'runtime.turn.started' ||
|
|
606
|
+
event.kind === 'runtime.turn.completed' ||
|
|
607
|
+
event.kind === 'runtime.gateway.ready' ||
|
|
608
|
+
event.kind === 'runtime.session.started' ||
|
|
609
|
+
event.kind === 'runtime.session.ended'
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function rolOperativo(actor: string): string {
|
|
614
|
+
if (actor === 'seat') return 'chair';
|
|
615
|
+
return E?.skills?.[actor]?.role || '';
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* An actor's face for the live rail: the server-drawn identicon when the city
|
|
620
|
+
* knows this agent, the old initials (or the chair icon) when it does not —
|
|
621
|
+
* an actor from another city still gets shown, just without claiming a face.
|
|
622
|
+
*/
|
|
623
|
+
function cara(actor: string, esChair: boolean): string {
|
|
624
|
+
const uri = E?.avatars?.[actor];
|
|
625
|
+
if (uri && uri.startsWith('data:image/svg+xml;base64,')) {
|
|
626
|
+
return `<img class="liveCara" src="${esc(uri)}" alt="">`;
|
|
627
|
+
}
|
|
628
|
+
return esChair ? icono('chair') : esc(iniciales(actor));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function iniciales(actor: string): string {
|
|
632
|
+
const parts = actor.split(/[-_.\s]+/).filter(Boolean);
|
|
633
|
+
const initials =
|
|
634
|
+
parts.length > 1
|
|
635
|
+
? parts
|
|
636
|
+
.slice(0, 2)
|
|
637
|
+
.map((part) => part[0])
|
|
638
|
+
.join('')
|
|
639
|
+
: actor.slice(0, 2);
|
|
640
|
+
return initials.toUpperCase();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function tonoActor(actor: string): number {
|
|
644
|
+
let hash = 17;
|
|
645
|
+
for (const character of actor) hash = (hash * 31 + character.charCodeAt(0)) % 360;
|
|
646
|
+
return hash;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function resumenVisible(summary: string): string {
|
|
650
|
+
if (summary.length <= 520) return summary;
|
|
651
|
+
const boundary = summary.slice(0, 520).lastIndexOf(' ');
|
|
652
|
+
return summary.slice(0, boundary > 360 ? boundary : 520).trimEnd() + '…';
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function tituloVisible(event: ActivityEvent): string {
|
|
656
|
+
if (event.kind === 'conversation.user')
|
|
657
|
+
return event.actor === 'seat' ? 'Question' : 'Brief received';
|
|
658
|
+
if (event.kind === 'conversation.agent.commentary') return 'Working note';
|
|
659
|
+
if (event.kind === 'conversation.agent') return 'Response';
|
|
660
|
+
if (event.kind === 'committee.opened') return 'Question to the committee';
|
|
661
|
+
if (event.kind === 'committee.position.submitted') return 'Position sealed';
|
|
662
|
+
if (event.kind === 'committee.positions.revealed') return 'Blind round complete';
|
|
663
|
+
if (event.kind === 'committee.position.revealed') return 'Position';
|
|
664
|
+
if (event.kind === 'committee.synthesis.published') return 'Chair synthesis';
|
|
665
|
+
if (event.kind === 'committee.floor.requested') return 'Requests the floor';
|
|
666
|
+
if (event.kind === 'committee.floor.granted') return 'Floor granted';
|
|
667
|
+
if (event.kind === 'committee.floor.denied') return 'Floor denied';
|
|
668
|
+
if (event.kind === 'committee.floor.spoke') return 'Intervention';
|
|
669
|
+
if (event.kind === 'committee.decision.recorded') return 'Decision';
|
|
670
|
+
if (event.kind.startsWith('committee.verification.')) return 'Verification';
|
|
671
|
+
if (event.kind === 'committee.closed') return 'Committee closed';
|
|
672
|
+
return event.title;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function icono(kind: string): string {
|
|
676
|
+
if (kind === 'chair') {
|
|
677
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 17h16l-1.4-9-4.1 3L12 5l-2.5 6-4.1-3L4 17Zm1 2h14"/></svg>';
|
|
678
|
+
}
|
|
679
|
+
if (kind.includes('question') || kind === 'committee.opened' || kind === 'conversation.user') {
|
|
680
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5h14v10H9l-4 4V5Zm5 4a2 2 0 1 1 3.4 1.4c-.9.6-1.4 1-1.4 2.1M12 14.7v.1"/></svg>';
|
|
681
|
+
}
|
|
682
|
+
if (kind.includes('decision') || kind.includes('verification') || kind === 'committee.closed') {
|
|
683
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 4 4L19 6"/></svg>';
|
|
684
|
+
}
|
|
685
|
+
if (kind.includes('floor')) {
|
|
686
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 11V5a1.5 1.5 0 0 1 3 0v5-7a1.5 1.5 0 0 1 3 0v7-5a1.5 1.5 0 0 1 3 0v7l1-2a1.5 1.5 0 0 1 2.6 1.5L17 18a4 4 0 0 1-3.5 2H10a5 5 0 0 1-5-5v-3.5a1.5 1.5 0 0 1 3 0V13"/></svg>';
|
|
687
|
+
}
|
|
688
|
+
if (kind.includes('command') || kind.includes('work.')) {
|
|
689
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 8 4 4-4 4m6 0h6"/></svg>';
|
|
690
|
+
}
|
|
691
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 6h14v12H5zM8 10h8m-8 4h5"/></svg>';
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function rail(): void {
|
|
695
|
+
q('#rail').innerHTML = SECCIONES.map(([id, et]) => {
|
|
696
|
+
const n =
|
|
697
|
+
id === 'gente'
|
|
698
|
+
? String(Object.keys(E.skills).length)
|
|
699
|
+
: id === 'committee'
|
|
700
|
+
? String(E.deliberations.length)
|
|
701
|
+
: id === 'red'
|
|
702
|
+
? String(E.roads.length)
|
|
703
|
+
: id === 'barrios'
|
|
704
|
+
? String(E.parcelas.length)
|
|
705
|
+
: '';
|
|
706
|
+
return `<li class="${id === SECCION ? 'aqui' : ''}" data-s="${id}">
|
|
707
|
+
<span>${et}</span>${n ? `<span class="n">${n}</span>` : ''}</li>`;
|
|
708
|
+
}).join('');
|
|
709
|
+
todos<HTMLElement>('#rail li').forEach((li) => {
|
|
710
|
+
li.onclick = () => {
|
|
711
|
+
SECCION = li.dataset.s ?? 'mapa';
|
|
712
|
+
pinta();
|
|
713
|
+
};
|
|
714
|
+
});
|
|
715
|
+
q('#dondeDatos').textContent = corto(E.datos);
|
|
716
|
+
const cs = q<HTMLElement>('#ciudades');
|
|
717
|
+
if (E.ciudades.length > 1) {
|
|
718
|
+
cs.innerHTML = `<select id="cambiaCiudad" title="which city this hall manages">
|
|
719
|
+
${E.ciudades
|
|
720
|
+
.map(
|
|
721
|
+
(c) => `<option value="${esc(c.ruta)}" ${c.actual ? 'selected' : ''}>
|
|
722
|
+
${esc(c.nombre)}</option>`,
|
|
723
|
+
)
|
|
724
|
+
.join('')}</select>`;
|
|
725
|
+
q<HTMLSelectElement>('#cambiaCiudad').onchange = (ev) => {
|
|
726
|
+
const ruta = (ev.target as HTMLSelectElement).value;
|
|
727
|
+
location.href = '/?PASE=' + encodeURIComponent(PASE) + '&city=' + encodeURIComponent(ruta);
|
|
728
|
+
};
|
|
729
|
+
} else {
|
|
730
|
+
cs.innerHTML = '';
|
|
731
|
+
}
|
|
732
|
+
const nueva = q<HTMLAnchorElement>('#alWizard');
|
|
733
|
+
nueva.onclick = async (ev) => {
|
|
734
|
+
ev.preventDefault();
|
|
735
|
+
const name = window.prompt('Name of the new city');
|
|
736
|
+
if (!name?.trim()) return;
|
|
737
|
+
const r = await api<Respuesta>('/api/ciudades', {
|
|
738
|
+
method: 'POST',
|
|
739
|
+
body: JSON.stringify({ name }),
|
|
740
|
+
});
|
|
741
|
+
if (r.error || !r.city) return toast(r.error ?? 'Could not create the city', true);
|
|
742
|
+
location.href = '/?PASE=' + encodeURIComponent(PASE) + '&city=' + encodeURIComponent(r.city);
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function pinta(): void {
|
|
747
|
+
rail();
|
|
748
|
+
q<HTMLElement>('.cuerpo').style.padding = '';
|
|
749
|
+
q<HTMLElement>('#lienzo').style.maxWidth = '';
|
|
750
|
+
VISTAS[SECCION]();
|
|
751
|
+
q<HTMLElement>('.cuerpo').scrollTop = 0;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const VISTAS: Record<string, () => void> = {};
|
|
755
|
+
|
|
756
|
+
// ── overview ─────────────────────────────────────────────────────────────────
|
|
757
|
+
VISTAS.resumen = () => {
|
|
758
|
+
const mia = E.tarjetas.find((t) => t.user === E.yo);
|
|
759
|
+
const sinUnidad = E.parcelas.filter((p) => p.unidad === 'none' || p.unidad === 'mine').length;
|
|
760
|
+
const tareas = [
|
|
761
|
+
{
|
|
762
|
+
ok: !!mia,
|
|
763
|
+
txt: mia
|
|
764
|
+
? `Your card exists — you are <b>${esc(mia.agent)}</b>`
|
|
765
|
+
: 'Take your seat: role, folders, one goal',
|
|
766
|
+
ir: 'puesto',
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
ok: !!(mia && mia.goals_defined),
|
|
770
|
+
txt:
|
|
771
|
+
mia && mia.goals_defined
|
|
772
|
+
? `Your goal: <b>${esc(mia.objetivo ? mia.objetivo.title : '')}</b>`
|
|
773
|
+
: 'Set <b>one goal</b>, with the command that measures it',
|
|
774
|
+
ir: 'puesto',
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
ok: E.parcelas.length > 0 && sinUnidad === 0,
|
|
778
|
+
txt:
|
|
779
|
+
E.parcelas.length === 0
|
|
780
|
+
? 'No houses yet — pick your folders and they appear'
|
|
781
|
+
: sinUnidad
|
|
782
|
+
? `<b>${sinUnidad}</b> of ${E.parcelas.length} houses sit in no real district — assign them`
|
|
783
|
+
: 'Every house has its district',
|
|
784
|
+
ir: 'barrios',
|
|
785
|
+
},
|
|
786
|
+
];
|
|
787
|
+
const sesionArriba = E.tmux.includes(E.sesion);
|
|
788
|
+
q('#lienzo').innerHTML = `
|
|
789
|
+
<div><span class="sub">your city</span>
|
|
790
|
+
<h1 style="margin-top:6px">${esc(E.city_name)}</h1>
|
|
791
|
+
<p class="de mono" style="margin-top:5px">${esc(E.address)}</p>
|
|
792
|
+
<p class="prosa" style="margin-top:8px">Domain: <b>${esc(E.domain)}</b>. Growth is
|
|
793
|
+
counted with <code class="mono">${esc(E.grow || 'nothing yet')}</code>.</p></div>
|
|
794
|
+
<div class="cifras">
|
|
795
|
+
<div class="cifra"><b>${Object.keys(E.skills).length}</b><span>repo agents</span></div>
|
|
796
|
+
<div class="cifra"><b>${E.parcelas.length}</b><span>houses</span></div>
|
|
797
|
+
<div class="cifra"><b>${E.roads.length}</b><span>roads</span></div>
|
|
798
|
+
<div class="cifra"><b>${E.deliberations.length}</b><span>committee acts</span></div>
|
|
799
|
+
<div class="cifra"><b>${Object.values(E.skills).reduce((n, a) => n + a.skills.length, 0)}</b><span>skills recognised</span></div>
|
|
800
|
+
</div>
|
|
801
|
+
<div class="luces">
|
|
802
|
+
<span class="luz ${E.tarjetas.length ? 'on' : ''}">data repo</span>
|
|
803
|
+
<span class="luz ${E.gh ? 'on' : 'neutra'}">github${E.gh ? '' : ' — optional'}</span>
|
|
804
|
+
<span class="luz ${E.plugin ? 'on' : ''}">plugin installed</span>
|
|
805
|
+
<span class="luz ${sesionArriba ? 'on' : 'neutra'}">tmux session${sesionArriba ? ' up' : ''}</span>
|
|
806
|
+
</div>
|
|
807
|
+
<div><span class="sub">what is left</span>
|
|
808
|
+
<div class="tareas" style="margin-top:9px">${tareas
|
|
809
|
+
.map(
|
|
810
|
+
(t) => `
|
|
811
|
+
<div class="tarea ${t.ok ? 'hecha' : ''}">${t.txt}
|
|
812
|
+
${t.ok ? '' : `<button class="bt mini ir" data-ir="${t.ir}">go</button>`}</div>`,
|
|
813
|
+
)
|
|
814
|
+
.join('')}
|
|
815
|
+
</div></div>
|
|
816
|
+
<div><span class="sub">work</span>
|
|
817
|
+
<div style="display:flex;flex-direction:column;gap:9px;margin-top:9px">
|
|
818
|
+
<div class="orden"><span class="et2">your day</span>
|
|
819
|
+
<code>${
|
|
820
|
+
sesionArriba
|
|
821
|
+
? 'tmux attach -t ' + esc(E.sesion)
|
|
822
|
+
: 'one window per folder, an agent in each'
|
|
823
|
+
}</code>
|
|
824
|
+
${
|
|
825
|
+
sesionArriba
|
|
826
|
+
? `<button class="bt mini" data-copia="tmux attach -t ${esc(E.sesion)}">copy</button>`
|
|
827
|
+
: '<button class="bt mini ppal" id="abreSesion">open my session</button>'
|
|
828
|
+
}
|
|
829
|
+
</div>
|
|
830
|
+
<div class="orden"><span class="et2">the map</span>
|
|
831
|
+
<code>${E.mapa ? esc(E.mapa) : 'not drawn yet'}</code>
|
|
832
|
+
<button class="bt mini ${E.mapa ? '' : 'ppal'}" data-ir="mapa">${E.mapa ? 'open' : 'draw it'}</button></div>
|
|
833
|
+
</div></div>`;
|
|
834
|
+
enlaza();
|
|
835
|
+
const b = document.querySelector<HTMLButtonElement>('#abreSesion');
|
|
836
|
+
if (b)
|
|
837
|
+
b.onclick = async () => {
|
|
838
|
+
b.disabled = true;
|
|
839
|
+
b.textContent = 'building…';
|
|
840
|
+
const r = await api<Respuesta>('/api/sesion', {
|
|
841
|
+
method: 'POST',
|
|
842
|
+
body: JSON.stringify({ user: E.yo }),
|
|
843
|
+
});
|
|
844
|
+
await espera(1500);
|
|
845
|
+
await refresca();
|
|
846
|
+
toast(
|
|
847
|
+
r.ok && r.attach ? `Session built — attach with: ${r.attach}` : 'Could not build it',
|
|
848
|
+
!r.ok,
|
|
849
|
+
);
|
|
850
|
+
};
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// ── the map ──────────────────────────────────────────────────────────────────
|
|
854
|
+
VISTAS.mapa = () => {
|
|
855
|
+
if (E.mapa) {
|
|
856
|
+
// One web: the map lives inside the hall. It is its own server (a Worker,
|
|
857
|
+
// independently deployable) so it arrives by iframe, full bleed. Embed mode
|
|
858
|
+
// hides the map's standalone rails: the Hall already owns both sidebars.
|
|
859
|
+
q<HTMLElement>('.cuerpo').style.padding = '0';
|
|
860
|
+
q<HTMLElement>('#lienzo').style.maxWidth = 'none';
|
|
861
|
+
const mapUrl = new URL(E.mapa, location.href);
|
|
862
|
+
mapUrl.searchParams.set('embed', '1');
|
|
863
|
+
mapUrl.searchParams.set('parent_origin', location.origin);
|
|
864
|
+
q('#lienzo').innerHTML =
|
|
865
|
+
`<iframe id="cityMapFrame" src="${esc(mapUrl.toString())}" title="the map"
|
|
866
|
+
allow="fullscreen" allowfullscreen
|
|
867
|
+
style="width:100%;height:calc(100vh - 2px);border:0;display:block"></iframe>`;
|
|
868
|
+
const frame = q<HTMLIFrameElement>('#cityMapFrame');
|
|
869
|
+
frame.onload = () => {
|
|
870
|
+
frame.dataset.ready = '1';
|
|
871
|
+
// What only the Hall knows and the map should draw: the city's roads,
|
|
872
|
+
// which become its gates. The standalone map never gets this and never
|
|
873
|
+
// invents a gate.
|
|
874
|
+
const origin = new URL(frame.src, location.href).origin;
|
|
875
|
+
frame.contentWindow?.postMessage(
|
|
876
|
+
{
|
|
877
|
+
protocol: MAP_ACTIVITY_PROTOCOL,
|
|
878
|
+
type: 'map.config',
|
|
879
|
+
roads: E.roads.map((r) => ({ name: r.name, address: r.address })),
|
|
880
|
+
agents: (E.agents ?? []).map((a) => ({ name: a.name, kind: a.kind })),
|
|
881
|
+
avatars: E.avatars ?? {},
|
|
882
|
+
},
|
|
883
|
+
origin,
|
|
884
|
+
);
|
|
885
|
+
if (pendingMapActivity) {
|
|
886
|
+
const event = pendingMapActivity;
|
|
887
|
+
pendingMapActivity = null;
|
|
888
|
+
enviaActividadAlMapa(event);
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
q('#lienzo').innerHTML = `<div><span class="sub">the map</span>
|
|
894
|
+
<h1 style="margin-top:6px">Not drawn yet</h1>
|
|
895
|
+
<p class="prosa" style="margin-top:8px">The map can be served independently.
|
|
896
|
+
Locally the hall runs it for you: first time bakes the front end
|
|
897
|
+
and seeds the local database, which takes about a minute.</p></div>
|
|
898
|
+
<div><button class="bt ppal" id="arrancaMapa">Draw my city</button></div>
|
|
899
|
+
<p class="cargando" id="mapaEspera" style="display:none">baking the map — first time takes a minute</p>`;
|
|
900
|
+
q<HTMLButtonElement>('#arrancaMapa').onclick = async () => {
|
|
901
|
+
q<HTMLButtonElement>('#arrancaMapa').disabled = true;
|
|
902
|
+
q<HTMLElement>('#mapaEspera').style.display = '';
|
|
903
|
+
await api<Respuesta>('/api/mapa', { method: 'POST', body: '{}' });
|
|
904
|
+
for (let i = 0; i < 60; i++) {
|
|
905
|
+
await espera(3000);
|
|
906
|
+
E = await api<Estado>('/api/estado');
|
|
907
|
+
if (E.mapa) {
|
|
908
|
+
pinta();
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
toast('It did not come up — run ./bin/city by hand and look at its output', true);
|
|
913
|
+
pinta();
|
|
914
|
+
};
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
// ── my seat ──────────────────────────────────────────────────────────────────
|
|
918
|
+
VISTAS.puesto = () => {
|
|
919
|
+
void puesto();
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
async function puesto(): Promise<void> {
|
|
923
|
+
const mia = E.tarjetas.find((t) => t.user === E.yo);
|
|
924
|
+
q('#lienzo').innerHTML = `<div><span class="sub">my seat</span>
|
|
925
|
+
<h1 style="margin-top:6px">${esc(E.yo)}</h1>
|
|
926
|
+
<p class="prosa" style="margin-top:8px">First choose the work domain, then your
|
|
927
|
+
chair role inside it, the folders you answer for, and one goal. The seat stays
|
|
928
|
+
the boss even when its professional role is blank. Saving writes your card —
|
|
929
|
+
<code class="mono">${esc(corto(E.datos))}/${esc(E.yo)}.md</code> — the same file
|
|
930
|
+
every other door writes.</p></div>
|
|
931
|
+
<div><span class="sub">work domain</span><div class="rolejilla" id="domains" style="margin-top:9px">
|
|
932
|
+
<p class="cargando">reading the domain packs</p></div></div>
|
|
933
|
+
<div><span class="sub">role</span><div class="rolejilla" id="roles" style="margin-top:9px">
|
|
934
|
+
<p class="cargando">reading the role files</p></div></div>
|
|
935
|
+
<div><span class="sub">folders</span><div id="carpetas" style="margin-top:9px">
|
|
936
|
+
<p class="cargando">indexing this disk — first run can take a minute</p></div></div>
|
|
937
|
+
<div><span class="sub">role of each support agent</span>
|
|
938
|
+
<p class="prosa" style="margin-top:8px">The seat above is the chair. Each
|
|
939
|
+
selected repo below adopts its own specialist role; it never gains chair or
|
|
940
|
+
road authority. Blank means no preset role knowledge.</p>
|
|
941
|
+
<div id="agentRoles" style="margin-top:9px"></div></div>
|
|
942
|
+
<div><span class="sub">one goal — optional</span><div class="campos" id="meta" style="margin-top:9px"></div></div>
|
|
943
|
+
<div style="display:flex;gap:10px;align-items:center">
|
|
944
|
+
<button class="bt ppal" id="guardaPuesto">Save my seat</button>
|
|
945
|
+
<span class="cargando" id="puestoEstado" style="display:none">writing</span></div>`;
|
|
946
|
+
|
|
947
|
+
// Domain first, then only the roles relevant inside it. Both are copied into
|
|
948
|
+
// the city as editable Markdown; neither changes skills in a referenced repo.
|
|
949
|
+
const domainsR = (await api<{ domains: DomainInfo[] }>('/api/domains')).domains;
|
|
950
|
+
let domain = E.domain || E.kind || 'software';
|
|
951
|
+
let rol = mia ? mia.role : '';
|
|
952
|
+
let rolesR: RolInfo[] = [];
|
|
953
|
+
const pintaRoles = (): void => {
|
|
954
|
+
q('#roles').innerHTML = rolesR
|
|
955
|
+
.map(
|
|
956
|
+
(r) => `
|
|
957
|
+
<button class="rol ${r.id === rol ? 'on' : ''}" data-r="${esc(r.id)}">
|
|
958
|
+
<h3>${esc(r.name)}</h3><span class="de">${esc(r.id)} · ${esc(r.trade.toLowerCase())}</span>
|
|
959
|
+
<p>${esc(r.summary)}</p></button>`,
|
|
960
|
+
)
|
|
961
|
+
.join('');
|
|
962
|
+
todos<HTMLButtonElement>('#roles .rol').forEach((b) => {
|
|
963
|
+
b.onclick = () => {
|
|
964
|
+
rol = b.dataset.r ?? '';
|
|
965
|
+
pintaRoles();
|
|
966
|
+
};
|
|
967
|
+
});
|
|
968
|
+
};
|
|
969
|
+
const cargaRoles = async (): Promise<void> => {
|
|
970
|
+
rolesR = (await api<{ roles: RolInfo[] }>('/api/roles?domain=' + encodeURIComponent(domain)))
|
|
971
|
+
.roles;
|
|
972
|
+
if (!rolesR.some((r) => r.id === rol)) rol = '';
|
|
973
|
+
pintaRoles();
|
|
974
|
+
};
|
|
975
|
+
const pintaDominios = (): void => {
|
|
976
|
+
q('#domains').innerHTML = domainsR
|
|
977
|
+
.map(
|
|
978
|
+
(d) => `
|
|
979
|
+
<button class="rol ${d.id === domain ? 'on' : ''}" data-d="${esc(d.id)}">
|
|
980
|
+
<h3>${esc(d.name)}</h3><span class="de">${esc(d.id)}</span>
|
|
981
|
+
<p>${esc(d.summary)}</p></button>`,
|
|
982
|
+
)
|
|
983
|
+
.join('');
|
|
984
|
+
todos<HTMLButtonElement>('#domains .rol').forEach((b) => {
|
|
985
|
+
b.onclick = () => {
|
|
986
|
+
domain = b.dataset.d ?? 'software';
|
|
987
|
+
pintaDominios();
|
|
988
|
+
void cargaRoles();
|
|
989
|
+
};
|
|
990
|
+
});
|
|
991
|
+
};
|
|
992
|
+
pintaDominios();
|
|
993
|
+
await cargaRoles();
|
|
994
|
+
|
|
995
|
+
// folders
|
|
996
|
+
const [reposResponse, agentRolesResponse] = await Promise.all([
|
|
997
|
+
api<{ repos: RepoLocal[] }>('/api/misrepos?user=' + encodeURIComponent(E.yo)),
|
|
998
|
+
api<{ roles: RepoRoleOption[] }>('/api/roles?scope=agent'),
|
|
999
|
+
]);
|
|
1000
|
+
let { repos } = reposResponse;
|
|
1001
|
+
const agentRoleOptions = agentRolesResponse.roles;
|
|
1002
|
+
const sel = new Set<string>(
|
|
1003
|
+
mia && mia.repos.length ? mia.repos : repos.filter((r) => r.mio).map((r) => r.nombre),
|
|
1004
|
+
);
|
|
1005
|
+
const assignedRoles: Record<string, string> = { ...(mia?.repo_roles || {}) };
|
|
1006
|
+
const pintaRolesAgentes = (): void => {
|
|
1007
|
+
q('#agentRoles').innerHTML = renderRepoRoleEditor(sel, assignedRoles, agentRoleOptions, esc);
|
|
1008
|
+
todos<HTMLSelectElement>('#agentRoles [data-repo-role]').forEach((select) => {
|
|
1009
|
+
select.onchange = () => {
|
|
1010
|
+
assignedRoles[select.dataset.repoRole ?? ''] = select.value || 'blank';
|
|
1011
|
+
};
|
|
1012
|
+
});
|
|
1013
|
+
};
|
|
1014
|
+
let filtro = '';
|
|
1015
|
+
const pintaCarpetas = (): void => {
|
|
1016
|
+
const vis = repos.filter((r) => !filtro || r.nombre.toLowerCase().includes(filtro));
|
|
1017
|
+
q('#carpetas').innerHTML = `<div class="lista">
|
|
1018
|
+
<div class="barra">
|
|
1019
|
+
<input class="mono" id="fl" placeholder="filter ${repos.length} repos…" value="${esc(filtro)}">
|
|
1020
|
+
<button class="mini2" id="rescan" title="the index caches for a day — this rebuilds it now">rescan disk</button>
|
|
1021
|
+
<button class="mini2" id="nada">none</button>
|
|
1022
|
+
<span class="de" style="font-family:var(--mono);font-size:11px;color:var(--tinta3)">${sel.size} picked</span>
|
|
1023
|
+
</div>
|
|
1024
|
+
<div class="filas">${
|
|
1025
|
+
vis
|
|
1026
|
+
.map(
|
|
1027
|
+
(r) => `
|
|
1028
|
+
<div class="fila ${sel.has(r.nombre) ? 'on' : ''}" data-n="${esc(r.nombre)}">
|
|
1029
|
+
<span class="caja">${sel.has(r.nombre) ? '[x]' : '[ ]'}</span>
|
|
1030
|
+
<span class="et">${esc(r.nombre)}</span>
|
|
1031
|
+
<span class="de">${esc(r.cuando || '—')} ${esc(corto(r.ruta))}</span></div>`,
|
|
1032
|
+
)
|
|
1033
|
+
.join('') ||
|
|
1034
|
+
'<div class="fila"><span class="de" style="margin:0">nothing matches</span></div>'
|
|
1035
|
+
}
|
|
1036
|
+
</div></div>
|
|
1037
|
+
<p class="pista" style="margin-top:6px;font-size:11.5px;color:var(--tinta3);font-style:italic">
|
|
1038
|
+
Ticked by your commits this year. Owning <b>no</b> folders is a real answer for a
|
|
1039
|
+
cross-cutting role — your city is simply empty, and your work is the roads.</p>`;
|
|
1040
|
+
const fl = q<HTMLInputElement>('#fl');
|
|
1041
|
+
fl.oninput = () => {
|
|
1042
|
+
filtro = fl.value.toLowerCase();
|
|
1043
|
+
pintaCarpetas();
|
|
1044
|
+
const f2 = q<HTMLInputElement>('#fl');
|
|
1045
|
+
f2.focus();
|
|
1046
|
+
f2.setSelectionRange(f2.value.length, f2.value.length);
|
|
1047
|
+
};
|
|
1048
|
+
q<HTMLButtonElement>('#rescan').onclick = async () => {
|
|
1049
|
+
const b = q<HTMLButtonElement>('#rescan');
|
|
1050
|
+
b.textContent = 'scanning…';
|
|
1051
|
+
b.disabled = true;
|
|
1052
|
+
({ repos } = await api<{ repos: RepoLocal[] }>(
|
|
1053
|
+
'/api/misrepos?refresh=1&user=' + encodeURIComponent(E.yo),
|
|
1054
|
+
));
|
|
1055
|
+
pintaCarpetas();
|
|
1056
|
+
toast(`${repos.length} repos on this disk`);
|
|
1057
|
+
};
|
|
1058
|
+
q<HTMLButtonElement>('#nada').onclick = () => {
|
|
1059
|
+
vis.forEach((r) => sel.delete(r.nombre));
|
|
1060
|
+
pintaCarpetas();
|
|
1061
|
+
};
|
|
1062
|
+
todos<HTMLElement>('#carpetas .fila[data-n]').forEach((f) => {
|
|
1063
|
+
f.onclick = () => {
|
|
1064
|
+
const n = f.dataset.n ?? '';
|
|
1065
|
+
if (sel.has(n)) sel.delete(n);
|
|
1066
|
+
else sel.add(n);
|
|
1067
|
+
pintaCarpetas();
|
|
1068
|
+
};
|
|
1069
|
+
});
|
|
1070
|
+
pintaRolesAgentes();
|
|
1071
|
+
};
|
|
1072
|
+
pintaCarpetas();
|
|
1073
|
+
|
|
1074
|
+
// goal
|
|
1075
|
+
const o: Partial<Objetivo> = (mia && mia.objetivo) || {};
|
|
1076
|
+
q('#meta').innerHTML = `
|
|
1077
|
+
<div class="campo"><label>The goal, in one line</label>
|
|
1078
|
+
<input type="text" id="g_title" value="${esc(o.title)}" placeholder="Concrete enough to argue with — empty skips it">
|
|
1079
|
+
</div>
|
|
1080
|
+
<div class="dos">
|
|
1081
|
+
<div class="campo"><label>How it is measured</label>
|
|
1082
|
+
<input type="text" id="g_signal" value="${esc(o.signal)}"></div>
|
|
1083
|
+
<div class="campo"><label>The command that returns it, if a command can</label>
|
|
1084
|
+
<input type="text" class="mono" id="g_command" value="${esc(o.command)}"
|
|
1085
|
+
placeholder="empty for a qualitative goal"></div>
|
|
1086
|
+
</div>
|
|
1087
|
+
<div class="campo"><label>…or who judges it, and how often</label>
|
|
1088
|
+
<input type="text" id="g_manual" value="${esc(o.manual)}"
|
|
1089
|
+
placeholder="in prose: the architect reads the AGENTS.md files on Fridays">
|
|
1090
|
+
<p class="pista">A goal judged by a person is a real goal — plenty of quality
|
|
1091
|
+
is prose, not a number. Used when the command is empty.</p></div>
|
|
1092
|
+
<div class="dos">
|
|
1093
|
+
<div class="campo"><label>What it returns today</label>
|
|
1094
|
+
<input type="text" class="mono" id="g_baseline" value="${esc(o.baseline)}"></div>
|
|
1095
|
+
<div class="campo"><label>Where it has to get to</label>
|
|
1096
|
+
<input type="text" class="mono" id="g_target" value="${esc(o.target)}"></div>
|
|
1097
|
+
</div>
|
|
1098
|
+
<div class="campo" style="max-width:240px"><label>By when</label>
|
|
1099
|
+
<input type="text" id="g_by" value="${esc(o.by ?? 'this quarter')}"></div>`;
|
|
1100
|
+
|
|
1101
|
+
const val = (id: string): string => q<HTMLInputElement>(id).value.trim();
|
|
1102
|
+
q<HTMLButtonElement>('#guardaPuesto').onclick = async () => {
|
|
1103
|
+
if (!rol) {
|
|
1104
|
+
toast('Pick a role — it is the name your seat answers to', true);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
q<HTMLElement>('#puestoEstado').style.display = '';
|
|
1108
|
+
const r = await api<Respuesta>('/api/ficha', {
|
|
1109
|
+
method: 'POST',
|
|
1110
|
+
body: JSON.stringify({
|
|
1111
|
+
user: E.yo,
|
|
1112
|
+
domain,
|
|
1113
|
+
role: rol,
|
|
1114
|
+
repos: [...sel],
|
|
1115
|
+
repo_roles: selectedRepoRoles(sel, assignedRoles),
|
|
1116
|
+
objetivo: {
|
|
1117
|
+
title: val('#g_title'),
|
|
1118
|
+
signal: val('#g_signal'),
|
|
1119
|
+
command: val('#g_command'),
|
|
1120
|
+
manual: val('#g_manual'),
|
|
1121
|
+
baseline: val('#g_baseline'),
|
|
1122
|
+
target: val('#g_target'),
|
|
1123
|
+
by: val('#g_by') || 'this quarter',
|
|
1124
|
+
},
|
|
1125
|
+
}),
|
|
1126
|
+
});
|
|
1127
|
+
await refresca();
|
|
1128
|
+
toast(
|
|
1129
|
+
r.ok ? `Card written — you are ${r.user ?? E.yo}` : (r.error ?? 'It could not write'),
|
|
1130
|
+
!r.ok,
|
|
1131
|
+
);
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ── districts & houses ───────────────────────────────────────────────────────
|
|
1136
|
+
VISTAS.barrios = () => {
|
|
1137
|
+
// Local working copies; nothing touches disk until Save.
|
|
1138
|
+
const unidades: Unidad[] = E.unidades.map((u) => ({ ...u }));
|
|
1139
|
+
const lab = new Set<string>(E.lab);
|
|
1140
|
+
interface Fila {
|
|
1141
|
+
ruta: string;
|
|
1142
|
+
unidad: string;
|
|
1143
|
+
nombre: string;
|
|
1144
|
+
}
|
|
1145
|
+
const porRepo: Record<string, Fila[]> = {};
|
|
1146
|
+
for (const p of E.parcelas) {
|
|
1147
|
+
(porRepo[p.repo] = porRepo[p.repo] ?? []).push({
|
|
1148
|
+
ruta: p.ruta,
|
|
1149
|
+
unidad: p.unidad,
|
|
1150
|
+
nombre: p.nombre,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
const slug = (s: string): string =>
|
|
1154
|
+
s
|
|
1155
|
+
.toLowerCase()
|
|
1156
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1157
|
+
.replace(/^-|-$/g, '')
|
|
1158
|
+
.slice(0, 20);
|
|
1159
|
+
|
|
1160
|
+
const render = (): void => {
|
|
1161
|
+
const idsValidos = new Set([...unidades.map((u) => u.id), 'none']);
|
|
1162
|
+
q('#lienzo').innerHTML = `<div><span class="sub">districts & houses</span>
|
|
1163
|
+
<h1 style="margin-top:6px">The modelling no tool can do for you</h1>
|
|
1164
|
+
<p class="prosa" style="margin-top:8px">A house is <b>not a repo</b> — it is a
|
|
1165
|
+
parcel, a slice of one serving a single business unit. Split the interesting
|
|
1166
|
+
repos, give every house its district, and the map can say
|
|
1167
|
+
“this change touches banking” instead of “this change touches src/lib”.</p></div>
|
|
1168
|
+
|
|
1169
|
+
<div><span class="sub">districts</span>
|
|
1170
|
+
<div class="tabla" id="unidades" style="margin-top:9px"></div>
|
|
1171
|
+
<div style="display:flex;gap:9px;margin-top:9px">
|
|
1172
|
+
<input type="text" id="nuevaU" placeholder="another district…" style="flex:1">
|
|
1173
|
+
<button class="bt" id="masU">Add</button></div></div>
|
|
1174
|
+
|
|
1175
|
+
<div><span class="sub">houses</span><div id="parcelas" style="margin-top:9px"></div></div>
|
|
1176
|
+
|
|
1177
|
+
<div style="display:flex;gap:10px;align-items:center">
|
|
1178
|
+
<button class="bt ppal" id="guardaBarrios">Save districts & houses</button>
|
|
1179
|
+
<span style="font-size:12px;color:var(--tinta3);font-style:italic">
|
|
1180
|
+
writes units.yml and parcels.yml — reseed the map to see it drawn</span></div>`;
|
|
1181
|
+
|
|
1182
|
+
q('#unidades').innerHTML =
|
|
1183
|
+
unidades
|
|
1184
|
+
.map(
|
|
1185
|
+
(u, i) => `
|
|
1186
|
+
<div class="tr">
|
|
1187
|
+
<input type="text" value="${esc(u.name)}" data-i="${i}" style="flex:1">
|
|
1188
|
+
<span class="id">${esc(u.id)}</span>
|
|
1189
|
+
<span class="pips" data-i="${i}">${E.paleta
|
|
1190
|
+
.map(
|
|
1191
|
+
(p) => `
|
|
1192
|
+
<button class="pip ${p.hex === u.color ? 'on' : ''}" data-c="${p.hex}"
|
|
1193
|
+
style="background:#${p.hex}" title="${esc(p.nombre)}"></button>`,
|
|
1194
|
+
)
|
|
1195
|
+
.join('')}</span>
|
|
1196
|
+
<button class="x" data-x="${i}">×</button></div>`,
|
|
1197
|
+
)
|
|
1198
|
+
.join('') ||
|
|
1199
|
+
'<div class="tr" style="color:var(--tinta3);font-size:12.5px">No districts yet — everything sits in “no unit”.</div>';
|
|
1200
|
+
|
|
1201
|
+
q('#parcelas').innerHTML =
|
|
1202
|
+
Object.keys(porRepo)
|
|
1203
|
+
.sort()
|
|
1204
|
+
.map(
|
|
1205
|
+
(repo) => `
|
|
1206
|
+
<div class="repoBloque" data-repo="${esc(repo)}">
|
|
1207
|
+
<div class="repoCab"><span>${esc(repo)}</span>
|
|
1208
|
+
<button class="bt mini" data-split="${esc(repo)}">split</button>
|
|
1209
|
+
<label class="lab"><input type="checkbox" data-lab="${esc(repo)}"
|
|
1210
|
+
${lab.has(repo) ? 'checked' : ''}> lab</label></div>
|
|
1211
|
+
${(porRepo[repo] ?? [])
|
|
1212
|
+
.map(
|
|
1213
|
+
(f, j) => `
|
|
1214
|
+
<div class="parcela">
|
|
1215
|
+
<input type="text" value="${esc(f.nombre)}" data-k="nombre" data-r="${esc(repo)}" data-j="${j}" title="name on the map">
|
|
1216
|
+
<input type="text" class="mono" value="${esc(f.ruta)}" data-k="ruta" data-r="${esc(repo)}" data-j="${j}"
|
|
1217
|
+
placeholder="glob inside the repo — empty = the whole repo">
|
|
1218
|
+
<select data-k="unidad" data-r="${esc(repo)}" data-j="${j}">
|
|
1219
|
+
${[...unidades.map((u) => u.id), 'none']
|
|
1220
|
+
.map(
|
|
1221
|
+
(id) => `
|
|
1222
|
+
<option value="${esc(id)}" ${(idsValidos.has(f.unidad) ? f.unidad : 'none') === id ? 'selected' : ''}>${esc(id)}</option>`,
|
|
1223
|
+
)
|
|
1224
|
+
.join('')}
|
|
1225
|
+
</select>
|
|
1226
|
+
${
|
|
1227
|
+
(porRepo[repo] ?? []).length > 1
|
|
1228
|
+
? `<button class="x" data-quita="${esc(repo)}" data-j="${j}">×</button>`
|
|
1229
|
+
: '<span></span>'
|
|
1230
|
+
}
|
|
1231
|
+
</div>`,
|
|
1232
|
+
)
|
|
1233
|
+
.join('')}
|
|
1234
|
+
</div>`,
|
|
1235
|
+
)
|
|
1236
|
+
.join('') ||
|
|
1237
|
+
'<p class="prosa">No houses yet. Pick your folders in <b>My seat</b> and each becomes one.</p>';
|
|
1238
|
+
|
|
1239
|
+
// wiring
|
|
1240
|
+
todos<HTMLInputElement>('#unidades input[data-i]').forEach((inp) => {
|
|
1241
|
+
inp.oninput = () => {
|
|
1242
|
+
const u = unidades[Number(inp.dataset.i)];
|
|
1243
|
+
if (!u) return;
|
|
1244
|
+
u.name = inp.value;
|
|
1245
|
+
u.id = slug(inp.value) || u.id;
|
|
1246
|
+
const idEl = inp.closest('.tr')?.querySelector('.id');
|
|
1247
|
+
if (idEl) idEl.textContent = u.id;
|
|
1248
|
+
};
|
|
1249
|
+
});
|
|
1250
|
+
todos<HTMLButtonElement>('#unidades .pip').forEach((b) => {
|
|
1251
|
+
b.onclick = () => {
|
|
1252
|
+
const grupo = b.closest<HTMLElement>('.pips');
|
|
1253
|
+
const u = unidades[Number(grupo?.dataset.i)];
|
|
1254
|
+
if (u) {
|
|
1255
|
+
u.color = b.dataset.c ?? u.color;
|
|
1256
|
+
render();
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
});
|
|
1260
|
+
todos<HTMLButtonElement>('#unidades .x[data-x]').forEach((b) => {
|
|
1261
|
+
b.onclick = () => {
|
|
1262
|
+
unidades.splice(Number(b.dataset.x), 1);
|
|
1263
|
+
render();
|
|
1264
|
+
};
|
|
1265
|
+
});
|
|
1266
|
+
q<HTMLButtonElement>('#masU').onclick = () => {
|
|
1267
|
+
const n = q<HTMLInputElement>('#nuevaU').value.trim();
|
|
1268
|
+
if (!n) return;
|
|
1269
|
+
const usados = unidades.map((u) => u.color);
|
|
1270
|
+
const libre = E.paleta.find((p) => !usados.includes(p.hex)) ?? E.paleta[0];
|
|
1271
|
+
unidades.push({ id: slug(n), name: n, color: libre ? libre.hex : 'c8b48a' });
|
|
1272
|
+
render();
|
|
1273
|
+
};
|
|
1274
|
+
q<HTMLInputElement>('#nuevaU').onkeydown = (e) => {
|
|
1275
|
+
if (e.key === 'Enter') q<HTMLButtonElement>('#masU').click();
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
todos<HTMLInputElement | HTMLSelectElement>('#parcelas [data-k]').forEach((el) => {
|
|
1279
|
+
el.onchange = () => {
|
|
1280
|
+
const fila = porRepo[el.dataset.r ?? '']?.[Number(el.dataset.j)];
|
|
1281
|
+
if (!fila) return;
|
|
1282
|
+
const k = el.dataset.k as keyof Fila;
|
|
1283
|
+
fila[k] = el.value;
|
|
1284
|
+
};
|
|
1285
|
+
});
|
|
1286
|
+
todos<HTMLButtonElement>('#parcelas [data-split]').forEach((b) => {
|
|
1287
|
+
b.onclick = () => {
|
|
1288
|
+
const r = b.dataset.split ?? '';
|
|
1289
|
+
(porRepo[r] = porRepo[r] ?? []).push({
|
|
1290
|
+
ruta: '',
|
|
1291
|
+
unidad: 'none',
|
|
1292
|
+
nombre: `${r} · new slice`,
|
|
1293
|
+
});
|
|
1294
|
+
render();
|
|
1295
|
+
};
|
|
1296
|
+
});
|
|
1297
|
+
todos<HTMLButtonElement>('#parcelas [data-quita]').forEach((b) => {
|
|
1298
|
+
b.onclick = () => {
|
|
1299
|
+
porRepo[b.dataset.quita ?? '']?.splice(Number(b.dataset.j), 1);
|
|
1300
|
+
render();
|
|
1301
|
+
};
|
|
1302
|
+
});
|
|
1303
|
+
todos<HTMLInputElement>('#parcelas [data-lab]').forEach((c) => {
|
|
1304
|
+
c.onchange = () => {
|
|
1305
|
+
const r = c.dataset.lab ?? '';
|
|
1306
|
+
if (c.checked) lab.add(r);
|
|
1307
|
+
else lab.delete(r);
|
|
1308
|
+
};
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
q<HTMLButtonElement>('#guardaBarrios').onclick = async () => {
|
|
1312
|
+
const r1 = await api<Respuesta>('/api/unidades', {
|
|
1313
|
+
method: 'POST',
|
|
1314
|
+
body: JSON.stringify({ unidades }),
|
|
1315
|
+
});
|
|
1316
|
+
const r2 = await api<Respuesta>('/api/parcelas', {
|
|
1317
|
+
method: 'POST',
|
|
1318
|
+
body: JSON.stringify({ repos: porRepo, lab: [...lab] }),
|
|
1319
|
+
});
|
|
1320
|
+
await refresca();
|
|
1321
|
+
const mal = r1.error ?? r2.error;
|
|
1322
|
+
toast(mal ?? 'Districts and houses written', !!mal);
|
|
1323
|
+
};
|
|
1324
|
+
};
|
|
1325
|
+
render();
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
// ── roads ────────────────────────────────────────────────────────────────────
|
|
1329
|
+
VISTAS.red = () => {
|
|
1330
|
+
const conectadas = new Set(E.roads.map((r) => r.id));
|
|
1331
|
+
const locales = E.ciudades.filter((c) => !c.actual && c.id && !conectadas.has(c.id));
|
|
1332
|
+
const invitation = JSON.stringify(E.invitation);
|
|
1333
|
+
q('#lienzo').innerHTML = `<div><span class="sub">roads</span>
|
|
1334
|
+
<h1 style="margin-top:6px">Cities this one may reach</h1>
|
|
1335
|
+
<p class="prosa" style="margin-top:8px">A road joins city seats. It may stay on
|
|
1336
|
+
this machine or continue over the remote bus; the city sees one explicit
|
|
1337
|
+
connection either way.</p></div>
|
|
1338
|
+
<div class="gente">${
|
|
1339
|
+
E.roads
|
|
1340
|
+
.map(
|
|
1341
|
+
(r) => `<div class="persona"><h3>${esc(r.name)}</h3>
|
|
1342
|
+
<span class="ag">${esc(r.address)}</span>
|
|
1343
|
+
<span class="rp">${r.local ? 'local road' : 'remote road'}</span>
|
|
1344
|
+
<button class="bt mini" data-road-close="${esc(r.id)}">close</button></div>`,
|
|
1345
|
+
)
|
|
1346
|
+
.join('') || '<p class="prosa">No roads yet. This city is isolated on purpose.</p>'
|
|
1347
|
+
}</div>
|
|
1348
|
+
<div><span class="sub">other cities on this machine</span>
|
|
1349
|
+
<div class="gente" style="margin-top:9px">${
|
|
1350
|
+
locales
|
|
1351
|
+
.map(
|
|
1352
|
+
(c) => `<div class="persona"><h3>${esc(c.nombre)}</h3>
|
|
1353
|
+
<span class="ag">${esc(c.slug ?? c.ruta)}</span>
|
|
1354
|
+
<button class="bt mini ppal" data-road-open="${esc(c.id)}">open road</button></div>`,
|
|
1355
|
+
)
|
|
1356
|
+
.join('') || '<p class="prosa">No unconnected local cities.</p>'
|
|
1357
|
+
}</div></div>
|
|
1358
|
+
<div class="orden"><span class="et2">remote invitation · public, no token</span>
|
|
1359
|
+
<code>${esc(invitation)}</code>
|
|
1360
|
+
<button class="bt mini" data-copia="${esc(invitation)}">copy</button>
|
|
1361
|
+
</div>`;
|
|
1362
|
+
enlaza();
|
|
1363
|
+
todos<HTMLButtonElement>('[data-road-open]').forEach((b) => {
|
|
1364
|
+
b.onclick = async () => {
|
|
1365
|
+
const r = await api<Respuesta>('/api/roads', {
|
|
1366
|
+
method: 'POST',
|
|
1367
|
+
body: JSON.stringify({ action: 'connect', target: b.dataset.roadOpen }),
|
|
1368
|
+
});
|
|
1369
|
+
if (r.error) return toast(r.error, true);
|
|
1370
|
+
await refresca();
|
|
1371
|
+
toast('Road open at both local cities');
|
|
1372
|
+
};
|
|
1373
|
+
});
|
|
1374
|
+
todos<HTMLButtonElement>('[data-road-close]').forEach((b) => {
|
|
1375
|
+
b.onclick = async () => {
|
|
1376
|
+
const r = await api<Respuesta>('/api/roads', {
|
|
1377
|
+
method: 'POST',
|
|
1378
|
+
body: JSON.stringify({ action: 'disconnect', target: b.dataset.roadClose }),
|
|
1379
|
+
});
|
|
1380
|
+
if (r.error) return toast(r.error, true);
|
|
1381
|
+
await refresca();
|
|
1382
|
+
toast('Road closed');
|
|
1383
|
+
};
|
|
1384
|
+
});
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// ── committee acts ───────────────────────────────────────────────────────────
|
|
1388
|
+
VISTAS.committee = () => {
|
|
1389
|
+
q('#lienzo').innerHTML = `<div><span class="sub">committee</span>
|
|
1390
|
+
<h1 style="margin-top:6px">Decisions with a visible chain of custody</h1>
|
|
1391
|
+
<p class="prosa" style="margin-top:8px">The seat selects relevant repo agents,
|
|
1392
|
+
gathers isolated positions, controls the floor, decides and assigns an
|
|
1393
|
+
independent verification. This view is read-only; the vendor-neutral CLI
|
|
1394
|
+
drives the protocol.</p></div>
|
|
1395
|
+
<div class="orden"><span class="et2">start</span>
|
|
1396
|
+
<code>agents-city committee schema open</code>
|
|
1397
|
+
<button class="bt mini" data-copia="agents-city committee schema open">copy</button>
|
|
1398
|
+
</div>
|
|
1399
|
+
<div class="gente">${
|
|
1400
|
+
E.deliberations
|
|
1401
|
+
.map(
|
|
1402
|
+
(act) => `<div class="persona">
|
|
1403
|
+
<h3>${esc(act.question)}</h3>
|
|
1404
|
+
<span class="ag">${esc(act.status)} · revision ${act.revision} ·
|
|
1405
|
+
${act.received}/${act.total} positions</span>
|
|
1406
|
+
<span class="rp">${esc(act.decision || act.desired_outcome)}</span>
|
|
1407
|
+
<span class="de">${esc(act.participants.join(', ') || 'no participants')}
|
|
1408
|
+
${act.contributors.length ? ` · decisive ${esc(act.contributors.join(', '))}` : ''}
|
|
1409
|
+
${act.verifier ? ` · verifier ${esc(act.verifier)} ${esc(act.verification)}` : ''}</span>
|
|
1410
|
+
<code class="mono">${esc(corto(act.act))}</code>
|
|
1411
|
+
</div>`,
|
|
1412
|
+
)
|
|
1413
|
+
.join('') ||
|
|
1414
|
+
'<p class="prosa">No committee acts yet. Open one only when the seat needs specialised evidence.</p>'
|
|
1415
|
+
}</div>`;
|
|
1416
|
+
enlaza();
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
// ── agents and skills ────────────────────────────────────────────────────────
|
|
1420
|
+
/** The engine's honest power reading. An alias maps to its tier; empty means
|
|
1421
|
+
* the owner's Claude default — unknown here, so the bar says "default" rather
|
|
1422
|
+
* than inventing a number. */
|
|
1423
|
+
function nivelDeMotor(model: string): { ancho: number; texto: string; defecto: boolean } {
|
|
1424
|
+
const alias = model.toLowerCase();
|
|
1425
|
+
if (!alias) return { ancho: 0.5, texto: 'default', defecto: true };
|
|
1426
|
+
for (const [clave, ancho] of [
|
|
1427
|
+
['fable', 1],
|
|
1428
|
+
['opus', 0.8],
|
|
1429
|
+
['sonnet', 0.55],
|
|
1430
|
+
['haiku', 0.35],
|
|
1431
|
+
] as const) {
|
|
1432
|
+
if (alias.includes(clave)) return { ancho, texto: alias, defecto: false };
|
|
1433
|
+
}
|
|
1434
|
+
return { ancho: 0.6, texto: alias, defecto: false };
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const NIVEL_ESFUERZO: Record<string, number> = { low: 1, medium: 2, high: 3, xhigh: 4, max: 5 };
|
|
1438
|
+
const MODELOS = ['haiku', 'sonnet', 'opus', 'fable'];
|
|
1439
|
+
|
|
1440
|
+
/** `<option>`s from known values plus the current one when it is off the list:
|
|
1441
|
+
* a card key must never be held hostage by a curated dropdown. */
|
|
1442
|
+
function opciones(valores: string[], actual: string): string {
|
|
1443
|
+
return valores
|
|
1444
|
+
.concat(actual && !valores.includes(actual) ? [actual] : [])
|
|
1445
|
+
.map(
|
|
1446
|
+
(v) =>
|
|
1447
|
+
`<option value="${esc(v)}"${v === actual ? ' selected' : ''}>${esc(v || 'default')}</option>`,
|
|
1448
|
+
)
|
|
1449
|
+
.join('');
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** The one sheet-avatar guard: a data URI or nothing. */
|
|
1453
|
+
function caraRPG(uri?: string): string {
|
|
1454
|
+
return uri?.startsWith('data:image/svg+xml;base64,')
|
|
1455
|
+
? `<img class="rpgCara" src="${esc(uri)}" alt="">`
|
|
1456
|
+
: '';
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function barra(ancho: number, clase = ''): string {
|
|
1460
|
+
const pct = Math.round(Math.max(0, Math.min(1, ancho)) * 100);
|
|
1461
|
+
return `<span class="barra${clase ? ' ' + clase : ''}"><span class="llena" style="width:${pct}%"></span></span>`;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
VISTAS.gente = () => {
|
|
1465
|
+
const mia = E.tarjetas.find((t) => t.user === E.yo);
|
|
1466
|
+
const agentes = E.agents ?? [];
|
|
1467
|
+
|
|
1468
|
+
const fichaDe = (a: FichaAgente) => {
|
|
1469
|
+
const motor = nivelDeMotor(a.model);
|
|
1470
|
+
const esfuerzo = NIVEL_ESFUERZO[a.effort] ?? 0;
|
|
1471
|
+
const g = a.growth;
|
|
1472
|
+
const crecido = Math.min(1, Math.log2(g.floors + 1) / 8);
|
|
1473
|
+
const skills = E.skills[a.name]?.skills ?? [];
|
|
1474
|
+
const esClaude = a.runtime === 'claude';
|
|
1475
|
+
// "custom…" keeps the curated lineup from ever holding the card hostage:
|
|
1476
|
+
// the alias is resolved by the CLI at launch, not by this list.
|
|
1477
|
+
const opcionesModelo =
|
|
1478
|
+
opciones([''].concat(MODELOS), a.model) + '<option value="__otro__">custom…</option>';
|
|
1479
|
+
const opcionesEsfuerzo = opciones([''].concat(Object.keys(NIVEL_ESFUERZO)), a.effort);
|
|
1480
|
+
const opcionesRuntime = opciones(['claude', 'codex', 'opencode', 'kimi'], a.runtime);
|
|
1481
|
+
return `
|
|
1482
|
+
<div class="fichaRPG">
|
|
1483
|
+
<div class="rpgCab">${caraRPG(a.avatar)}
|
|
1484
|
+
<div><h3>${esc(a.name)}</h3>
|
|
1485
|
+
<span class="rpgTags"><span class="kindChip ${esc(a.kind)}">${esc(a.kind)}</span>
|
|
1486
|
+
<span class="rpgRol">${esc(a.role)}</span>
|
|
1487
|
+
<span class="rpgHogar">${a.legacy ? 'repo' : `workspace · ${a.mounts | 0} ${a.mounts === 1 ? 'mount' : 'mounts'}`}</span></span></div>
|
|
1488
|
+
<span class="rpgBotones">
|
|
1489
|
+
<button class="rpgMini rpgDado" type="button" data-agente="${esc(a.slug)}"
|
|
1490
|
+
title="Reroll this agent's face — deterministic, persisted on the card">🎲</button>
|
|
1491
|
+
<button class="rpgMini rpgIns" type="button" data-agente="${esc(a.slug)}"
|
|
1492
|
+
data-file="CLAUDE.md" title="Instructions the Claude runtime reads">CLAUDE.md</button>
|
|
1493
|
+
<button class="rpgMini rpgIns" type="button" data-agente="${esc(a.slug)}"
|
|
1494
|
+
data-file="AGENTS.md" title="Instructions Codex, OpenCode and Kimi read">AGENTS.md</button>
|
|
1495
|
+
</span>
|
|
1496
|
+
</div>
|
|
1497
|
+
<div class="rpgFila"><label>engine</label>
|
|
1498
|
+
${barra(motor.ancho, motor.defecto ? 'defecto' : '')}
|
|
1499
|
+
<select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="model"
|
|
1500
|
+
${esClaude ? '' : 'disabled title="This window runs its own CLI; its model lives in its own flags"'}>
|
|
1501
|
+
${opcionesModelo}</select></div>
|
|
1502
|
+
<div class="rpgFila"><label>effort</label>
|
|
1503
|
+
${barra(esfuerzo / 5, esfuerzo ? '' : 'defecto')}
|
|
1504
|
+
<select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="effort"
|
|
1505
|
+
${esClaude ? '' : 'disabled title="This window runs its own CLI; its effort lives in its own flags"'}>
|
|
1506
|
+
${opcionesEsfuerzo}</select></div>
|
|
1507
|
+
<div class="rpgFila"><label>provider</label>
|
|
1508
|
+
<span class="rpgDato"><span class="cliDot ${
|
|
1509
|
+
a.cli.connected ? 'on' : a.cli.installed ? 'idle' : 'off'
|
|
1510
|
+
}" title="${esc(
|
|
1511
|
+
a.cli.connected
|
|
1512
|
+
? 'connected: this agent’s window is alive right now'
|
|
1513
|
+
: a.cli.installed
|
|
1514
|
+
? 'installed on this machine, not in use by this agent'
|
|
1515
|
+
: `${a.cli.binary} is not installed`,
|
|
1516
|
+
)}"></span>${esc(
|
|
1517
|
+
a.cli.connected ? 'connected' : a.cli.installed ? 'idle' : 'missing',
|
|
1518
|
+
)} · ${esc(a.cli.binary)}
|
|
1519
|
+
<button class="rpgMini rpgTest" type="button" data-agente="${esc(a.slug)}"
|
|
1520
|
+
title="Run the engine for real: --version, and the login state on Claude">test</button></span>
|
|
1521
|
+
<select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="runtime">
|
|
1522
|
+
${opcionesRuntime}</select></div>
|
|
1523
|
+
<div class="rpgFila"><label>growth</label>
|
|
1524
|
+
${barra(crecido)}
|
|
1525
|
+
<span class="rpgDato">${g.floors | 0} floors · ${g.bricks | 0} bricks · ${g.activity30 | 0}/30d</span></div>
|
|
1526
|
+
<div class="rpgSkills"><label>skills · ${skills.length}</label>
|
|
1527
|
+
${
|
|
1528
|
+
skills.length
|
|
1529
|
+
? skills
|
|
1530
|
+
.map(
|
|
1531
|
+
(s) =>
|
|
1532
|
+
`<code class="mono" title="${esc(s.description)}">${esc(s.name)}${
|
|
1533
|
+
s.removable && s.dir
|
|
1534
|
+
? `<button class="rpgQuitar" type="button" data-agente="${esc(a.slug)}"
|
|
1535
|
+
data-skill="${esc(s.dir)}" title="Remove this skill from the agent's home">×</button>`
|
|
1536
|
+
: ''
|
|
1537
|
+
}</code>`,
|
|
1538
|
+
)
|
|
1539
|
+
.join(' ')
|
|
1540
|
+
: '<span class="rpgDato">none discovered</span>'
|
|
1541
|
+
}
|
|
1542
|
+
<label class="rpgMini rpgSubir"
|
|
1543
|
+
title="Install a skill zip into this agent's own home — the Claude runtime reads skills; other engines ignore them">
|
|
1544
|
+
+ zip<input type="file" accept=".zip" data-agente="${esc(a.slug)}"></label></div>
|
|
1545
|
+
</div>`;
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
q('#lienzo').innerHTML = `<div><span class="sub">agents & skills</span>
|
|
1549
|
+
<h1 style="margin-top:6px">One seat, support agents behind it</h1>
|
|
1550
|
+
<p class="prosa" style="margin-top:8px">Every number on a card is real: the
|
|
1551
|
+
engine comes from the card keys the launcher resolves, growth from what the
|
|
1552
|
+
agent actually produced, skills from live read-only recognition — Agents
|
|
1553
|
+
City copies and installs nothing. Engine changes persist to the card and
|
|
1554
|
+
apply the next time the session opens.</p></div>
|
|
1555
|
+
<div class="fichasRPG">
|
|
1556
|
+
<div class="fichaRPG rpgSeat">
|
|
1557
|
+
<div class="rpgCab">${caraRPG(E.avatars?.['seat'])}
|
|
1558
|
+
<div><h3>seat</h3>
|
|
1559
|
+
<span class="rpgTags"><span class="kindChip coordinator">chair</span>
|
|
1560
|
+
<span class="rpgRol">${esc(mia?.role ?? 'not configured')}</span></span></div>
|
|
1561
|
+
</div>
|
|
1562
|
+
<div class="rpgFila"><span class="rpgDato">${esc(E.address)}</span></div>
|
|
1563
|
+
<div class="rpgFila"><span class="rpgDato">${esc(mia?.objetivo?.title ?? 'No goal yet')}</span></div>
|
|
1564
|
+
</div>
|
|
1565
|
+
${agentes.map(fichaDe).join('')}
|
|
1566
|
+
</div>`;
|
|
1567
|
+
|
|
1568
|
+
// Engine changes: one POST, the sheet re-renders from the server's answer.
|
|
1569
|
+
// One save round-trip for every sheet edit: POST, refresh the sheet from the
|
|
1570
|
+
// server's answer, keep the roster's face in sync, repaint.
|
|
1571
|
+
const guardaAgente = async (carga: Record<string, string>, mensaje: string) => {
|
|
1572
|
+
let r: { ok?: boolean; agent?: FichaAgente; error?: string };
|
|
1573
|
+
try {
|
|
1574
|
+
r = await api('/api/agente', { method: 'POST', body: JSON.stringify(carga) });
|
|
1575
|
+
} catch (e) {
|
|
1576
|
+
toast(String(e), true);
|
|
1577
|
+
pinta(); // the failed edit must not stay on screen looking saved
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
if (!r.ok || !r.agent) {
|
|
1581
|
+
toast(r.error || 'Could not save', true);
|
|
1582
|
+
// Repaint from the untouched state: a select left showing "custom…"
|
|
1583
|
+
// never fires another change event for the same choice.
|
|
1584
|
+
pinta();
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
E.agents = (E.agents ?? []).map((x) => (x.slug === r.agent!.slug ? r.agent! : x));
|
|
1588
|
+
if (E.avatars) E.avatars[r.agent.name] = r.agent.avatar;
|
|
1589
|
+
toast(mensaje);
|
|
1590
|
+
pinta();
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
todos<HTMLSelectElement>('.rpgSel').forEach((sel) => {
|
|
1594
|
+
sel.onchange = async () => {
|
|
1595
|
+
let valor = sel.value;
|
|
1596
|
+
if (valor === '__otro__') {
|
|
1597
|
+
// The CLI resolves any alias at launch; the prompt just has to be an
|
|
1598
|
+
// alias shape, and the server refuses anything that is not.
|
|
1599
|
+
const propio = window.prompt('Model alias (resolved by the CLI at launch):', '');
|
|
1600
|
+
if (propio === null) {
|
|
1601
|
+
pinta();
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
valor = propio.trim().toLowerCase();
|
|
1605
|
+
}
|
|
1606
|
+
await guardaAgente(
|
|
1607
|
+
{ agent: sel.dataset.agente ?? '', [sel.dataset.campo ?? 'model']: valor },
|
|
1608
|
+
'Saved — applies next session',
|
|
1609
|
+
);
|
|
1610
|
+
};
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
// The dice: one reroll, one new deterministic look, persisted on the card.
|
|
1614
|
+
todos<HTMLButtonElement>('.rpgDado').forEach((boton) => {
|
|
1615
|
+
boton.onclick = () =>
|
|
1616
|
+
guardaAgente(
|
|
1617
|
+
{ agent: boton.dataset.agente ?? '', avatar: Math.random().toString(36).slice(2, 8) },
|
|
1618
|
+
'New look — same identity, everywhere, forever',
|
|
1619
|
+
);
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
// The test button: the engine runs for real and answers for itself.
|
|
1623
|
+
todos<HTMLButtonElement>('.rpgTest').forEach((boton) => {
|
|
1624
|
+
boton.onclick = async () => {
|
|
1625
|
+
boton.disabled = true;
|
|
1626
|
+
try {
|
|
1627
|
+
const r = await api<{
|
|
1628
|
+
ok?: boolean;
|
|
1629
|
+
binary?: string;
|
|
1630
|
+
version?: string;
|
|
1631
|
+
detail?: string;
|
|
1632
|
+
error?: string;
|
|
1633
|
+
}>('/api/motor', {
|
|
1634
|
+
method: 'POST',
|
|
1635
|
+
body: JSON.stringify({ agent: boton.dataset.agente ?? '' }),
|
|
1636
|
+
});
|
|
1637
|
+
if (r.error) {
|
|
1638
|
+
toast(r.error, true);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
const partes = [r.version, r.detail].filter(Boolean).join(' · ');
|
|
1642
|
+
toast(
|
|
1643
|
+
r.ok ? `${r.binary} works — ${partes}` : `${r.binary}: ${r.detail || 'failed'}`,
|
|
1644
|
+
!r.ok,
|
|
1645
|
+
);
|
|
1646
|
+
} catch (e) {
|
|
1647
|
+
toast(String(e), true);
|
|
1648
|
+
} finally {
|
|
1649
|
+
// Not on the success line: a dropped request must not leave the
|
|
1650
|
+
// button greyed out until the whole view re-renders.
|
|
1651
|
+
boton.disabled = false;
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
// Removing a skill: only ones living in the agent's own `.claude/skills/` —
|
|
1657
|
+
// a skill committed elsewhere in a repo is the repo's property, no × shown.
|
|
1658
|
+
todos<HTMLButtonElement>('.rpgQuitar').forEach((boton) => {
|
|
1659
|
+
boton.onclick = async () => {
|
|
1660
|
+
const nombre = boton.dataset.skill ?? '';
|
|
1661
|
+
if (!window.confirm(`Remove the skill ${nombre} from this agent's home?`)) return;
|
|
1662
|
+
const r = await api<{ ok?: boolean; error?: string }>('/api/skill', {
|
|
1663
|
+
method: 'POST',
|
|
1664
|
+
body: JSON.stringify({ agent: boton.dataset.agente ?? '', remove: nombre }),
|
|
1665
|
+
});
|
|
1666
|
+
if (!r.ok) {
|
|
1667
|
+
toast(r.error || 'Could not remove', true);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
toast(`Skill ${nombre} removed`);
|
|
1671
|
+
void refresca();
|
|
1672
|
+
};
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
// The instruction editors: one click, the modal opens on that agent's file.
|
|
1676
|
+
todos<HTMLButtonElement>('.rpgIns').forEach((boton) => {
|
|
1677
|
+
boton.onclick = () =>
|
|
1678
|
+
void abreInstrucciones(boton.dataset.agente ?? '', boton.dataset.file ?? '');
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
// Installing a skill: the picked zip travels as base64, like claude.ai's own
|
|
1682
|
+
// upload. The name only matters when the zip has no single top folder.
|
|
1683
|
+
todos<HTMLInputElement>('.rpgSubir input').forEach((entrada) => {
|
|
1684
|
+
entrada.onchange = async () => {
|
|
1685
|
+
const archivo = entrada.files?.[0];
|
|
1686
|
+
if (!archivo) return;
|
|
1687
|
+
entrada.disabled = true;
|
|
1688
|
+
try {
|
|
1689
|
+
const zip = await aBase64(archivo);
|
|
1690
|
+
const nombre = archivo.name
|
|
1691
|
+
.replace(/\.zip$/i, '')
|
|
1692
|
+
.toLowerCase()
|
|
1693
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
1694
|
+
.replace(/^-+|-+$/g, '');
|
|
1695
|
+
const r = await api<{ ok?: boolean; skill?: string; error?: string }>('/api/skill', {
|
|
1696
|
+
method: 'POST',
|
|
1697
|
+
body: JSON.stringify({ agent: entrada.dataset.agente ?? '', name: nombre, zip }),
|
|
1698
|
+
});
|
|
1699
|
+
if (!r.ok) {
|
|
1700
|
+
toast(r.error || 'Could not install', true);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
toast(`Skill ${r.skill} installed — the Claude runtime reads it next session`);
|
|
1704
|
+
void refresca();
|
|
1705
|
+
} catch (e) {
|
|
1706
|
+
toast(String(e), true);
|
|
1707
|
+
} finally {
|
|
1708
|
+
entrada.disabled = false;
|
|
1709
|
+
entrada.value = '';
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
});
|
|
1713
|
+
};
|
|
1714
|
+
|
|
1715
|
+
function aBase64(archivo: File): Promise<string> {
|
|
1716
|
+
// The browser's own encoder, off a data URL: native and allocation-friendly,
|
|
1717
|
+
// where hand-chunked String.fromCharCode reallocated megabytes on the way.
|
|
1718
|
+
return new Promise((listo, falla) => {
|
|
1719
|
+
const lector = new FileReader();
|
|
1720
|
+
lector.onerror = () => falla(lector.error);
|
|
1721
|
+
lector.onload = () => listo(String(lector.result).split(',', 2)[1] ?? '');
|
|
1722
|
+
lector.readAsDataURL(archivo);
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/** The instruction editor: one agent, one file, one honest label saying which
|
|
1727
|
+
* engine actually reads it. Saved atomically into the agent's own home. */
|
|
1728
|
+
let editorAbierto: { agent: string; file: string } | null = null;
|
|
1729
|
+
|
|
1730
|
+
async function abreInstrucciones(agente: string, archivo: string): Promise<void> {
|
|
1731
|
+
const r = await api<{
|
|
1732
|
+
exists?: boolean;
|
|
1733
|
+
content?: string;
|
|
1734
|
+
home?: string;
|
|
1735
|
+
reader?: string;
|
|
1736
|
+
error?: string;
|
|
1737
|
+
}>(`/api/instrucciones?agent=${encodeURIComponent(agente)}&file=${encodeURIComponent(archivo)}`);
|
|
1738
|
+
if (r.error) {
|
|
1739
|
+
toast(r.error, true);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
editorAbierto = { agent: agente, file: archivo };
|
|
1743
|
+
q<HTMLElement>('#edTitulo').textContent = `${agente} · ${archivo}`;
|
|
1744
|
+
q<HTMLElement>('#edLector').textContent =
|
|
1745
|
+
`read by ${r.reader ?? '?'}${r.exists ? '' : ' · new file'}`;
|
|
1746
|
+
q<HTMLElement>('#edRuta').textContent = r.home ? `${r.home}/${archivo}` : '';
|
|
1747
|
+
q<HTMLTextAreaElement>('#edTexto').value = r.content ?? '';
|
|
1748
|
+
q<HTMLElement>('#editorIns').hidden = false;
|
|
1749
|
+
q<HTMLTextAreaElement>('#edTexto').focus();
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
document.getElementById('edCerrar')?.addEventListener('click', () => {
|
|
1753
|
+
q<HTMLElement>('#editorIns').hidden = true;
|
|
1754
|
+
editorAbierto = null;
|
|
1755
|
+
});
|
|
1756
|
+
document.getElementById('edGuardar')?.addEventListener('click', async () => {
|
|
1757
|
+
if (!editorAbierto) return;
|
|
1758
|
+
const r = await api<{ ok?: boolean; error?: string }>('/api/instrucciones', {
|
|
1759
|
+
method: 'POST',
|
|
1760
|
+
body: JSON.stringify({ ...editorAbierto, content: q<HTMLTextAreaElement>('#edTexto').value }),
|
|
1761
|
+
});
|
|
1762
|
+
if (!r.ok) {
|
|
1763
|
+
toast(r.error || 'Could not save', true);
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
toast('Saved');
|
|
1767
|
+
q<HTMLElement>('#editorIns').hidden = true;
|
|
1768
|
+
editorAbierto = null;
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
// ── shared ──────────────────────────────────────────────────────────────────
|
|
1772
|
+
function enlaza(): void {
|
|
1773
|
+
todos<HTMLButtonElement>('[data-copia]').forEach((b) => {
|
|
1774
|
+
b.onclick = async () => {
|
|
1775
|
+
try {
|
|
1776
|
+
await navigator.clipboard.writeText(b.dataset.copia ?? '');
|
|
1777
|
+
toast('Copied');
|
|
1778
|
+
} catch {
|
|
1779
|
+
toast('Could not copy — select it by hand', true);
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1782
|
+
});
|
|
1783
|
+
todos<HTMLButtonElement>('[data-ir]').forEach((b) => {
|
|
1784
|
+
b.onclick = () => {
|
|
1785
|
+
SECCION = b.dataset.ir ?? 'mapa';
|
|
1786
|
+
pinta();
|
|
1787
|
+
};
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* The divider between the map and the live rail: grab it and the rail follows
|
|
1793
|
+
* the mouse. The width persists per browser — a rail you resized yesterday is
|
|
1794
|
+
* still that size today — and a double-click gives the default back. Dragging
|
|
1795
|
+
* disables the map iframe's pointer events for the duration, or the iframe
|
|
1796
|
+
* swallows the drag the moment the cursor crosses it.
|
|
1797
|
+
*/
|
|
1798
|
+
function arrastreDelRail(): void {
|
|
1799
|
+
const app = document.getElementById('app');
|
|
1800
|
+
const asa = document.getElementById('liveResize');
|
|
1801
|
+
const rail = document.getElementById('livePanel');
|
|
1802
|
+
if (!app || !asa || !rail) return;
|
|
1803
|
+
const CLAVE = 'hall-live-width';
|
|
1804
|
+
const aplica = (px: number | null) => {
|
|
1805
|
+
if (px === null) app.style.removeProperty('--live-ancho');
|
|
1806
|
+
else app.style.setProperty('--live-ancho', `${Math.round(px)}px`);
|
|
1807
|
+
};
|
|
1808
|
+
// Never narrower than the cards stay readable, never wider than the map
|
|
1809
|
+
// stops being a map.
|
|
1810
|
+
const tope = (px: number) => Math.max(340, Math.min(px, Math.max(420, window.innerWidth * 0.6)));
|
|
1811
|
+
try {
|
|
1812
|
+
const guardado = Number(localStorage.getItem(CLAVE));
|
|
1813
|
+
if (guardado > 0) aplica(tope(guardado));
|
|
1814
|
+
} catch {
|
|
1815
|
+
// A browser with no storage gets the default width, which is fine.
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
asa.addEventListener('pointerdown', (e: PointerEvent) => {
|
|
1819
|
+
e.preventDefault();
|
|
1820
|
+
const desdeX = e.clientX;
|
|
1821
|
+
const desdeAncho = rail.getBoundingClientRect().width;
|
|
1822
|
+
app.classList.add('liveResizing');
|
|
1823
|
+
asa.setPointerCapture(e.pointerId);
|
|
1824
|
+
const mueve = (ev: PointerEvent) => aplica(tope(desdeAncho + (desdeX - ev.clientX)));
|
|
1825
|
+
const suelta = () => {
|
|
1826
|
+
app.classList.remove('liveResizing');
|
|
1827
|
+
asa.removeEventListener('pointermove', mueve);
|
|
1828
|
+
asa.removeEventListener('pointerup', suelta);
|
|
1829
|
+
asa.removeEventListener('pointercancel', suelta);
|
|
1830
|
+
try {
|
|
1831
|
+
localStorage.setItem(CLAVE, String(Math.round(rail.getBoundingClientRect().width)));
|
|
1832
|
+
} catch {
|
|
1833
|
+
// Nothing to persist to; the drag still worked for this session.
|
|
1834
|
+
}
|
|
1835
|
+
};
|
|
1836
|
+
asa.addEventListener('pointermove', mueve);
|
|
1837
|
+
asa.addEventListener('pointerup', suelta);
|
|
1838
|
+
asa.addEventListener('pointercancel', suelta);
|
|
1839
|
+
});
|
|
1840
|
+
asa.addEventListener('dblclick', () => {
|
|
1841
|
+
aplica(null);
|
|
1842
|
+
try {
|
|
1843
|
+
localStorage.removeItem(CLAVE);
|
|
1844
|
+
} catch {
|
|
1845
|
+
// Already gone.
|
|
1846
|
+
}
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/** The map can ask for a section: a click on the town hall opens the
|
|
1851
|
+
* committee, a click on a gate opens the roads. Only from the map's own frame
|
|
1852
|
+
* and only to sections that exist — a message is a request, not a capability. */
|
|
1853
|
+
window.addEventListener('message', (message) => {
|
|
1854
|
+
if (!esNavDeMapa(message.data)) return;
|
|
1855
|
+
const frame = document.querySelector<HTMLIFrameElement>('#cityMapFrame');
|
|
1856
|
+
if (!frame || message.source !== frame.contentWindow) return;
|
|
1857
|
+
if (message.origin !== new URL(frame.src, location.href).origin) return;
|
|
1858
|
+
const view = message.data.view;
|
|
1859
|
+
if (view !== 'committee' && view !== 'red') return;
|
|
1860
|
+
SECCION = view;
|
|
1861
|
+
pinta();
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
arrastreDelRail();
|
|
1865
|
+
void refresca();
|
|
1866
|
+
|
|
1867
|
+
export {};
|