agents-city 0.4.0 → 0.5.2

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 (98) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +240 -11
  3. package/README.md +238 -11
  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 +1 -0
  8. package/bin/connect +5 -0
  9. package/bin/hall.html +117 -18
  10. package/bin/navegador.mjs +170 -1
  11. package/bin/serve.py +201 -101
  12. package/bin/test +15 -3
  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-diario.py +185 -0
  21. package/bin/test-doctor.py +31 -0
  22. package/bin/test-i18n.py +189 -0
  23. package/bin/test-navegador.py +61 -0
  24. package/bin/test-seat.py +78 -20
  25. package/bin/test-security.py +2 -0
  26. package/bin/test-serve.py +366 -0
  27. package/city/web/dist-hall/hall.js +789 -214
  28. package/city/web/src/bienvenida.ts +14 -13
  29. package/city/web/src/casa.ts +143 -56
  30. package/city/web/src/demo.ts +61 -31
  31. package/city/web/src/es.ts +187 -27
  32. package/city/web/src/explorador.ts +70 -43
  33. package/city/web/src/hall.ts +520 -103
  34. package/city/web/src/idioma.ts +21 -1
  35. package/city/web/src/motores.ts +48 -1
  36. package/city/web/src/vista.ts +58 -0
  37. package/demo/graba.py +5 -10
  38. package/docs/managed-connect.md +237 -0
  39. package/docs/security.md +41 -2
  40. package/package.json +8 -4
  41. package/plugin/.claude-plugin/plugin.json +1 -1
  42. package/plugin/channel/adapter-prompts.ts +10 -0
  43. package/plugin/channel/adapter.js +78 -31
  44. package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
  45. package/plugin/channel/bus.js +109 -65
  46. package/plugin/channel/bus.ts +11 -1
  47. package/plugin/channel/client.js +67 -30
  48. package/plugin/channel/delivery-queue.ts +160 -21
  49. package/plugin/channel/hub/remote-roads.ts +32 -1
  50. package/plugin/channel/hub/road-controller.ts +116 -16
  51. package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
  52. package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
  53. package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
  54. package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
  55. package/plugin/channel/licenses/keyring-MIT.txt +21 -0
  56. package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
  57. package/plugin/channel/local-hub.js +2080 -97
  58. package/plugin/channel/local-hub.ts +5 -4
  59. package/plugin/channel/managed-connect/bridge.ts +208 -0
  60. package/plugin/channel/managed-connect/cli.ts +416 -0
  61. package/plugin/channel/managed-connect/device.ts +15 -0
  62. package/plugin/channel/managed-connect/local-cities.ts +94 -0
  63. package/plugin/channel/managed-connect/person-message.ts +74 -0
  64. package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
  65. package/plugin/channel/managed-connect/relay-session.ts +7 -0
  66. package/plugin/channel/managed-connect/storage.ts +653 -0
  67. package/plugin/channel/managed-connect/transport.ts +87 -0
  68. package/plugin/channel/managed-connect-cli.js +4777 -0
  69. package/plugin/channel/managed-connect-cli.ts +7 -0
  70. package/plugin/channel/managed-connect-client.d.ts +261 -0
  71. package/plugin/channel/managed-connect-client.js +6572 -0
  72. package/plugin/channel/managed-connect-client.manifest.json +38 -0
  73. package/plugin/channel/package-lock.json +123 -105
  74. package/plugin/channel/package.json +4 -4
  75. package/plugin/channel/protocol.ts +4 -0
  76. package/plugin/channel/reception.ts +896 -0
  77. package/plugin/channel/road-cli.ts +1 -1
  78. package/plugin/channel/runtime/arnes.json +189 -0
  79. package/plugin/channel/runtime/arnes.ts +47 -0
  80. package/plugin/channel/runtime/claude.ts +18 -0
  81. package/plugin/channel/runtime/codex-config.ts +51 -1
  82. package/plugin/channel/runtime/codex.ts +31 -11
  83. package/plugin/channel/runtime/kimi.ts +6 -4
  84. package/plugin/channel/runtime-files.ts +39 -5
  85. package/plugin/channel/runtime-gateway.js +426 -100
  86. package/plugin/channel/runtime-gateway.ts +12 -0
  87. package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
  88. package/plugin/scripts/arnes.py +271 -0
  89. package/plugin/scripts/busca.py +46 -10
  90. package/plugin/scripts/cage.py +5 -0
  91. package/plugin/scripts/city-session.sh +144 -28
  92. package/plugin/scripts/crecimiento.py +4 -1
  93. package/plugin/scripts/demos.py +58 -43
  94. package/plugin/scripts/desinstala.py +36 -21
  95. package/plugin/scripts/diario.py +119 -0
  96. package/plugin/scripts/doctor.py +90 -15
  97. package/plugin/scripts/read-card.py +48 -8
  98. package/plugin/scripts/reception.py +665 -0
package/bin/serve.py CHANGED
@@ -23,10 +23,13 @@ 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
32
+ import time
30
33
  import urllib.parse
31
34
  import webbrowser
32
35
 
@@ -40,9 +43,12 @@ import domains # noqa: E402
40
43
  import gh # noqa: E402
41
44
  import parcels # noqa: E402
42
45
  import units # noqa: E402
46
+ import busca # noqa: E402 the disk scanner, and the one list of where work lives
43
47
  import cities # noqa: E402
48
+ import diario # noqa: E402 what happened, written down
44
49
  import demos # noqa: E402 the recorded demos the Hall plays back
45
50
  import roads # noqa: E402
51
+ import reception # noqa: E402
46
52
  import capabilities # noqa: E402
47
53
  import deliberations # noqa: E402
48
54
  import avatar # noqa: E402
@@ -187,6 +193,22 @@ def estado_seguro_agentes(datos):
187
193
  return []
188
194
 
189
195
 
196
+ def resumen_recepcion_segura():
197
+ """A damaged optional inbox must not make the whole local Hall disappear."""
198
+ try:
199
+ return reception.resumen()
200
+ except (OSError, sqlite3.Error, reception.ReceptionError) as e:
201
+ return {
202
+ "pending": 0,
203
+ "pendingBytes": 0,
204
+ "routingMode": "manual",
205
+ "reviewPolicy": "every_message",
206
+ "routerProfile": None,
207
+ "autoAvailable": False,
208
+ "error": str(e),
209
+ }
210
+
211
+
190
212
  def olvida_skills():
191
213
  """Forget the discovery memo: a skill was installed or removed, and that
192
214
  changes the answer without touching the card."""
@@ -401,24 +423,6 @@ def historia_del_demo(datos):
401
423
  historia = lee_clave(os.path.join(datos, "city.yml"), "domain")
402
424
  return historia if historia in DEMO_STORIES else "software"
403
425
 
404
- # Where people actually keep their code. The curses wizard offered `~/code` and
405
- # nothing else, which is wrong on most machines and silently scans a folder that
406
- # is not there. Offer the ones that exist, most-repos first, and let them type.
407
- CANDIDATAS = [
408
- "code",
409
- "codigo",
410
- "src",
411
- "dev",
412
- "devel",
413
- "projects",
414
- "proyectos",
415
- "work",
416
- "trabajo",
417
- "repos",
418
- "git",
419
- "Developer",
420
- "Documents",
421
- ]
422
426
 
423
427
 
424
428
  def glob_fichas(datos):
@@ -521,33 +525,28 @@ def actividad_viva(datos):
521
525
  return {"online": False, "url": "", "city": "", "started_at": ""}
522
526
 
523
527
 
528
+ def _resumen_cuerpo(cuerpo):
529
+ """On success, the shape rather than the content: which fields arrived and
530
+ how long each was. A log that repeats every goal somebody types is a log
531
+ they will not send anywhere."""
532
+ if not isinstance(cuerpo, dict):
533
+ return {}
534
+ return {k: (len(v) if isinstance(v, (str, list)) else v) for k, v in cuerpo.items()}
535
+
536
+
524
537
  def _que_es_git(ruta):
525
538
  """`repo`, `worktree`, or nothing. A label on a row, never a filter: the
526
539
  picker shows every folder and lets the person decide which one matters."""
527
- marca = os.path.join(ruta, ".git")
528
- if os.path.isdir(marca):
529
- return "repo"
530
- if os.path.isfile(marca):
531
- return "worktree"
532
- return ""
540
+ try:
541
+ # One stat, not an isdir followed by an isfile: a clone's `.git` is a
542
+ # directory and a linked worktree's is a file, and that is the whole
543
+ # question.
544
+ modo = os.stat(os.path.join(ruta, ".git")).st_mode
545
+ except OSError:
546
+ return ""
547
+ return "repo" if stat.S_ISDIR(modo) else "worktree"
533
548
 
534
549
 
535
- def carpetas_probables():
536
- """Existing folders under $HOME that look like where the work lives."""
537
- casa = os.path.expanduser("~")
538
- fuera = []
539
- for n in CANDIDATAS:
540
- ruta = os.path.join(casa, n)
541
- if not os.path.isdir(ruta):
542
- continue
543
- try:
544
- hijos = [x for x in os.listdir(ruta) if not x.startswith(".")]
545
- gits = sum(1 for x in hijos if os.path.isdir(os.path.join(ruta, x, ".git")))
546
- except OSError:
547
- continue
548
- fuera.append({"ruta": ruta, "nombre": n, "items": len(hijos), "gits": gits})
549
- fuera.sort(key=lambda c: (-c["gits"], -c["items"]))
550
- return fuera
551
550
 
552
551
 
553
552
  def catalogo_roles(dominio):
@@ -615,7 +614,27 @@ class Manejador(http.server.BaseHTTPRequestHandler):
615
614
  if os.environ.get("CITY_SETUP_DEBUG"):
616
615
  sys.stderr.write(" %s\n" % (format % args))
617
616
 
617
+ #: What this handler last answered, so the request log can say so without
618
+ #: every endpoint having to report for itself.
619
+ _ultimo = None
620
+ _error = None
621
+
622
+ def apunta(self, q, tipo, **campos):
623
+ """One journal line, in the journal of the city the request acted on.
624
+
625
+ Not the selected one: a request carries `?city=`, and writing its line
626
+ into whichever city happened to be current put the record of what
627
+ happened to one city in another city's file. Which is worse than no
628
+ record, because it is a record that lies about where.
629
+ """
630
+ try:
631
+ diario.apunta(self.ciudad(q), tipo, **campos)
632
+ except Exception: # noqa: BLE001 logging must not break the request
633
+ pass
634
+
618
635
  def responde(self, cuerpo, tipo="application/json", codigo=200):
636
+ self._ultimo = codigo
637
+ self._error = cuerpo.get("error") if isinstance(cuerpo, dict) else None
619
638
  if not isinstance(cuerpo, bytes):
620
639
  cuerpo = json.dumps(cuerpo).encode()
621
640
  self.send_response(codigo)
@@ -702,9 +721,11 @@ class Manejador(http.server.BaseHTTPRequestHandler):
702
721
  "/api/instrucciones": "g_instrucciones",
703
722
  "/api/live": "g_live",
704
723
  "/api/carpeta": "g_carpeta",
724
+ "/api/diario": "g_diario",
705
725
  "/api/demos": "g_demos",
706
726
  "/api/domains": "g_domains",
707
727
  "/api/roles": "g_roles",
728
+ "/api/reception": "g_reception",
708
729
  }
709
730
  POSTS = {
710
731
  "/api/ficha": "p_ficha",
@@ -716,7 +737,9 @@ class Manejador(http.server.BaseHTTPRequestHandler):
716
737
  "/api/ciudad-archiva": "p_archiva_ciudad",
717
738
  "/api/ciudad-reinicia": "p_reinicia_ciudad",
718
739
  "/api/roads": "p_roads",
740
+ "/api/reception": "p_reception",
719
741
  "/api/agente": "p_agente",
742
+ "/api/diario": "p_diario",
720
743
  "/api/agentes": "p_agentes",
721
744
  "/api/montaje": "p_montaje",
722
745
  "/api/motor": "p_motor",
@@ -769,9 +792,24 @@ class Manejador(http.server.BaseHTTPRequestHandler):
769
792
  cuerpo = json.loads(self.rfile.read(largo) or b"{}")
770
793
  except (ValueError, json.JSONDecodeError) as e:
771
794
  return self.responde({"error": f"unreadable body: {e}"}, codigo=400)
772
- if ruta in self.POSTS:
773
- return getattr(self, self.POSTS[ruta])(q, cuerpo)
774
- return self.responde({"error": "no such thing"}, codigo=404)
795
+ if ruta not in self.POSTS:
796
+ self.apunta(q, "post", ruta=ruta, estado=404, error="no such thing")
797
+ return self.responde({"error": "no such thing"}, codigo=404)
798
+ # Every write is recorded before it happens and judged after, so a
799
+ # request that never came back says so too — a handler that hangs or
800
+ # dies leaves a line with no verdict, which is itself the finding.
801
+ empezado = time.monotonic()
802
+ self._ultimo = None
803
+ try:
804
+ salida = getattr(self, self.POSTS[ruta])(q, cuerpo)
805
+ except Exception as e: # noqa: BLE001 the log is the point
806
+ self.apunta(q, "post", ruta=ruta, error=f"{type(e).__name__}: {e}",
807
+ cuerpo=cuerpo, ms=int((time.monotonic() - empezado) * 1000))
808
+ raise
809
+ self.apunta(q, "post", ruta=ruta, estado=self._ultimo,
810
+ cuerpo=cuerpo if self._ultimo != 200 else _resumen_cuerpo(cuerpo),
811
+ error=self._error, ms=int((time.monotonic() - empezado) * 1000))
812
+ return salida
775
813
 
776
814
  def g_estado(self, q):
777
815
  datos = self.ciudad(q)
@@ -837,6 +875,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
837
875
  # Registration is earned by writing to one, never by looking.
838
876
  "ciudades": lista_con(datos),
839
877
  "roads": roads.lee(datos),
878
+ "reception": resumen_recepcion_segura(),
840
879
  "invitation": roads.invitacion(datos, owner),
841
880
  "skills": habilidades,
842
881
  "deliberations": deliberations.lista(datos),
@@ -855,6 +894,13 @@ class Manejador(http.server.BaseHTTPRequestHandler):
855
894
  def g_live(self, q):
856
895
  return self.responde(actividad_viva(self.ciudad(q)))
857
896
 
897
+ def g_reception(self, q):
898
+ """Owner-level remote quarantine; it is deliberately not city-scoped."""
899
+ try:
900
+ return self.responde(reception.estado(seat.quien_soy(), actual=self.ciudad(q)))
901
+ except (OSError, sqlite3.Error, reception.ReceptionError) as e:
902
+ return self.responde({"error": str(e)}, codigo=503)
903
+
858
904
  def g_demos(self, q):
859
905
  """The recorded demos, and one of them in full when asked by name.
860
906
 
@@ -864,18 +910,24 @@ class Manejador(http.server.BaseHTTPRequestHandler):
864
910
  the browser owning a second, drifting copy of the state machine.
865
911
  """
866
912
  cual = q.get("story", [""])[0]
867
- catalogo = demos.catalogo()
868
913
  if not cual:
869
- return self.responde({"demos": catalogo})
870
- historia = next((d for d in catalogo if d["id"] == cual), None)
871
- if not historia:
872
- return self.responde({"error": "no such demo"}, codigo=404)
914
+ return self.responde({"demos": demos.catalogo()})
915
+ # One read of one file: the card is derived from the very events being
916
+ # served, rather than building the whole shelf to look one row up.
873
917
  eventos = demos.eventos(cual)
874
918
  if not eventos:
875
- return self.responde(
876
- {"error": "that demo has no recording; run demo/graba.py"}, codigo=409
877
- )
878
- return self.responde({**historia, "eventos": eventos})
919
+ return self.responde({"error": "no such demo"}, codigo=404)
920
+ return self.responde({**(demos.ficha(cual, eventos) or {}), "eventos": eventos})
921
+
922
+ def g_diario(self, q):
923
+ """The journal, for `doctor --log` and for anybody about to send it."""
924
+ try:
925
+ cuantas = max(1, min(2000, int(q.get("n", ["200"])[0])))
926
+ except ValueError:
927
+ cuantas = 200
928
+ return self.responde(
929
+ {"ruta": diario.ruta(self.ciudad(q)), "lineas": diario.lee(self.ciudad(q), cuantas)}
930
+ )
879
931
 
880
932
  def g_carpeta(self, q):
881
933
  """One folder, listed: what is in it, and what each thing is.
@@ -911,10 +963,6 @@ class Manejador(http.server.BaseHTTPRequestHandler):
911
963
  "nombre": e.name,
912
964
  "ruta": os.path.join(ruta, e.name),
913
965
  "dir": es_dir,
914
- # What kind of place this is, said plainly rather
915
- # than used to filter anything out: a folder is
916
- # yours to pick whether or not it holds a .git.
917
- "git": _que_es_git(os.path.join(ruta, e.name)) if es_dir else "",
918
966
  "enlace": e.is_symlink(),
919
967
  }
920
968
  )
@@ -923,40 +971,31 @@ class Manejador(http.server.BaseHTTPRequestHandler):
923
971
  # Folders first, then files, each alphabetical and case-insensitive —
924
972
  # the order every file picker has used for forty years.
925
973
  entradas.sort(key=lambda x: (not x["dir"], x["nombre"].lower()))
974
+ recortada = len(entradas) > 2000
975
+ entradas = entradas[:2000]
976
+ # Only now, and only for the rows that will be sent. Asking every entry
977
+ # in a twenty-thousand-item directory whether it holds a `.git` was
978
+ # eighteen thousand pairs of stat calls for rows nobody would see. What
979
+ # kind of place this is gets said plainly rather than used to filter
980
+ # anything out: a folder is yours to pick whether or not it holds one.
981
+ for e in entradas:
982
+ e["git"] = _que_es_git(e["ruta"]) if e["dir"] else ""
926
983
  casa = os.path.expanduser("~")
927
984
  arriba = os.path.dirname(ruta)
928
985
  return self.responde(
929
986
  {
930
987
  "ruta": ruta,
931
988
  "arriba": arriba if arriba != ruta else "",
932
- "casa": casa,
933
989
  "atajos": [
934
990
  {"nombre": os.path.basename(c) or c, "ruta": c}
935
- for c in [casa] + [os.path.join(casa, n) for n in CANDIDATAS]
991
+ for c in [casa] + [os.path.join(casa, n) for n in busca.CANDIDATAS]
936
992
  if os.path.isdir(c)
937
993
  ],
938
- "entradas": entradas[:2000],
939
- "recortada": len(entradas) > 2000,
994
+ "entradas": entradas,
995
+ "recortada": recortada,
940
996
  }
941
997
  )
942
998
 
943
- def g_arranque(self, q):
944
- plants = W.plantillas()
945
- return self.responde(
946
- {
947
- "kinds": plants,
948
- "destino": DESTINO,
949
- "casa": os.path.expanduser("~"),
950
- "carpetas": carpetas_probables(),
951
- # Only whether the CLI is there. Whether it is logged in is the
952
- # orgs endpoint's answer, and asking it here would make every
953
- # page load wait on a network call.
954
- "gh": bool(shutil.which("gh")),
955
- "existe": os.path.isdir(DESTINO),
956
- "paleta": [{"hex": c, "nombre": n} for c, n in W.PALETA],
957
- "arquitectos": sorted(ARQUITECTOS),
958
- }
959
- )
960
999
 
961
1000
  def g_roles(self, q):
962
1001
  if q.get("scope", [""])[0] == "agent":
@@ -974,29 +1013,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
974
1013
  }
975
1014
  )
976
1015
 
977
- def g_orgs(self, q):
978
- return self.responde({"orgs": W.orgs_de_gh()})
979
1016
 
980
- def g_repos(self, q):
981
- org = q.get("org", [""])[0]
982
- if org:
983
- hallados = [
984
- {"nombre": n, "detalle": d, "fecha": f, "ruta": ""}
985
- for n, d, f in W.repos_de_org(org)
986
- ]
987
- return self.responde({"repos": hallados})
988
- base = os.path.expanduser(q.get("ruta", [""])[0])
989
- if not base or not os.path.isdir(base):
990
- return self.responde({"repos": [], "error": f"{base or '(empty)'} is not a folder"})
991
- # Two different third columns, and saying so beats guessing in the page:
992
- # a git repo reports its last commit, any-folder reports what is inside.
993
- if q.get("modo", ["git"])[0] == "git":
994
- crudo = W.repos_del_disco(base)
995
- repos = [{"nombre": n, "ruta": r, "detalle": "", "fecha": f} for n, r, f in crudo]
996
- else:
997
- crudo = W.carpetas_del_disco(base)
998
- repos = [{"nombre": n, "ruta": r, "detalle": d, "fecha": ""} for n, r, d in crudo]
999
- return self.responde({"repos": repos})
1000
1017
 
1001
1018
  def g_gente(self, q):
1002
1019
  org = q.get("org", [""])[0]
@@ -1242,6 +1259,51 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1242
1259
  return self.responde({"error": str(e)}, codigo=400)
1243
1260
  return self.responde({"ok": True, "roads": roads.lee(origen)})
1244
1261
 
1262
+ def p_reception(self, q, cuerpo):
1263
+ """One human decision, committed before any city can consume the text."""
1264
+ if cuerpo.get("action") == "configure":
1265
+ try:
1266
+ return self.responde(
1267
+ reception.configura(
1268
+ seat.quien_soy(),
1269
+ cuerpo.get("routing_mode"),
1270
+ cuerpo.get("rules"),
1271
+ self.ciudad(q),
1272
+ )
1273
+ )
1274
+ except reception.ReceptionError as e:
1275
+ return self.responde({"error": str(e)}, codigo=400)
1276
+ except (OSError, sqlite3.Error) as e:
1277
+ return self.responde({"error": f"reception unavailable: {e}"}, codigo=503)
1278
+ if cuerpo.get("action") == "send":
1279
+ try:
1280
+ return self.responde(
1281
+ reception.envia(cuerpo.get("connection_id"), cuerpo.get("text")),
1282
+ codigo=202,
1283
+ )
1284
+ except reception.ReceptionError as e:
1285
+ return self.responde({"error": str(e)}, codigo=400)
1286
+ except (OSError, sqlite3.Error) as e:
1287
+ return self.responde({"error": f"reception unavailable: {e}"}, codigo=503)
1288
+ destinos = cuerpo.get("destinations") or []
1289
+ if not isinstance(destinos, list):
1290
+ return self.responde({"error": "destinations must be a list"}, codigo=400)
1291
+ try:
1292
+ resultado = reception.decide(
1293
+ seat.quien_soy(),
1294
+ cuerpo.get("message_id"),
1295
+ cuerpo.get("action"),
1296
+ destinos,
1297
+ cuerpo.get("reason"),
1298
+ self.ciudad(q),
1299
+ )
1300
+ except reception.ReceptionError as e:
1301
+ codigo = 409 if "already" in str(e) or "conflict" in str(e) else 400
1302
+ return self.responde({"error": str(e)}, codigo=codigo)
1303
+ except (OSError, sqlite3.Error) as e:
1304
+ return self.responde({"error": f"reception unavailable: {e}"}, codigo=503)
1305
+ return self.responde(resultado)
1306
+
1245
1307
  def p_mapa(self, q, cuerpo):
1246
1308
  # Bake and serve the map, detached. First run builds the front end and
1247
1309
  # seeds the local D1, so the page polls /api/estado until it is up.
@@ -1290,7 +1352,12 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1290
1352
  # nothing, never a shell fragment". `runtime` maps to `runs.` — the launcher's
1291
1353
  # own key — and the web deliberately cannot reach the terminal: fallback.
1292
1354
  CAMPOS_DE_AGENTE = {
1293
- "model": ("model", re.compile(r"[a-z0-9][a-z0-9.-]{0,63}"), "that is not a model alias"),
1355
+ # A model name is whatever the CLI in play calls it: `opus`, but also
1356
+ # `gpt-5.6-sol` and OpenCode's `anthropic/claude-sonnet-4`. Slash,
1357
+ # underscore and colon are in; whitespace, quotes and every shell
1358
+ # metacharacter stay out, because this value ends up on a command line.
1359
+ "model": ("model", re.compile(r"[a-z0-9][a-z0-9./:_-]{0,63}"),
1360
+ "that is not a model name"),
1294
1361
  "effort": ("effort", frozenset(("low", "medium", "high", "xhigh", "max")),
1295
1362
  "effort is low..max or empty"),
1296
1363
  "runtime": ("runs", frozenset(("claude",) + seat.OTROS_MOTORES),
@@ -1314,8 +1381,23 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1314
1381
  return self.responde({"error": "this city has no owner card yet"}, codigo=409)
1315
1382
  nombre = " ".join(str(cuerpo.get("name") or "").split())
1316
1383
  slug = card.ventana(nombre)
1317
- if not nombre or not card.ventana_valida(slug):
1384
+ # `card.ventana` falls back to the word `repo` when nothing in the name
1385
+ # survives slugging — which is right for a repository whose folder is
1386
+ # punctuation, and wrong here: a house called `///` would be created
1387
+ # with the window `repo`, and the name a person reads would stop being
1388
+ # the thing the city addresses. A name has to carry a letter or a digit.
1389
+ legible = bool(re.search(r"[a-z0-9]", nombre.lower()))
1390
+ if not nombre or not card.ventana_valida(slug) or not legible:
1318
1391
  return self.responde({"error": "an agent needs a plain name"}, codigo=400)
1392
+ # A window slug is cut at 80 characters, so a longer name would be
1393
+ # stored in full on the card and addressed by a truncated one — the
1394
+ # name a person reads and the window it opens quietly stop being the
1395
+ # same thing. Refuse rather than silently rename.
1396
+ if len(nombre) > 80:
1397
+ return self.responde(
1398
+ {"error": "an agent's name has to fit in a window title: 80 characters"},
1399
+ codigo=400,
1400
+ )
1319
1401
  clase = str(cuerpo.get("kind") or workspace.CLASE_DEFECTO).strip().lower()
1320
1402
  if clase not in workspace.CLASES:
1321
1403
  return self.responde(
@@ -1410,6 +1492,24 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1410
1492
  }
1411
1493
  )
1412
1494
 
1495
+ def p_diario(self, q, cuerpo):
1496
+ """The browser's half of the log.
1497
+
1498
+ Half of what goes wrong here goes wrong in the page — a handler that
1499
+ threw, a fetch that never came back, a button that did nothing. A log
1500
+ that stops at the network boundary tells half the story, so the page
1501
+ writes into the same file the server does, and one file answers "what
1502
+ happened" instead of two that have to be lined up by hand.
1503
+ """
1504
+ diario.apunta(
1505
+ self.ciudad(q),
1506
+ "browser",
1507
+ que=str(cuerpo.get("que") or "")[:120],
1508
+ detalle=cuerpo.get("detalle"),
1509
+ donde=str(cuerpo.get("donde") or "")[:200],
1510
+ )
1511
+ return self.responde({"ok": True})
1512
+
1413
1513
  def p_agente(self, q, cuerpo):
1414
1514
  """Tune one agent from its character sheet: model, effort, runtime and
1415
1515
  avatar seed, written to the card keys the launcher already resolves. An
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 desinstala launch)
25
+ SUITES=(widgets card parcels domains busca arnes i18n diario 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
@@ -90,16 +90,27 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
90
90
  "plugin/scripts/workspace.py", "plugin/scripts/avatar.py",
91
91
  "plugin/scripts/crecimiento.py",
92
92
  "plugin/scripts/reset.py", "plugin/scripts/desinstala.py",
93
- "plugin/scripts/demos.py", "plugin/scripts/capabilities.py",
93
+ "plugin/scripts/demos.py", "plugin/scripts/arnes.py",
94
+ "plugin/scripts/diario.py",
95
+ "plugin/channel/runtime/arnes.json", "plugin/scripts/capabilities.py",
94
96
  "plugin/scripts/deliberations.py",
95
97
  "plugin/scripts/runtime_processes.py",
96
98
  "plugin/scripts/runtime_log.py", "plugin/scripts/logs.py",
97
99
  "plugin/scripts/launch.py", "plugin/scripts/hook_activity.py",
100
+ "plugin/scripts/reception.py",
98
101
  "plugin/scripts/city-session.sh", "plugin/scripts/city-runtime.sh",
99
102
  "plugin/scripts/find-repos.sh",
100
103
  "plugin/scripts/busca.py",
101
104
  "plugin/channel/bus.js", "plugin/channel/local-hub.js",
102
105
  "plugin/channel/client.js", "plugin/channel/adapter.js",
106
+ "plugin/channel/managed-connect-client.js",
107
+ "plugin/channel/managed-connect-client.d.ts",
108
+ "plugin/channel/managed-connect-client.manifest.json",
109
+ "plugin/channel/trust/agents-city-sandbox-roots.json",
110
+ "plugin/channel/kinsh_vodozemac_wasm_bg.wasm",
111
+ "plugin/channel/agents_city_hybrid_crypto_bg.wasm",
112
+ "plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md",
113
+ "plugin/channel/managed-connect-cli.js", "bin/connect",
103
114
  "plugin/channel/runtime-gateway.js",
104
115
  "plugin/.mcp.json",
105
116
  "bin/cities", "bin/road", "bin/bus", "bin/committee", "bin/agents",
@@ -111,7 +122,8 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
111
122
  "benchmarks/latency/fake-native-server.mjs",
112
123
  "benchmarks/latency/fake-claude-cli.mjs",
113
124
  "benchmarks/stress/run.py", "benchmarks/stress/README.md",
114
- "city/web/src/repo-roles.ts",
125
+ "benchmarks/reception/run.mjs", "benchmarks/reception/README.md",
126
+ "city/web/src/repo-roles.ts", "city/web/src/vista.ts",
115
127
  "demo/show.py", "demo/city/city.yml", "demo/city/ada.md",
116
128
  "city/scripts/seed.py", "city/worker/schema.sql", "demo/parcels.yml"]
117
129
  problemas = [f"missing: {n}" for n in necesarios if n not in dentro]