agents-city 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +294 -16
  3. package/README.md +291 -16
  4. package/benchmarks/latency/fake-claude-cli.mjs +2 -1
  5. package/benchmarks/reception/README.md +16 -0
  6. package/benchmarks/reception/run.mjs +84 -0
  7. package/bin/agents-city.js +5 -0
  8. package/bin/connect +5 -0
  9. package/bin/hall.html +208 -22
  10. package/bin/navegador.mjs +203 -13
  11. package/bin/serve.py +176 -111
  12. package/bin/test +18 -4
  13. package/bin/test-arnes.py +192 -0
  14. package/bin/test-cage.py +9 -0
  15. package/bin/test-channel.py +178 -12
  16. package/bin/test-claude-runtime.py +132 -4
  17. package/bin/test-connect-client.mjs +609 -0
  18. package/bin/test-connect.py +52 -0
  19. package/bin/test-contracts.py +214 -0
  20. package/bin/test-demo.py +67 -0
  21. package/bin/test-desinstala.py +170 -0
  22. package/bin/test-doctor.py +31 -0
  23. package/bin/test-i18n.py +189 -0
  24. package/bin/test-navegador.py +54 -0
  25. package/bin/test-seat.py +78 -20
  26. package/bin/test-security.py +2 -0
  27. package/bin/test-serve.py +414 -2
  28. package/bin/uninstall +5 -0
  29. package/city/web/dist/city.js +65 -65
  30. package/city/web/dist/index.html +18 -5
  31. package/city/web/dist-hall/hall.js +1566 -486
  32. package/city/web/index.html +17 -4
  33. package/city/web/src/bienvenida.ts +48 -341
  34. package/city/web/src/casa.ts +324 -0
  35. package/city/web/src/demo.ts +277 -0
  36. package/city/web/src/dialogo.ts +178 -0
  37. package/city/web/src/es.ts +281 -25
  38. package/city/web/src/explorador.ts +216 -0
  39. package/city/web/src/hall.ts +612 -139
  40. package/city/web/src/idioma.ts +21 -1
  41. package/city/web/src/main.ts +28 -5
  42. package/city/web/src/motores.ts +48 -1
  43. package/city/web/src/vista.ts +58 -0
  44. package/demo/graba.py +127 -0
  45. package/demo/grabaciones/legal.jsonl +22 -0
  46. package/demo/grabaciones/medicina.jsonl +22 -0
  47. package/demo/grabaciones/software.jsonl +22 -0
  48. package/docs/managed-connect.md +237 -0
  49. package/docs/security.md +41 -2
  50. package/package.json +8 -4
  51. package/plugin/.claude-plugin/plugin.json +1 -1
  52. package/plugin/channel/adapter-prompts.ts +10 -0
  53. package/plugin/channel/adapter.js +78 -31
  54. package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
  55. package/plugin/channel/bus.js +109 -65
  56. package/plugin/channel/bus.ts +11 -1
  57. package/plugin/channel/client.js +67 -30
  58. package/plugin/channel/delivery-queue.ts +160 -21
  59. package/plugin/channel/hub/remote-roads.ts +32 -1
  60. package/plugin/channel/hub/road-controller.ts +116 -16
  61. package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
  62. package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
  63. package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
  64. package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
  65. package/plugin/channel/licenses/keyring-MIT.txt +21 -0
  66. package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
  67. package/plugin/channel/local-hub.js +2080 -97
  68. package/plugin/channel/local-hub.ts +5 -4
  69. package/plugin/channel/managed-connect/bridge.ts +208 -0
  70. package/plugin/channel/managed-connect/cli.ts +416 -0
  71. package/plugin/channel/managed-connect/device.ts +15 -0
  72. package/plugin/channel/managed-connect/local-cities.ts +94 -0
  73. package/plugin/channel/managed-connect/person-message.ts +74 -0
  74. package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
  75. package/plugin/channel/managed-connect/relay-session.ts +7 -0
  76. package/plugin/channel/managed-connect/storage.ts +653 -0
  77. package/plugin/channel/managed-connect/transport.ts +87 -0
  78. package/plugin/channel/managed-connect-cli.js +4777 -0
  79. package/plugin/channel/managed-connect-cli.ts +7 -0
  80. package/plugin/channel/managed-connect-client.d.ts +261 -0
  81. package/plugin/channel/managed-connect-client.js +6572 -0
  82. package/plugin/channel/managed-connect-client.manifest.json +38 -0
  83. package/plugin/channel/package-lock.json +123 -105
  84. package/plugin/channel/package.json +4 -4
  85. package/plugin/channel/protocol.ts +4 -0
  86. package/plugin/channel/reception.ts +896 -0
  87. package/plugin/channel/road-cli.ts +1 -1
  88. package/plugin/channel/runtime/arnes.json +189 -0
  89. package/plugin/channel/runtime/arnes.ts +47 -0
  90. package/plugin/channel/runtime/claude.ts +18 -0
  91. package/plugin/channel/runtime/codex-config.ts +51 -1
  92. package/plugin/channel/runtime/codex.ts +31 -11
  93. package/plugin/channel/runtime/kimi.ts +6 -4
  94. package/plugin/channel/runtime-files.ts +39 -5
  95. package/plugin/channel/runtime-gateway.js +426 -100
  96. package/plugin/channel/runtime-gateway.ts +12 -0
  97. package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
  98. package/plugin/scripts/arnes.py +271 -0
  99. package/plugin/scripts/busca.py +46 -10
  100. package/plugin/scripts/cage.py +5 -0
  101. package/plugin/scripts/city-session.sh +144 -28
  102. package/plugin/scripts/crecimiento.py +4 -1
  103. package/plugin/scripts/demos.py +122 -0
  104. package/plugin/scripts/desinstala.py +237 -0
  105. package/plugin/scripts/doctor.py +37 -15
  106. package/plugin/scripts/read-card.py +48 -8
  107. package/plugin/scripts/reception.py +665 -0
package/bin/serve.py CHANGED
@@ -23,11 +23,12 @@ import re
23
23
  import secrets
24
24
  import shutil
25
25
  import signal
26
+ import sqlite3
26
27
  import socketserver
28
+ import stat
27
29
  import subprocess
28
30
  import sys
29
31
  import threading
30
- import time
31
32
  import urllib.parse
32
33
  import webbrowser
33
34
 
@@ -36,14 +37,16 @@ sys.path.insert(0, AQUI)
36
37
  import setup as W # noqa: E402 legacy template catalogue used by the Hall
37
38
 
38
39
  sys.path.insert(0, os.path.join(os.path.dirname(AQUI), "plugin", "scripts"))
39
- import busca # noqa: E402 the one disk scanner: repos, worktrees, documents
40
40
  import card # noqa: E402
41
41
  import domains # noqa: E402
42
42
  import gh # noqa: E402
43
43
  import parcels # noqa: E402
44
44
  import units # noqa: E402
45
+ import busca # noqa: E402 the disk scanner, and the one list of where work lives
45
46
  import cities # noqa: E402
47
+ import demos # noqa: E402 the recorded demos the Hall plays back
46
48
  import roads # noqa: E402
49
+ import reception # noqa: E402
47
50
  import capabilities # noqa: E402
48
51
  import deliberations # noqa: E402
49
52
  import avatar # noqa: E402
@@ -129,7 +132,7 @@ def contador_git_local(a, datos):
129
132
  plain commits, activity the last thirty days. Without this, `_code`
130
133
  honestly answers zero and every code agent's sheet showed an empty house
131
134
  no matter how much it had built. The disk index a legacy agent needs is
132
- memoised inside capabilities, so N agents cost one find-repos scan.
135
+ memoised inside capabilities, so N agents cost one disk scan.
133
136
  """
134
137
  import time
135
138
 
@@ -188,6 +191,22 @@ def estado_seguro_agentes(datos):
188
191
  return []
189
192
 
190
193
 
194
+ def resumen_recepcion_segura():
195
+ """A damaged optional inbox must not make the whole local Hall disappear."""
196
+ try:
197
+ return reception.resumen()
198
+ except (OSError, sqlite3.Error, reception.ReceptionError) as e:
199
+ return {
200
+ "pending": 0,
201
+ "pendingBytes": 0,
202
+ "routingMode": "manual",
203
+ "reviewPolicy": "every_message",
204
+ "routerProfile": None,
205
+ "autoAvailable": False,
206
+ "error": str(e),
207
+ }
208
+
209
+
191
210
  def olvida_skills():
192
211
  """Forget the discovery memo: a skill was installed or removed, and that
193
212
  changes the answer without touching the card."""
@@ -402,24 +421,6 @@ def historia_del_demo(datos):
402
421
  historia = lee_clave(os.path.join(datos, "city.yml"), "domain")
403
422
  return historia if historia in DEMO_STORIES else "software"
404
423
 
405
- # Where people actually keep their code. The curses wizard offered `~/code` and
406
- # nothing else, which is wrong on most machines and silently scans a folder that
407
- # is not there. Offer the ones that exist, most-repos first, and let them type.
408
- CANDIDATAS = [
409
- "code",
410
- "codigo",
411
- "src",
412
- "dev",
413
- "devel",
414
- "projects",
415
- "proyectos",
416
- "work",
417
- "trabajo",
418
- "repos",
419
- "git",
420
- "Developer",
421
- "Documents",
422
- ]
423
424
 
424
425
 
425
426
  def glob_fichas(datos):
@@ -522,30 +523,19 @@ def actividad_viva(datos):
522
523
  return {"online": False, "url": "", "city": "", "started_at": ""}
523
524
 
524
525
 
525
- def _dia(cuando):
526
- """A unix time as a plain date, or nothing. The picker shows when a place
527
- was last touched, and an epoch integer is not something anybody reads."""
528
- if not cuando:
526
+ def _que_es_git(ruta):
527
+ """`repo`, `worktree`, or nothing. A label on a row, never a filter: the
528
+ picker shows every folder and lets the person decide which one matters."""
529
+ try:
530
+ # One stat, not an isdir followed by an isfile: a clone's `.git` is a
531
+ # directory and a linked worktree's is a file, and that is the whole
532
+ # question.
533
+ modo = os.stat(os.path.join(ruta, ".git")).st_mode
534
+ except OSError:
529
535
  return ""
530
- return time.strftime("%Y-%m-%d", time.localtime(cuando))
536
+ return "repo" if stat.S_ISDIR(modo) else "worktree"
531
537
 
532
538
 
533
- def carpetas_probables():
534
- """Existing folders under $HOME that look like where the work lives."""
535
- casa = os.path.expanduser("~")
536
- fuera = []
537
- for n in CANDIDATAS:
538
- ruta = os.path.join(casa, n)
539
- if not os.path.isdir(ruta):
540
- continue
541
- try:
542
- hijos = [x for x in os.listdir(ruta) if not x.startswith(".")]
543
- gits = sum(1 for x in hijos if os.path.isdir(os.path.join(ruta, x, ".git")))
544
- except OSError:
545
- continue
546
- fuera.append({"ruta": ruta, "nombre": n, "items": len(hijos), "gits": gits})
547
- fuera.sort(key=lambda c: (-c["gits"], -c["items"]))
548
- return fuera
549
539
 
550
540
 
551
541
  def catalogo_roles(dominio):
@@ -699,9 +689,11 @@ class Manejador(http.server.BaseHTTPRequestHandler):
699
689
  "/api/estado": "g_estado",
700
690
  "/api/instrucciones": "g_instrucciones",
701
691
  "/api/live": "g_live",
702
- "/api/misrepos": "g_misrepos",
692
+ "/api/carpeta": "g_carpeta",
693
+ "/api/demos": "g_demos",
703
694
  "/api/domains": "g_domains",
704
695
  "/api/roles": "g_roles",
696
+ "/api/reception": "g_reception",
705
697
  }
706
698
  POSTS = {
707
699
  "/api/ficha": "p_ficha",
@@ -713,6 +705,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
713
705
  "/api/ciudad-archiva": "p_archiva_ciudad",
714
706
  "/api/ciudad-reinicia": "p_reinicia_ciudad",
715
707
  "/api/roads": "p_roads",
708
+ "/api/reception": "p_reception",
716
709
  "/api/agente": "p_agente",
717
710
  "/api/agentes": "p_agentes",
718
711
  "/api/montaje": "p_montaje",
@@ -834,6 +827,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
834
827
  # Registration is earned by writing to one, never by looking.
835
828
  "ciudades": lista_con(datos),
836
829
  "roads": roads.lee(datos),
830
+ "reception": resumen_recepcion_segura(),
837
831
  "invitation": roads.invitacion(datos, owner),
838
832
  "skills": habilidades,
839
833
  "deliberations": deliberations.lista(datos),
@@ -852,58 +846,98 @@ class Manejador(http.server.BaseHTTPRequestHandler):
852
846
  def g_live(self, q):
853
847
  return self.responde(actividad_viva(self.ciudad(q)))
854
848
 
855
- def g_misrepos(self, q):
856
- """Everything on this disk an agent could be given to work on.
849
+ def g_reception(self, q):
850
+ """Owner-level remote quarantine; it is deliberately not city-scoped."""
851
+ try:
852
+ return self.responde(reception.estado(seat.quien_soy(), actual=self.ciudad(q)))
853
+ except (OSError, sqlite3.Error, reception.ReceptionError) as e:
854
+ return self.responde({"error": str(e)}, codigo=503)
857
855
 
858
- Repositories, linked worktrees and folders of documents, most recently
859
- touched first — the same index the seat and the launcher read, so the
860
- picker and the terminal can never disagree about what is out there.
856
+ def g_demos(self, q):
857
+ """The recorded demos, and one of them in full when asked by name.
861
858
 
862
- The version before this one ran `git log` in every repository to mark
863
- the ones you had committed to. On a machine with three hundred clones
864
- that is three hundred processes while somebody watches a spinner, and
865
- the answer it bought — "yours" — is one a single-user product can read
866
- off the filesystem for free: what you touched this month is yours.
859
+ These are recordings, not simulations: `demo/graba.py` plays each story
860
+ over the real local bus and keeps the exact stream a spectator saw, so
861
+ the Hall can replay a committee without a provider account and without
862
+ the browser owning a second, drifting copy of the state machine.
867
863
  """
868
- # ?refresh=1 rebuilds the index first. The cache lasts a day, and the
869
- # repo somebody just cloned is exactly the one they came to tick.
864
+ cual = q.get("story", [""])[0]
865
+ if not cual:
866
+ return self.responde({"demos": demos.catalogo()})
867
+ # One read of one file: the card is derived from the very events being
868
+ # served, rather than building the whole shelf to look one row up.
869
+ eventos = demos.eventos(cual)
870
+ if not eventos:
871
+ return self.responde({"error": "no such demo"}, codigo=404)
872
+ return self.responde({**(demos.ficha(cual, eventos) or {}), "eventos": eventos})
873
+
874
+ def g_carpeta(self, q):
875
+ """One folder, listed: what is in it, and what each thing is.
876
+
877
+ This replaced a picker that scanned the whole disk and offered what it
878
+ thought you wanted. It was wrong to do that. A person choosing what an
879
+ agent works on knows where their work is, and being handed a guessed
880
+ list of two hundred repositories is not help — it is a second thing to
881
+ read before you can do the thing you came to do. So: a folder, its
882
+ contents, and you walk.
883
+
884
+ Read-only, and metadata only. It never opens a file. The Hall is already
885
+ bound to the loopback behind a per-run token, and mounting an arbitrary
886
+ path was always allowed from here; listing one is strictly less.
887
+ """
888
+ pedida = q.get("path", ["~"])[0] or "~"
889
+ ruta = os.path.realpath(os.path.expanduser(pedida))
890
+ if not os.path.isdir(ruta):
891
+ return self.responde({"error": f"there is no folder at {ruta}"}, codigo=404)
892
+ ocultos = bool(q.get("hidden", [""])[0])
893
+ entradas = []
870
894
  try:
871
- sitios = busca.lugares(refrescar=bool(q.get("refresh", [""])[0]))
872
- except OSError:
873
- sitios = []
874
- frontera = time.time() - 60 * 86400
895
+ with os.scandir(ruta) as it:
896
+ for e in it:
897
+ if e.name.startswith(".") and not ocultos:
898
+ continue
899
+ try:
900
+ es_dir = e.is_dir(follow_symlinks=True)
901
+ except OSError:
902
+ es_dir = False
903
+ entradas.append(
904
+ {
905
+ "nombre": e.name,
906
+ "ruta": os.path.join(ruta, e.name),
907
+ "dir": es_dir,
908
+ "enlace": e.is_symlink(),
909
+ }
910
+ )
911
+ except OSError as error:
912
+ return self.responde({"error": str(error)}, codigo=403)
913
+ # Folders first, then files, each alphabetical and case-insensitive —
914
+ # the order every file picker has used for forty years.
915
+ entradas.sort(key=lambda x: (not x["dir"], x["nombre"].lower()))
916
+ recortada = len(entradas) > 2000
917
+ entradas = entradas[:2000]
918
+ # Only now, and only for the rows that will be sent. Asking every entry
919
+ # in a twenty-thousand-item directory whether it holds a `.git` was
920
+ # eighteen thousand pairs of stat calls for rows nobody would see. What
921
+ # kind of place this is gets said plainly rather than used to filter
922
+ # anything out: a folder is yours to pick whether or not it holds one.
923
+ for e in entradas:
924
+ e["git"] = _que_es_git(e["ruta"]) if e["dir"] else ""
925
+ casa = os.path.expanduser("~")
926
+ arriba = os.path.dirname(ruta)
875
927
  return self.responde(
876
928
  {
877
- "repos": [
878
- {
879
- "nombre": s["nombre"],
880
- "ruta": s["ruta"],
881
- "clase": s.get("clase", "repo"),
882
- "cuando": _dia(s.get("cuando", 0.0)),
883
- "mio": s.get("cuando", 0.0) >= frontera,
884
- }
885
- for s in sitios
886
- ]
929
+ "ruta": ruta,
930
+ "arriba": arriba if arriba != ruta else "",
931
+ "atajos": [
932
+ {"nombre": os.path.basename(c) or c, "ruta": c}
933
+ for c in [casa] + [os.path.join(casa, n) for n in busca.CANDIDATAS]
934
+ if os.path.isdir(c)
935
+ ],
936
+ "entradas": entradas,
937
+ "recortada": recortada,
887
938
  }
888
939
  )
889
940
 
890
- def g_arranque(self, q):
891
- plants = W.plantillas()
892
- return self.responde(
893
- {
894
- "kinds": plants,
895
- "destino": DESTINO,
896
- "casa": os.path.expanduser("~"),
897
- "carpetas": carpetas_probables(),
898
- # Only whether the CLI is there. Whether it is logged in is the
899
- # orgs endpoint's answer, and asking it here would make every
900
- # page load wait on a network call.
901
- "gh": bool(shutil.which("gh")),
902
- "existe": os.path.isdir(DESTINO),
903
- "paleta": [{"hex": c, "nombre": n} for c, n in W.PALETA],
904
- "arquitectos": sorted(ARQUITECTOS),
905
- }
906
- )
907
941
 
908
942
  def g_roles(self, q):
909
943
  if q.get("scope", [""])[0] == "agent":
@@ -921,29 +955,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
921
955
  }
922
956
  )
923
957
 
924
- def g_orgs(self, q):
925
- return self.responde({"orgs": W.orgs_de_gh()})
926
958
 
927
- def g_repos(self, q):
928
- org = q.get("org", [""])[0]
929
- if org:
930
- hallados = [
931
- {"nombre": n, "detalle": d, "fecha": f, "ruta": ""}
932
- for n, d, f in W.repos_de_org(org)
933
- ]
934
- return self.responde({"repos": hallados})
935
- base = os.path.expanduser(q.get("ruta", [""])[0])
936
- if not base or not os.path.isdir(base):
937
- return self.responde({"repos": [], "error": f"{base or '(empty)'} is not a folder"})
938
- # Two different third columns, and saying so beats guessing in the page:
939
- # a git repo reports its last commit, any-folder reports what is inside.
940
- if q.get("modo", ["git"])[0] == "git":
941
- crudo = W.repos_del_disco(base)
942
- repos = [{"nombre": n, "ruta": r, "detalle": "", "fecha": f} for n, r, f in crudo]
943
- else:
944
- crudo = W.carpetas_del_disco(base)
945
- repos = [{"nombre": n, "ruta": r, "detalle": d, "fecha": ""} for n, r, d in crudo]
946
- return self.responde({"repos": repos})
947
959
 
948
960
  def g_gente(self, q):
949
961
  org = q.get("org", [""])[0]
@@ -1189,6 +1201,51 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1189
1201
  return self.responde({"error": str(e)}, codigo=400)
1190
1202
  return self.responde({"ok": True, "roads": roads.lee(origen)})
1191
1203
 
1204
+ def p_reception(self, q, cuerpo):
1205
+ """One human decision, committed before any city can consume the text."""
1206
+ if cuerpo.get("action") == "configure":
1207
+ try:
1208
+ return self.responde(
1209
+ reception.configura(
1210
+ seat.quien_soy(),
1211
+ cuerpo.get("routing_mode"),
1212
+ cuerpo.get("rules"),
1213
+ self.ciudad(q),
1214
+ )
1215
+ )
1216
+ except reception.ReceptionError as e:
1217
+ return self.responde({"error": str(e)}, codigo=400)
1218
+ except (OSError, sqlite3.Error) as e:
1219
+ return self.responde({"error": f"reception unavailable: {e}"}, codigo=503)
1220
+ if cuerpo.get("action") == "send":
1221
+ try:
1222
+ return self.responde(
1223
+ reception.envia(cuerpo.get("connection_id"), cuerpo.get("text")),
1224
+ codigo=202,
1225
+ )
1226
+ except reception.ReceptionError as e:
1227
+ return self.responde({"error": str(e)}, codigo=400)
1228
+ except (OSError, sqlite3.Error) as e:
1229
+ return self.responde({"error": f"reception unavailable: {e}"}, codigo=503)
1230
+ destinos = cuerpo.get("destinations") or []
1231
+ if not isinstance(destinos, list):
1232
+ return self.responde({"error": "destinations must be a list"}, codigo=400)
1233
+ try:
1234
+ resultado = reception.decide(
1235
+ seat.quien_soy(),
1236
+ cuerpo.get("message_id"),
1237
+ cuerpo.get("action"),
1238
+ destinos,
1239
+ cuerpo.get("reason"),
1240
+ self.ciudad(q),
1241
+ )
1242
+ except reception.ReceptionError as e:
1243
+ codigo = 409 if "already" in str(e) or "conflict" in str(e) else 400
1244
+ return self.responde({"error": str(e)}, codigo=codigo)
1245
+ except (OSError, sqlite3.Error) as e:
1246
+ return self.responde({"error": f"reception unavailable: {e}"}, codigo=503)
1247
+ return self.responde(resultado)
1248
+
1192
1249
  def p_mapa(self, q, cuerpo):
1193
1250
  # Bake and serve the map, detached. First run builds the front end and
1194
1251
  # seeds the local D1, so the page polls /api/estado until it is up.
@@ -1237,7 +1294,12 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1237
1294
  # nothing, never a shell fragment". `runtime` maps to `runs.` — the launcher's
1238
1295
  # own key — and the web deliberately cannot reach the terminal: fallback.
1239
1296
  CAMPOS_DE_AGENTE = {
1240
- "model": ("model", re.compile(r"[a-z0-9][a-z0-9.-]{0,63}"), "that is not a model alias"),
1297
+ # A model name is whatever the CLI in play calls it: `opus`, but also
1298
+ # `gpt-5.6-sol` and OpenCode's `anthropic/claude-sonnet-4`. Slash,
1299
+ # underscore and colon are in; whitespace, quotes and every shell
1300
+ # metacharacter stay out, because this value ends up on a command line.
1301
+ "model": ("model", re.compile(r"[a-z0-9][a-z0-9./:_-]{0,63}"),
1302
+ "that is not a model name"),
1241
1303
  "effort": ("effort", frozenset(("low", "medium", "high", "xhigh", "max")),
1242
1304
  "effort is low..max or empty"),
1243
1305
  "runtime": ("runs", frozenset(("claude",) + seat.OTROS_MOTORES),
@@ -1326,8 +1388,11 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1326
1388
  ]
1327
1389
  elif anadir:
1328
1390
  destino = os.path.realpath(os.path.expanduser(anadir))
1329
- if not os.path.isdir(destino):
1330
- return self.responde({"error": f"there is no folder at {destino}"}, codigo=400)
1391
+ # A folder, or one exact file. Both are things somebody legitimately
1392
+ # wants an agent to work on — a handbook is a folder, a spec is a
1393
+ # file — and the workspace links either the same way.
1394
+ if not os.path.exists(destino):
1395
+ return self.responde({"error": f"there is nothing at {destino}"}, codigo=400)
1331
1396
  try:
1332
1397
  workspace.monta(datos, a.slug, destino)
1333
1398
  except (OSError, ValueError) as e:
package/bin/test CHANGED
@@ -22,7 +22,7 @@ if [ -d .git ] && [ -x .githooks/pre-commit ] \
22
22
  && printf ' pre-commit gate armed (core.hooksPath .githooks)\n'
23
23
  fi
24
24
 
25
- SUITES=(widgets card parcels domains busca serve seat cities channel committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza launch)
25
+ SUITES=(widgets card parcels domains busca arnes i18n serve seat cities channel connect committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch)
26
26
  [ $# -gt 0 ] && SUITES=("$@")
27
27
 
28
28
  fallos=0
@@ -89,26 +89,40 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
89
89
  "plugin/scripts/hall_protocol.py", "plugin/scripts/doctor.py",
90
90
  "plugin/scripts/workspace.py", "plugin/scripts/avatar.py",
91
91
  "plugin/scripts/crecimiento.py",
92
- "plugin/scripts/reset.py", "plugin/scripts/capabilities.py",
92
+ "plugin/scripts/reset.py", "plugin/scripts/desinstala.py",
93
+ "plugin/scripts/demos.py", "plugin/scripts/arnes.py",
94
+ "plugin/channel/runtime/arnes.json", "plugin/scripts/capabilities.py",
93
95
  "plugin/scripts/deliberations.py",
94
96
  "plugin/scripts/runtime_processes.py",
95
97
  "plugin/scripts/runtime_log.py", "plugin/scripts/logs.py",
96
98
  "plugin/scripts/launch.py", "plugin/scripts/hook_activity.py",
99
+ "plugin/scripts/reception.py",
97
100
  "plugin/scripts/city-session.sh", "plugin/scripts/city-runtime.sh",
98
101
  "plugin/scripts/find-repos.sh",
99
102
  "plugin/scripts/busca.py",
100
103
  "plugin/channel/bus.js", "plugin/channel/local-hub.js",
101
104
  "plugin/channel/client.js", "plugin/channel/adapter.js",
105
+ "plugin/channel/managed-connect-client.js",
106
+ "plugin/channel/managed-connect-client.d.ts",
107
+ "plugin/channel/managed-connect-client.manifest.json",
108
+ "plugin/channel/trust/agents-city-sandbox-roots.json",
109
+ "plugin/channel/kinsh_vodozemac_wasm_bg.wasm",
110
+ "plugin/channel/agents_city_hybrid_crypto_bg.wasm",
111
+ "plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md",
112
+ "plugin/channel/managed-connect-cli.js", "bin/connect",
102
113
  "plugin/channel/runtime-gateway.js",
103
114
  "plugin/.mcp.json",
104
115
  "bin/cities", "bin/road", "bin/bus", "bin/committee", "bin/agents",
105
- "bin/reset", "bin/skills", "bin/benchmark", "bin/logs",
116
+ "bin/reset", "bin/uninstall", "bin/skills", "bin/benchmark", "bin/logs",
117
+ "demo/graba.py", "demo/grabaciones/software.jsonl",
118
+ "demo/grabaciones/medicina.jsonl", "demo/grabaciones/legal.jsonl",
106
119
  "benchmarks/committee/run.py",
107
120
  "benchmarks/latency/live.py", "benchmarks/latency/README.md",
108
121
  "benchmarks/latency/fake-native-server.mjs",
109
122
  "benchmarks/latency/fake-claude-cli.mjs",
110
123
  "benchmarks/stress/run.py", "benchmarks/stress/README.md",
111
- "city/web/src/repo-roles.ts",
124
+ "benchmarks/reception/run.mjs", "benchmarks/reception/README.md",
125
+ "city/web/src/repo-roles.ts", "city/web/src/vista.ts",
112
126
  "demo/show.py", "demo/city/city.yml", "demo/city/ada.md",
113
127
  "city/scripts/seed.py", "city/worker/schema.sql", "demo/parcels.yml"]
114
128
  problemas = [f"missing: {n}" for n in necesarios if n not in dentro]
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """What this product does to somebody else's CLI, and whether it says so.
3
+
4
+ The whole pitch is that Agents City orchestrates the CLIs people already run,
5
+ with the plugins, skills, MCP servers and settings they already have. That is a
6
+ claim about someone else's machine, and a claim like that is worth exactly as
7
+ much as the command that checks it.
8
+
9
+ So there are two things to defend here, and the second is the hard one:
10
+
11
+ 1. the report reads what is actually on the disk, and never invents a fact
12
+ about a file it could not read;
13
+ 2. the report and the RUNTIME cannot drift. Every value the connectors impose
14
+ comes out of `arnes.json`, and nothing is imposed that the declaration does
15
+ not mention. A product that quietly injects an instruction it does not
16
+ print is exactly what this file exists to catch — and it caught one while
17
+ it was being written.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ import tempfile
25
+
26
+ AQUI = os.path.dirname(os.path.abspath(__file__))
27
+ RAIZ = os.path.dirname(AQUI)
28
+ sys.path.insert(0, AQUI)
29
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
30
+
31
+ import arnes # noqa: E402
32
+ from testlib import afirma, comprueba, resumen # noqa: E402
33
+
34
+ RUNTIME = os.path.join(RAIZ, "plugin", "channel", "runtime")
35
+
36
+
37
+ def la_declaracion():
38
+ print(" the declaration")
39
+ motores = arnes.declaracion()
40
+ afirma("· every runtime the product launches is declared",
41
+ sorted(motores) == ["claude", "codex", "kimi", "opencode"], str(sorted(motores)))
42
+ for nombre, motor in motores.items():
43
+ for clave in ("binario", "config", "trato", "hereda", "respeta"):
44
+ afirma(f"· {nombre} declares {clave}", clave in motor, str(sorted(motor)))
45
+ for t in motor["trato"]:
46
+ afirma(f"· {nombre}/{t['clave']} says how it is applied", bool(t.get("via")), str(t))
47
+ # The reason is not decoration. Somebody reading this report is
48
+ # deciding whether to trust the product with their machine, and
49
+ # "because we do" is not an answer they can weigh.
50
+ afirma(f"· {nombre}/{t['clave']} says why", len(t.get("porque", "")) > 25, str(t))
51
+
52
+
53
+ def sin_deriva():
54
+ print(" the report cannot drift from the behaviour")
55
+ # Comments are stripped first. A check that cannot tell a value from a
56
+ # sentence about that value teaches people to write worse comments, and the
57
+ # comments in these files are load-bearing.
58
+ def codigo(nombre):
59
+ texto = open(os.path.join(RUNTIME, f"{nombre}.ts"), encoding="utf-8").read()
60
+ texto = re.sub(r"/\*.*?\*/", "", texto, flags=re.S)
61
+ return "\n".join(l for l in texto.split("\n") if not l.strip().startswith("//"))
62
+
63
+ fuentes = {n: codigo(n) for n in ("codex", "kimi", "opencode", "claude")}
64
+ # Claude's deal is applied by the shell launcher, not by claude.ts. Scanning
65
+ # only the connectors meant this guard passed VACUOUSLY for the one runtime
66
+ # whose values were respelled somewhere else — the exact failure it exists
67
+ # to catch, hiding in the place it could not see.
68
+ fuentes["claude"] += "\n" + open(
69
+ os.path.join(RAIZ, "plugin", "scripts", "city-session.sh"), encoding="utf-8"
70
+ ).read()
71
+ motores = arnes.declaracion()
72
+
73
+ # Every declared value is READ from the declaration, never respelled.
74
+ for nombre, motor in motores.items():
75
+ for t in motor["trato"]:
76
+ valor = t["valor"]
77
+ if len(valor) < 12:
78
+ continue # a short literal like "auto" is not a drift risk
79
+ afirma(
80
+ f"· {nombre}/{t['clave']} is not spelled a second time in {nombre}.ts",
81
+ valor not in fuentes[nombre], f"{valor[:60]}… appears inline",
82
+ )
83
+
84
+ # And nothing is imposed that the declaration does not mention. These are
85
+ # the shapes a runtime uses to put words or policy into somebody's agent.
86
+ sospechosas = ("developerInstructions", "system_prompt", "systemPrompt",
87
+ "approvalPolicy", "permission_mode", "instructions")
88
+ for nombre, fuente in fuentes.items():
89
+ declaradas = {t["clave"] for t in motores.get(nombre, {}).get("trato", [])}
90
+ # A private method named after the key is how a declared value is read.
91
+ declaradas |= {c[0].lower() + c[1:] for c in declaradas}
92
+ for aguja in sospechosas:
93
+ if not re.search(rf"\b{aguja}\b", fuente):
94
+ continue
95
+ afirma(
96
+ f"· {nombre}.ts sets {aguja}, and the declaration says so",
97
+ aguja in declaradas or _es_metodo_declarado(aguja, declaradas),
98
+ f"{aguja} is imposed by {nombre}.ts and missing from arnes.json",
99
+ )
100
+
101
+ # The one sentence the product puts into other people's agents lives in
102
+ # exactly one file. It used to live in two, and one of them was undeclared.
103
+ frase = "You are one member of an Agents City committee"
104
+ fuera = [n for n, f in fuentes.items() if frase in f]
105
+ afirma("· the committee instruction is written in one place only",
106
+ not fuera, f"also inline in: {fuera}")
107
+
108
+ # And the launcher asks for the deal rather than carrying a copy of it.
109
+ shell = open(os.path.join(RAIZ, "plugin", "scripts", "city-session.sh"),
110
+ encoding="utf-8").read()
111
+ afirma("· the launcher asks arnes.py for Claude's flags",
112
+ 'arnes.py" flags claude' in shell, "")
113
+ banderas = arnes.banderas("claude")
114
+ for trozo in ("--settings", "crossSessionInbound", "--disallowed-tools",
115
+ "SendMessage,ListAgents"):
116
+ afirma(f"· and they are built from the declaration: {trozo}",
117
+ trozo in banderas, banderas)
118
+ afirma("· a runtime whose deal is not command-line shaped gets no flags",
119
+ arnes.banderas("codex") == "" and arnes.banderas("opencode") == "",
120
+ f"codex={arnes.banderas('codex')!r}")
121
+
122
+
123
+ def _es_metodo_declarado(aguja, declaradas):
124
+ """`approvalPolicy` declared, read through a method of the same name."""
125
+ return any(aguja.lower() == d.lower() for d in declaradas)
126
+
127
+
128
+ def lo_que_hay_en_el_disco():
129
+ print(" it reads the machine, and admits what it cannot read")
130
+ casa = tempfile.mkdtemp()
131
+ toml = os.path.join(casa, "config.toml")
132
+ open(toml, "w", encoding="utf-8").write(
133
+ '# a comment\n'
134
+ 'model = "gpt-5.6-sol"\n'
135
+ "model_reasoning_effort = 'max'\n"
136
+ "suelto = 3\n"
137
+ "[features]\n"
138
+ 'oculto = "no debe leerse"\n'
139
+ )
140
+ plano = arnes._lee_toml_plano(toml)
141
+ comprueba("· a quoted value is read", plano.get("model"), "gpt-5.6-sol")
142
+ comprueba("· single quotes too", plano.get("model_reasoning_effort"), "max")
143
+ comprueba("· and a bare one", plano.get("suelto"), "3")
144
+ afirma("· a comment is not a setting", "# a comment" not in plano, str(plano))
145
+ afirma("· and it stops at the first section rather than guessing",
146
+ "oculto" not in plano, str(plano))
147
+ comprueba("· a file that is not there is empty, not an error",
148
+ arnes._lee_toml_plano(os.path.join(casa, "no-hay")), {})
149
+
150
+ print(" it never invents a value")
151
+ afirma("· a long block is counted, not dumped",
152
+ arnes.resume({"allow": [1, 2, 3], "deny": []}) == "3 entries",
153
+ arnes.resume({"allow": [1, 2, 3], "deny": []}))
154
+ afirma("· a long string is cut, and says it was",
155
+ arnes.resume("x" * 200).endswith("…"), arnes.resume("x" * 200))
156
+ comprueba("· a short one is left alone", arnes.resume("auto"), "auto")
157
+
158
+
159
+ def el_informe():
160
+ print(" the report")
161
+ entradas = arnes.informe(instalado=lambda _b: False)
162
+ comprueba("· one entry per runtime", len(entradas), 4)
163
+ afirma("· a CLI that is not on this machine is said to be missing",
164
+ all(not e["instalado"] for e in entradas), "")
165
+ salida = []
166
+ arnes.imprime(entradas, di=salida.append)
167
+ texto = "\n".join(salida)
168
+ afirma("· it separates the deal from what it inherits and what it leaves alone",
169
+ "the deal" in texto and "we inherit" in texto and "untouched" in texto, texto[:400])
170
+ afirma("· it says the deal is what makes the bus the only route",
171
+ "the only route" in texto, texto[-400:])
172
+ afirma("· and how to switch the cage off",
173
+ "CITY_CAGE=0" in texto, texto[-400:])
174
+ afirma("· a runtime that imposes nothing says so plainly",
175
+ "nothing — it runs exactly as you configured it" in texto, texto)
176
+ # It is a report about somebody's machine: it must survive not finding one.
177
+ vacio = arnes.informe(instalado=lambda _b: True)
178
+ afirma("· and it renders on a machine with no configuration at all",
179
+ len(vacio) == 4, str(len(vacio)))
180
+ json.dumps(entradas) # the --json door must stay serialisable
181
+
182
+
183
+ def main():
184
+ la_declaracion()
185
+ sin_deriva()
186
+ lo_que_hay_en_el_disco()
187
+ el_informe()
188
+ return resumen("arnes")
189
+
190
+
191
+ if __name__ == "__main__":
192
+ sys.exit(main())