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,111 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Index the git repos already cloned on this machine, wherever they are, and
|
|
3
|
+
# cache it. One line each: <name-on-the-remote><TAB><local-path>
|
|
4
|
+
#
|
|
5
|
+
# find-repos.sh uses the cache while it is less than a day old
|
|
6
|
+
# find-repos.sh --refresh rebuilds it
|
|
7
|
+
# find-repos.sh <repo> prints that repo's path, or nothing
|
|
8
|
+
#
|
|
9
|
+
# The name comes from the `origin` remote and not from the folder name, so it
|
|
10
|
+
# finds the repo even when the folder is called something else — which is the
|
|
11
|
+
# normal case for anybody with more than ten of them.
|
|
12
|
+
#
|
|
13
|
+
# AGENTS_CITY_ORG only index repos of this organisation. Unset = all of
|
|
14
|
+
# them; the filter is for machines that also hold work
|
|
15
|
+
# from somewhere else.
|
|
16
|
+
# CITY_SEARCH_IN colon-separated roots to search instead of the defaults
|
|
17
|
+
# CITY_SEARCH_DEPTH how deep to go under each root (default 4)
|
|
18
|
+
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
ORG="${AGENTS_CITY_ORG:-}"
|
|
22
|
+
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/agents-city"
|
|
23
|
+
CACHE="$CACHE_DIR/repos.tsv"
|
|
24
|
+
RAICES_DEF="$HOME/codigo:$HOME/code:$HOME/dev:$HOME/src:$HOME/projects:$HOME/proyectos:$HOME/work:$HOME/trabajo:$HOME/repos:$HOME/git:$HOME/Documents:$HOME/Desktop:$HOME/Developer:$HOME"
|
|
25
|
+
RAICES="${CITY_SEARCH_IN:-$RAICES_DEF}"
|
|
26
|
+
PROFUNDIDAD="${CITY_SEARCH_DEPTH:-4}"
|
|
27
|
+
|
|
28
|
+
construir() {
|
|
29
|
+
mkdir -p "$CACHE_DIR"
|
|
30
|
+
local tmp="$CACHE.tmp.$$"
|
|
31
|
+
: > "$tmp"
|
|
32
|
+
local vistos=""
|
|
33
|
+
local IFS=':'
|
|
34
|
+
for raiz in $RAICES; do
|
|
35
|
+
unset IFS
|
|
36
|
+
[ -d "$raiz" ] || continue
|
|
37
|
+
# Do not walk a root another one already covered.
|
|
38
|
+
case ":$vistos:" in *":$raiz:"*) continue ;; esac
|
|
39
|
+
vistos="$vistos:$raiz"
|
|
40
|
+
|
|
41
|
+
# `.git` is a directory in a normal clone and a FILE in a linked worktree —
|
|
42
|
+
# and a worktree is exactly the folder an isolated agent works in, so missing
|
|
43
|
+
# them missed the people using agents the most. Both are indexed; a worktree
|
|
44
|
+
# is named `repo@branch` so the two are distinct things to pick.
|
|
45
|
+
find "$raiz" -maxdepth "$PROFUNDIDAD" \( -type d -o -type f \) -name .git \
|
|
46
|
+
-not -path "*/node_modules/*" \
|
|
47
|
+
-not -path "*/Library/*" \
|
|
48
|
+
-not -path "*/.Trash/*" \
|
|
49
|
+
-not -path "*/vendor/*" \
|
|
50
|
+
-not -path "*/.cache/*" \
|
|
51
|
+
-not -path "*/plugins/cache/*" \
|
|
52
|
+
-not -path "*/.cargo/*" -not -path "*/.asdf/*" -not -path "*/miniconda3/*" \
|
|
53
|
+
-not -path "*/.rbenv/*" -not -path "*/.pyenv/*" -not -path "*/.nvm/*" \
|
|
54
|
+
2>/dev/null \
|
|
55
|
+
| while read -r g; do
|
|
56
|
+
repo="$(dirname "$g")"
|
|
57
|
+
url="$(git -C "$repo" remote get-url origin 2>/dev/null)" || continue
|
|
58
|
+
[ -z "$url" ] && continue
|
|
59
|
+
# git@host:org/name.git | https://host/org/name(.git)
|
|
60
|
+
# The version excluded by an organisation whose default was the literal
|
|
61
|
+
# "<your-org>", so the index came back empty for everybody who never set
|
|
62
|
+
# the variable. Unset now means: index what is there.
|
|
63
|
+
if [ -n "$ORG" ]; then
|
|
64
|
+
case "$url" in
|
|
65
|
+
*[:/]"$ORG"/*) ;;
|
|
66
|
+
*) continue ;;
|
|
67
|
+
esac
|
|
68
|
+
fi
|
|
69
|
+
nombre="$(basename "$url" .git)"
|
|
70
|
+
if [ -f "$g" ]; then
|
|
71
|
+
rama="$(git -C "$repo" branch --show-current 2>/dev/null)"
|
|
72
|
+
[ -z "$rama" ] && rama="$(basename "$repo")"
|
|
73
|
+
nombre="$nombre@$rama"
|
|
74
|
+
fi
|
|
75
|
+
[ -n "$nombre" ] && printf '%s\t%s\n' "$nombre" "$repo"
|
|
76
|
+
done >> "$tmp"
|
|
77
|
+
IFS=':'
|
|
78
|
+
done
|
|
79
|
+
unset IFS
|
|
80
|
+
# First one wins on a duplicate: the roots run from most specific to least.
|
|
81
|
+
sort -u -t$'\t' -k1,1 "$tmp" > "$CACHE"
|
|
82
|
+
rm -f "$tmp"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
caduca() {
|
|
86
|
+
[ ! -f "$CACHE" ] && return 0
|
|
87
|
+
[ -n "$(find "$CACHE" -mtime +1 2>/dev/null)" ] && return 0 # older than a day
|
|
88
|
+
return 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case "${1:-}" in
|
|
92
|
+
--refresh|--refrescar)
|
|
93
|
+
echo "Looking for cloned repos…" >&2
|
|
94
|
+
construir
|
|
95
|
+
echo "$(wc -l < "$CACHE" | tr -d ' ') repos indexed in $CACHE" >&2
|
|
96
|
+
;;
|
|
97
|
+
"")
|
|
98
|
+
caduca && construir
|
|
99
|
+
cat "$CACHE"
|
|
100
|
+
;;
|
|
101
|
+
*)
|
|
102
|
+
caduca && construir
|
|
103
|
+
ruta="$(awk -F'\t' -v n="$1" '$1==n {print $2; exit}' "$CACHE")"
|
|
104
|
+
# Not there? It may have been cloned a minute ago: rebuild once and retry.
|
|
105
|
+
if [ -z "$ruta" ]; then
|
|
106
|
+
construir
|
|
107
|
+
ruta="$(awk -F'\t' -v n="$1" '$1==n {print $2; exit}' "$CACHE")"
|
|
108
|
+
fi
|
|
109
|
+
[ -n "$ruta" ] && printf '%s\n' "$ruta"
|
|
110
|
+
;;
|
|
111
|
+
esac
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Who and what, according to GitHub and according to git.
|
|
3
|
+
|
|
4
|
+
Used by both doors — `./bin/seat` and the wizard — so it sits in the folder that
|
|
5
|
+
ships. Everything here is optional: nothing in this project requires an account, and
|
|
6
|
+
every function returns empty rather than raising when `gh` is missing or logged out.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import unicodedata
|
|
15
|
+
from collections import Counter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sh(args, timeout=180):
|
|
19
|
+
"""Run something and take its stdout, or '' if it failed in any way."""
|
|
20
|
+
try:
|
|
21
|
+
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
|
22
|
+
return r.stdout if r.returncode == 0 else ""
|
|
23
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
24
|
+
return ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def hay(programa):
|
|
28
|
+
return bool(shutil.which(programa))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def conectado():
|
|
32
|
+
"""`gh` present *and* logged in. Both, because an installed-but-anonymous gh
|
|
33
|
+
fails on the first list with a message about scopes that reads like a bug."""
|
|
34
|
+
if not hay("gh"):
|
|
35
|
+
return False
|
|
36
|
+
try:
|
|
37
|
+
r = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True, timeout=20)
|
|
38
|
+
return r.returncode == 0
|
|
39
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def autentica_web():
|
|
44
|
+
"""Run GitHub CLI's browser/device OAuth conversation in the foreground."""
|
|
45
|
+
if not hay("gh"):
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
r = subprocess.run(["gh", "auth", "login", "--web", "--git-protocol", "https"])
|
|
49
|
+
return r.returncode == 0 and conectado()
|
|
50
|
+
except OSError:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def login():
|
|
55
|
+
"""Your GitHub login, which is *not* the same string as your card's user.
|
|
56
|
+
|
|
57
|
+
A card's user comes from a git email's local part. Mine is `joseluiscases`; my
|
|
58
|
+
login is `jlcases`. Anything asking GitHub `author:<who>` needs this one — the
|
|
59
|
+
other gives a 422 that the caller swallows, so the number silently never arrives.
|
|
60
|
+
"""
|
|
61
|
+
return sh(["gh", "api", "user", "--jq", ".login"]).strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def orgs():
|
|
65
|
+
out = sh(["gh", "api", "user/orgs", "--jq", ".[].login"])
|
|
66
|
+
return [l.strip() for l in out.splitlines() if l.strip()]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def repos(dueno):
|
|
70
|
+
"""A user's or an organisation's repos: (name, description, last pushed)."""
|
|
71
|
+
out = sh(
|
|
72
|
+
[
|
|
73
|
+
"gh",
|
|
74
|
+
"repo",
|
|
75
|
+
"list",
|
|
76
|
+
dueno,
|
|
77
|
+
"--limit",
|
|
78
|
+
"400",
|
|
79
|
+
"--no-archived",
|
|
80
|
+
"--json",
|
|
81
|
+
"name,pushedAt,description",
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
return [
|
|
86
|
+
(r["name"], (r.get("description") or "")[:60], r.get("pushedAt", "")[:10])
|
|
87
|
+
for r in json.loads(out or "[]")
|
|
88
|
+
]
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def miembros(org):
|
|
94
|
+
out = sh(["gh", "api", f"orgs/{org}/members", "--paginate", "--jq", ".[].login"])
|
|
95
|
+
return [l.strip() for l in out.splitlines() if l.strip()]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── identity, from a git email ─────────────────────────────────────────────
|
|
99
|
+
def usuario_de_correo(correo):
|
|
100
|
+
"""The username inside an email. One rule, shared by every door.
|
|
101
|
+
|
|
102
|
+
A `+` means one of two opposite things and the digits tell you which:
|
|
103
|
+
|
|
104
|
+
12345678+alice@users.noreply.github.com -> alice
|
|
105
|
+
GitHub's private-email setting. The number is the account id, so the name
|
|
106
|
+
is after the plus. Taking the whole thing gives a card called
|
|
107
|
+
`12345678+alice.md` and an agent name nobody can type.
|
|
108
|
+
|
|
109
|
+
alice+work@example.com -> alice
|
|
110
|
+
Ordinary plus-addressing. Here the account is before the plus.
|
|
111
|
+
|
|
112
|
+
Then down to something safe as a filename and as a name on the bus, but
|
|
113
|
+
transliterated rather than stripped: `josé` should be `jose`, not `jos`.
|
|
114
|
+
"""
|
|
115
|
+
u = correo.split("@")[0].strip().lower()
|
|
116
|
+
if "+" in u:
|
|
117
|
+
izq, der = u.split("+", 1)
|
|
118
|
+
u = der if izq.isdigit() else izq
|
|
119
|
+
# NFKD splits an accented letter into letter + mark, and dropping the marks is
|
|
120
|
+
# what turns Máx into max instead of Mx.
|
|
121
|
+
u = unicodedata.normalize("NFKD", u).encode("ascii", "ignore").decode()
|
|
122
|
+
return re.sub(r"[^a-z0-9._-]", "", u)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def es_maquina(usuario, nombre):
|
|
126
|
+
"""Whether this author is a bot. `[bot]` is the GitHub App convention, but plenty
|
|
127
|
+
of CI identities just call themselves some flavour of bot, and a card for one of
|
|
128
|
+
those is a card nobody can ask anything."""
|
|
129
|
+
return any(
|
|
130
|
+
s in f"{usuario} {nombre}".lower()
|
|
131
|
+
for s in ("[bot]", "dependabot", "renovate", "github-actions", "noreply")
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def gente_de_repos(rutas):
|
|
136
|
+
"""Whoever has commits in these folders. Works with no account at all.
|
|
137
|
+
|
|
138
|
+
Grouped by username alone, not by (username, name). One person writes their own
|
|
139
|
+
name three ways across a decade of commits — "Max Carrion", "Máx Carrión",
|
|
140
|
+
"maxc" — and counting those separately offered the same person three times and
|
|
141
|
+
then wrote their card three times over the top of itself. The name shown is
|
|
142
|
+
whichever spelling they used most.
|
|
143
|
+
"""
|
|
144
|
+
porUsuario = {}
|
|
145
|
+
for r in rutas:
|
|
146
|
+
if not os.path.isdir(r):
|
|
147
|
+
continue
|
|
148
|
+
for l in sh(["git", "-C", r, "log", "--since=1.year", "--format=%ae|%an"]).splitlines():
|
|
149
|
+
if "|" not in l:
|
|
150
|
+
continue
|
|
151
|
+
correo, nombre = l.split("|", 1)
|
|
152
|
+
u = usuario_de_correo(correo)
|
|
153
|
+
if u and not es_maquina(u, nombre):
|
|
154
|
+
porUsuario.setdefault(u, Counter())[nombre.strip()] += 1
|
|
155
|
+
fuera = [
|
|
156
|
+
(u, nombres.most_common(1)[0][0], sum(nombres.values()))
|
|
157
|
+
for u, nombres in porUsuario.items()
|
|
158
|
+
]
|
|
159
|
+
return sorted(fuera, key=lambda x: -x[2])[:40]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The Hall protocol: three frames, a per-connection sequence, no replay.
|
|
3
|
+
|
|
4
|
+
The Hall is the local web page that watches the cities and writes to the seat.
|
|
5
|
+
It talks to the bus the page is served from — same origin, one socket — and
|
|
6
|
+
this module is the transport-free contract both ends share: the frame shapes,
|
|
7
|
+
the monotonic per-connection sequence, and the one rule that removes a whole
|
|
8
|
+
class of bugs — events are never replayed, so on a sequence gap the client
|
|
9
|
+
re-fetches a snapshot instead of the server keeping a durable per-client log.
|
|
10
|
+
|
|
11
|
+
Writing to the seat is one method with a queue mode, not several: the same
|
|
12
|
+
call reaches an idle seat (start a turn) or a busy one (steer into the running
|
|
13
|
+
turn, or queue, or just leave a note), and the bus decides which. Loopback is
|
|
14
|
+
a convenience for pairing, never a substitute for the seat's own admission.
|
|
15
|
+
|
|
16
|
+
Pure Python so the Hall's server and its tests validate frames identically;
|
|
17
|
+
the browser side reimplements the same shapes in TypeScript when it is built.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
PROTOCOL = 'agents-city-hall/1'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Tipo(str, Enum):
|
|
27
|
+
REQ = 'req' # client -> bus: a method call
|
|
28
|
+
RES = 'res' # bus -> client: the paired reply
|
|
29
|
+
EVENT = 'event' # bus -> client: an unsolicited push, carrying seq
|
|
30
|
+
|
|
31
|
+
def __str__(self):
|
|
32
|
+
return self.value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Modo(str, Enum):
|
|
36
|
+
"""How a write to the seat meets the seat's current turn."""
|
|
37
|
+
INICIA = 'start' # idle seat: begin a new turn
|
|
38
|
+
DIRIGE = 'steer' # busy seat: inject into the running turn
|
|
39
|
+
ENCOLA = 'queue' # busy seat: run after the current turn
|
|
40
|
+
NOTA = 'note' # write to the transcript, do not wake the seat
|
|
41
|
+
|
|
42
|
+
def __str__(self):
|
|
43
|
+
return self.value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: Closed method set. A method outside it is refused at the edge, never routed.
|
|
47
|
+
METODOS = frozenset({
|
|
48
|
+
'hall.hello', # handshake: negotiate protocol + declare read scope
|
|
49
|
+
'cities.snapshot', # subscribe-and-snapshot in one round trip
|
|
50
|
+
'seat.write', # write to the seat (carries a Modo)
|
|
51
|
+
'seat.history', # re-fetch a seat transcript after a gap
|
|
52
|
+
'pairing.pending', # list opaque pending road pairings
|
|
53
|
+
'pairing.approve', # approve one by opaque id
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
#: Structured error codes the client branches on (never prose-matching).
|
|
57
|
+
ERRORES = frozenset({
|
|
58
|
+
'unknown_method', 'bad_frame', 'unauthorized', 'seat_busy',
|
|
59
|
+
'not_addressed', 'rate_limited', 'gap_detected',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
MAX_TEXTO = 64_000
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def error(codigo, mensaje, reintentable=False):
|
|
66
|
+
if codigo not in ERRORES:
|
|
67
|
+
raise ValueError(f'unknown error code: {codigo}')
|
|
68
|
+
return {'code': codigo, 'message': mensaje, 'retryable': bool(reintentable)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def valida_req(frame):
|
|
72
|
+
"""Validate an inbound request frame. Returns (ok, problema|None).
|
|
73
|
+
|
|
74
|
+
Fail-closed: an unknown method, a missing id, a non-object params, or a
|
|
75
|
+
steer/queue write without text is refused here rather than deeper in.
|
|
76
|
+
"""
|
|
77
|
+
if not isinstance(frame, dict):
|
|
78
|
+
return False, error('bad_frame', 'frame must be an object')
|
|
79
|
+
if frame.get('type') != Tipo.REQ.value:
|
|
80
|
+
return False, error('bad_frame', 'not a req frame')
|
|
81
|
+
if not isinstance(frame.get('id'), str) or not frame['id']:
|
|
82
|
+
return False, error('bad_frame', 'req needs a string id')
|
|
83
|
+
metodo = frame.get('method')
|
|
84
|
+
if metodo not in METODOS:
|
|
85
|
+
return False, error('unknown_method', f'no such method: {metodo}')
|
|
86
|
+
params = frame.get('params', {})
|
|
87
|
+
if not isinstance(params, dict):
|
|
88
|
+
return False, error('bad_frame', 'params must be an object')
|
|
89
|
+
if metodo == 'seat.write':
|
|
90
|
+
modo = params.get('mode', Modo.INICIA.value)
|
|
91
|
+
if modo not in {m.value for m in Modo}:
|
|
92
|
+
return False, error('bad_frame', f'unknown write mode: {modo}')
|
|
93
|
+
texto = params.get('text', '')
|
|
94
|
+
# text must be an actual string — a dict/list/number silently stringified
|
|
95
|
+
# would be admitted and forwarded as 'None' or "{'x': 1}".
|
|
96
|
+
if not isinstance(texto, str):
|
|
97
|
+
return False, error('bad_frame', 'text must be a string')
|
|
98
|
+
if modo != Modo.NOTA.value and not texto.strip():
|
|
99
|
+
return False, error('bad_frame', 'a seat write needs non-empty text')
|
|
100
|
+
if len(texto) > MAX_TEXTO:
|
|
101
|
+
return False, error('bad_frame', f'text exceeds {MAX_TEXTO} characters')
|
|
102
|
+
return True, None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def respuesta(id_, payload=None, err=None):
|
|
106
|
+
"""Build a res frame paired to a req id. Exactly one of payload/err."""
|
|
107
|
+
if (payload is None) == (err is None):
|
|
108
|
+
raise ValueError('a response carries exactly one of payload or error')
|
|
109
|
+
frame = {'type': Tipo.RES.value, 'id': id_, 'ok': err is None}
|
|
110
|
+
if err is None:
|
|
111
|
+
frame['payload'] = payload
|
|
112
|
+
else:
|
|
113
|
+
frame['error'] = err
|
|
114
|
+
return frame
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Secuencia:
|
|
118
|
+
"""A per-connection monotonic sequence stamped on every event.
|
|
119
|
+
|
|
120
|
+
The client watches for gaps: a jump means frames were dropped (a slow
|
|
121
|
+
consumer is served by dropping, not by unbounded buffering), and the client
|
|
122
|
+
recovers by re-fetching a snapshot — the server keeps no durable replay log.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self):
|
|
126
|
+
self._n = 0
|
|
127
|
+
|
|
128
|
+
def evento(self, nombre, payload):
|
|
129
|
+
self._n += 1
|
|
130
|
+
return {'type': Tipo.EVENT.value, 'event': nombre, 'seq': self._n, 'payload': payload}
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def hay_hueco(ultimo_visto, seq):
|
|
134
|
+
"""True when `seq` is not exactly one past `ultimo_visto` — a gap."""
|
|
135
|
+
return seq != ultimo_visto + 1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def codifica(frame):
|
|
139
|
+
return json.dumps(frame, separators=(',', ':'))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def descodifica(crudo):
|
|
143
|
+
"""Parse one wire frame, or (None, problema) on malformed input."""
|
|
144
|
+
try:
|
|
145
|
+
return json.loads(crudo), None
|
|
146
|
+
except (json.JSONDecodeError, TypeError):
|
|
147
|
+
return None, error('bad_frame', 'frame is not valid json')
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mirror Claude's visible prompts and final answers onto the city WebSocket."""
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import runtime_log
|
|
10
|
+
import runtime_processes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def text(value, limit=4000):
|
|
14
|
+
return str(value or '').strip()[:limit]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def source_id(event, payload, content):
|
|
18
|
+
session = text(payload.get('session_id'), 100) or 'session'
|
|
19
|
+
transcript = text(payload.get('transcript_path'), 1000)
|
|
20
|
+
try:
|
|
21
|
+
position = os.path.getsize(transcript) if transcript else 0
|
|
22
|
+
except OSError:
|
|
23
|
+
position = 0
|
|
24
|
+
digest = hashlib.sha256(content.encode('utf-8')).hexdigest()[:20]
|
|
25
|
+
return f'claude:{session}:{event}:{position}:{digest}'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def marker_path(data, actor):
|
|
29
|
+
safe = ''.join(c if c.isalnum() or c in '_-' else '-' for c in actor)[:80] or 'seat'
|
|
30
|
+
return os.path.join(runtime_processes.ruta(data), 'claude-threads', safe + '.json')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def marker(data, actor):
|
|
34
|
+
try:
|
|
35
|
+
with open(marker_path(data, actor), encoding='utf-8') as stream:
|
|
36
|
+
value = json.load(stream)
|
|
37
|
+
return text(value.get('thread'), 160) or None
|
|
38
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def clear_marker(data, actor):
|
|
43
|
+
try:
|
|
44
|
+
os.unlink(marker_path(data, actor))
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def internal_city_prompt(content):
|
|
50
|
+
prompt = content.lstrip()
|
|
51
|
+
return (prompt.startswith('[Agents City authenticated local bus]')
|
|
52
|
+
or (prompt.startswith('<channel')
|
|
53
|
+
and 'source="plugin:city:city-bus"' in prompt[:500]))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def activity(event, payload, data='', actor='seat'):
|
|
57
|
+
session = text(payload.get('session_id'), 160) or None
|
|
58
|
+
if event == 'UserPromptSubmit':
|
|
59
|
+
content = text(payload.get('prompt'))
|
|
60
|
+
if not content:
|
|
61
|
+
return None
|
|
62
|
+
if internal_city_prompt(content):
|
|
63
|
+
# The committee state machine already emits a concise assignment.
|
|
64
|
+
# Mirroring the transport wrapper produces the unreadable <channel>
|
|
65
|
+
# wall that the Hall must never expose.
|
|
66
|
+
return None
|
|
67
|
+
clear_marker(data, actor)
|
|
68
|
+
return session, {
|
|
69
|
+
'sourceId': source_id(event, payload, content),
|
|
70
|
+
'kind': 'conversation.user', 'phase': 'asked', 'tone': 'question',
|
|
71
|
+
'title': f'{actor} asked', 'summary': content,
|
|
72
|
+
}
|
|
73
|
+
if event == 'Stop':
|
|
74
|
+
content = text(payload.get('last_assistant_message'))
|
|
75
|
+
if not content:
|
|
76
|
+
return None
|
|
77
|
+
return marker(data, actor) or session, {
|
|
78
|
+
'sourceId': source_id(event, payload, content),
|
|
79
|
+
'kind': 'conversation.agent', 'phase': 'answered', 'tone': 'evidence',
|
|
80
|
+
'title': f'{actor} answered', 'summary': content,
|
|
81
|
+
}
|
|
82
|
+
if event in ('SessionStart', 'SessionEnd'):
|
|
83
|
+
phase = 'started' if event == 'SessionStart' else 'ended'
|
|
84
|
+
return session, {
|
|
85
|
+
'sourceId': source_id(event, payload, phase),
|
|
86
|
+
'kind': f'runtime.session.{phase}', 'phase': phase, 'tone': 'system',
|
|
87
|
+
'title': f'{actor} session {phase}',
|
|
88
|
+
'summary': f'Claude session {phase}.',
|
|
89
|
+
}
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main():
|
|
94
|
+
event = sys.argv[1] if len(sys.argv) > 1 else ''
|
|
95
|
+
data = os.environ.get('AGENTS_CITY_DATA', '')
|
|
96
|
+
actor = text(os.environ.get('CITY_BUS_ACTOR'), 80) or 'seat'
|
|
97
|
+
try:
|
|
98
|
+
payload = json.load(sys.stdin)
|
|
99
|
+
except (ValueError, OSError):
|
|
100
|
+
payload = {}
|
|
101
|
+
# The native Claude stream gateway has the exact bus-envelope/thread
|
|
102
|
+
# correlation and publishes the visible answer itself. Mirroring the same
|
|
103
|
+
# turn from lifecycle hooks would show it twice in the Hall. Token/growth
|
|
104
|
+
# hooks remain enabled; only this activity mirror becomes a no-op.
|
|
105
|
+
if os.environ.get('CITY_CLAUDE_STREAM_GATEWAY') == '1':
|
|
106
|
+
print('{}')
|
|
107
|
+
return
|
|
108
|
+
chosen = activity(event, payload, data, actor)
|
|
109
|
+
if chosen and data:
|
|
110
|
+
thread, body = chosen
|
|
111
|
+
client = os.path.realpath(
|
|
112
|
+
os.path.join(os.path.dirname(__file__), '..', 'channel', 'client.js')
|
|
113
|
+
)
|
|
114
|
+
command = ['node', client, 'activity', 'publish']
|
|
115
|
+
if thread:
|
|
116
|
+
command.append(thread)
|
|
117
|
+
command += ['--input', '-']
|
|
118
|
+
try:
|
|
119
|
+
result = subprocess.run(
|
|
120
|
+
command, input=json.dumps(body), capture_output=True, text=True,
|
|
121
|
+
timeout=6, env=dict(os.environ, CITY_BUS_ACTOR=actor))
|
|
122
|
+
if result.returncode:
|
|
123
|
+
runtime_log.append(data, f'claude-hook:{actor}', 'activity.publish.failed',
|
|
124
|
+
actor=actor, outcome='failed', message=result.stderr)
|
|
125
|
+
else:
|
|
126
|
+
runtime_log.append(data, f'claude-hook:{actor}', 'activity.published',
|
|
127
|
+
actor=actor, outcome='ok', message=event)
|
|
128
|
+
except Exception as error:
|
|
129
|
+
runtime_log.append(data, f'claude-hook:{actor}', 'activity.publish.failed',
|
|
130
|
+
actor=actor, outcome='failed', message=error)
|
|
131
|
+
if event in ('Stop', 'SessionEnd') and data:
|
|
132
|
+
clear_marker(data, actor)
|
|
133
|
+
# Claude hooks accept an empty JSON decision. Mirroring is deliberately
|
|
134
|
+
# fail-open so observability can never block the user's model session.
|
|
135
|
+
print('{}')
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == '__main__':
|
|
139
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a short, auditable launcher so tmux never has to paste long commands."""
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import runtime_processes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def safe(value):
|
|
14
|
+
cleaned = ''.join(c if c.isalnum() or c in '-_' else '-' for c in value.lower())
|
|
15
|
+
return cleaned.strip('-')[:80] or 'actor'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make(args):
|
|
19
|
+
directory = os.path.join(runtime_processes.ruta(args.data), 'launchers')
|
|
20
|
+
app_home = os.path.realpath(os.environ.get('AGENTS_CITY_HOME') or
|
|
21
|
+
os.path.join(args.data, '..', '..'))
|
|
22
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
23
|
+
os.chmod(directory, 0o700)
|
|
24
|
+
path = os.path.join(directory, f'{safe(args.actor)}-{os.getpid()}-{time.time_ns()}.sh')
|
|
25
|
+
command = base64.b64encode(args.command.encode('utf-8')).decode('ascii')
|
|
26
|
+
failure = base64.b64encode(json.dumps({
|
|
27
|
+
'sourceId': f'launcher:{args.actor}:{time.time_ns()}:failed',
|
|
28
|
+
'kind': 'runtime.launch.failed',
|
|
29
|
+
'phase': 'failed',
|
|
30
|
+
'tone': 'error',
|
|
31
|
+
'title': f'{args.actor} failed to start',
|
|
32
|
+
'summary': 'The agent command exited before a usable session was ready.',
|
|
33
|
+
}).encode('utf-8')).decode('ascii')
|
|
34
|
+
log = os.path.realpath(os.path.join(os.path.dirname(__file__), 'runtime_log.py'))
|
|
35
|
+
client = os.path.realpath(args.client)
|
|
36
|
+
lines = [
|
|
37
|
+
'#!/usr/bin/env bash',
|
|
38
|
+
'set +e',
|
|
39
|
+
f'DATA={shlex.quote(os.path.realpath(args.data))}',
|
|
40
|
+
f'APP_HOME={shlex.quote(app_home)}',
|
|
41
|
+
f'ACTOR={shlex.quote(args.actor)}',
|
|
42
|
+
f'WORKDIR={shlex.quote(os.path.realpath(args.cwd))}',
|
|
43
|
+
f'RUNTIME_DIR={shlex.quote(os.path.dirname(directory))}',
|
|
44
|
+
f'CLIENT={shlex.quote(client)}',
|
|
45
|
+
f'RUNTIME_LOG={shlex.quote(log)}',
|
|
46
|
+
f'COMMAND_B64={shlex.quote(command)}',
|
|
47
|
+
f'FAILURE_B64={shlex.quote(failure)}',
|
|
48
|
+
'export AGENTS_CITY_HOME="$APP_HOME" AGENTS_CITY_DATA="$DATA" CITY_BUS_ACTOR="$ACTOR"',
|
|
49
|
+
'python3 "$RUNTIME_LOG" launch.started --data "$DATA" --component launcher '
|
|
50
|
+
' --actor "$ACTOR" --outcome starting',
|
|
51
|
+
'cd "$WORKDIR"',
|
|
52
|
+
'cd_status=$?',
|
|
53
|
+
'if [ "$cd_status" -eq 0 ]; then',
|
|
54
|
+
' command_text="$(printf %s "$COMMAND_B64" | base64 --decode)"',
|
|
55
|
+
' eval "$command_text"',
|
|
56
|
+
' status=$?',
|
|
57
|
+
'else',
|
|
58
|
+
' status=$cd_status',
|
|
59
|
+
'fi',
|
|
60
|
+
'if [ "$status" -eq 0 ]; then',
|
|
61
|
+
' python3 "$RUNTIME_LOG" launch.exited --data "$DATA" --component launcher '
|
|
62
|
+
' --actor "$ACTOR" --outcome ok --message "exit 0"',
|
|
63
|
+
'else',
|
|
64
|
+
' python3 "$RUNTIME_LOG" launch.failed --data "$DATA" --component launcher '
|
|
65
|
+
' --actor "$ACTOR" --outcome failed --message "exit $status"',
|
|
66
|
+
' printf %s "$FAILURE_B64" | base64 --decode | env AGENTS_CITY_DATA="$DATA" '
|
|
67
|
+
' CITY_BUS_ACTOR="$ACTOR" node "$CLIENT" activity publish '
|
|
68
|
+
' --input - >/dev/null 2>&1 || true',
|
|
69
|
+
' printf "\\n Agents City: %s failed to start (exit %s).\\n" "$ACTOR" "$status" >&2',
|
|
70
|
+
' printf " Diagnostics: %s/diagnostics.jsonl\\n\\n" "$RUNTIME_DIR" >&2',
|
|
71
|
+
'fi',
|
|
72
|
+
'exit "$status"',
|
|
73
|
+
'',
|
|
74
|
+
]
|
|
75
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o700)
|
|
76
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
|
|
77
|
+
stream.write('\n'.join(lines))
|
|
78
|
+
print(path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
parser = argparse.ArgumentParser()
|
|
83
|
+
parser.add_argument('verb', choices=['create'])
|
|
84
|
+
parser.add_argument('--data', required=True)
|
|
85
|
+
parser.add_argument('--actor', required=True)
|
|
86
|
+
parser.add_argument('--cwd', required=True)
|
|
87
|
+
parser.add_argument('--client', required=True)
|
|
88
|
+
parser.add_argument('--command', required=True)
|
|
89
|
+
args = parser.parse_args()
|
|
90
|
+
make(args)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == '__main__':
|
|
94
|
+
main()
|