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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +294 -16
- package/README.md +291 -16
- package/benchmarks/latency/fake-claude-cli.mjs +2 -1
- package/benchmarks/reception/README.md +16 -0
- package/benchmarks/reception/run.mjs +84 -0
- package/bin/agents-city.js +5 -0
- package/bin/connect +5 -0
- package/bin/hall.html +208 -22
- package/bin/navegador.mjs +203 -13
- package/bin/serve.py +176 -111
- package/bin/test +18 -4
- package/bin/test-arnes.py +192 -0
- package/bin/test-cage.py +9 -0
- package/bin/test-channel.py +178 -12
- package/bin/test-claude-runtime.py +132 -4
- package/bin/test-connect-client.mjs +609 -0
- package/bin/test-connect.py +52 -0
- package/bin/test-contracts.py +214 -0
- package/bin/test-demo.py +67 -0
- package/bin/test-desinstala.py +170 -0
- package/bin/test-doctor.py +31 -0
- package/bin/test-i18n.py +189 -0
- package/bin/test-navegador.py +54 -0
- package/bin/test-seat.py +78 -20
- package/bin/test-security.py +2 -0
- package/bin/test-serve.py +414 -2
- package/bin/uninstall +5 -0
- package/city/web/dist/city.js +65 -65
- package/city/web/dist/index.html +18 -5
- package/city/web/dist-hall/hall.js +1566 -486
- package/city/web/index.html +17 -4
- package/city/web/src/bienvenida.ts +48 -341
- package/city/web/src/casa.ts +324 -0
- package/city/web/src/demo.ts +277 -0
- package/city/web/src/dialogo.ts +178 -0
- package/city/web/src/es.ts +281 -25
- package/city/web/src/explorador.ts +216 -0
- package/city/web/src/hall.ts +612 -139
- package/city/web/src/idioma.ts +21 -1
- package/city/web/src/main.ts +28 -5
- package/city/web/src/motores.ts +48 -1
- package/city/web/src/vista.ts +58 -0
- package/demo/graba.py +127 -0
- package/demo/grabaciones/legal.jsonl +22 -0
- package/demo/grabaciones/medicina.jsonl +22 -0
- package/demo/grabaciones/software.jsonl +22 -0
- package/docs/managed-connect.md +237 -0
- package/docs/security.md +41 -2
- package/package.json +8 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/adapter-prompts.ts +10 -0
- package/plugin/channel/adapter.js +78 -31
- package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
- package/plugin/channel/bus.js +109 -65
- package/plugin/channel/bus.ts +11 -1
- package/plugin/channel/client.js +67 -30
- package/plugin/channel/delivery-queue.ts +160 -21
- package/plugin/channel/hub/remote-roads.ts +32 -1
- package/plugin/channel/hub/road-controller.ts +116 -16
- package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
- package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
- package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
- package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
- package/plugin/channel/licenses/keyring-MIT.txt +21 -0
- package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
- package/plugin/channel/local-hub.js +2080 -97
- package/plugin/channel/local-hub.ts +5 -4
- package/plugin/channel/managed-connect/bridge.ts +208 -0
- package/plugin/channel/managed-connect/cli.ts +416 -0
- package/plugin/channel/managed-connect/device.ts +15 -0
- package/plugin/channel/managed-connect/local-cities.ts +94 -0
- package/plugin/channel/managed-connect/person-message.ts +74 -0
- package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
- package/plugin/channel/managed-connect/relay-session.ts +7 -0
- package/plugin/channel/managed-connect/storage.ts +653 -0
- package/plugin/channel/managed-connect/transport.ts +87 -0
- package/plugin/channel/managed-connect-cli.js +4777 -0
- package/plugin/channel/managed-connect-cli.ts +7 -0
- package/plugin/channel/managed-connect-client.d.ts +261 -0
- package/plugin/channel/managed-connect-client.js +6572 -0
- package/plugin/channel/managed-connect-client.manifest.json +38 -0
- package/plugin/channel/package-lock.json +123 -105
- package/plugin/channel/package.json +4 -4
- package/plugin/channel/protocol.ts +4 -0
- package/plugin/channel/reception.ts +896 -0
- package/plugin/channel/road-cli.ts +1 -1
- package/plugin/channel/runtime/arnes.json +189 -0
- package/plugin/channel/runtime/arnes.ts +47 -0
- package/plugin/channel/runtime/claude.ts +18 -0
- package/plugin/channel/runtime/codex-config.ts +51 -1
- package/plugin/channel/runtime/codex.ts +31 -11
- package/plugin/channel/runtime/kimi.ts +6 -4
- package/plugin/channel/runtime-files.ts +39 -5
- package/plugin/channel/runtime-gateway.js +426 -100
- package/plugin/channel/runtime-gateway.ts +12 -0
- package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
- package/plugin/scripts/arnes.py +271 -0
- package/plugin/scripts/busca.py +46 -10
- package/plugin/scripts/cage.py +5 -0
- package/plugin/scripts/city-session.sh +144 -28
- package/plugin/scripts/crecimiento.py +4 -1
- package/plugin/scripts/demos.py +122 -0
- package/plugin/scripts/desinstala.py +237 -0
- package/plugin/scripts/doctor.py +37 -15
- package/plugin/scripts/read-card.py +48 -8
- 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
|
|
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
|
|
526
|
-
"""
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
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/
|
|
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
|
|
856
|
-
"""
|
|
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
|
-
|
|
859
|
-
|
|
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
|
-
|
|
863
|
-
the
|
|
864
|
-
|
|
865
|
-
the
|
|
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
|
-
|
|
869
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
"
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
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/
|
|
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
|
-
"
|
|
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())
|