agents-city 0.3.0-beta.21 → 0.3.0

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 (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +310 -70
  3. package/README.md +297 -69
  4. package/bin/agents-city.js +3 -0
  5. package/bin/doctor +3 -0
  6. package/bin/hall.html +164 -24
  7. package/bin/navegador.mjs +415 -0
  8. package/bin/serve.py +383 -127
  9. package/bin/shortcut +3 -0
  10. package/bin/test +5 -2
  11. package/bin/test-actualiza.py +130 -0
  12. package/bin/test-atajos.py +301 -0
  13. package/bin/test-busca.py +216 -0
  14. package/bin/test-cage.py +170 -2
  15. package/bin/test-card.py +2 -2
  16. package/bin/test-cities.py +45 -0
  17. package/bin/test-contracts.py +12 -5
  18. package/bin/test-doctor.py +33 -0
  19. package/bin/test-navegador.py +164 -0
  20. package/bin/test-seat.py +245 -25
  21. package/bin/test-serve.py +214 -9
  22. package/bin/test-workspace.py +63 -0
  23. package/bin/testlib.py +23 -0
  24. package/bin/update +3 -0
  25. package/city/web/dist/city.js +47 -47
  26. package/city/web/dist/index.html +1 -1
  27. package/city/web/dist-hall/hall.js +2193 -174
  28. package/city/web/src/bienvenida.ts +686 -0
  29. package/city/web/src/es.ts +180 -0
  30. package/city/web/src/hall.ts +520 -168
  31. package/city/web/src/idioma.ts +86 -0
  32. package/city/web/src/main.ts +27 -0
  33. package/city/web/src/motores.ts +54 -0
  34. package/docs/agents-first.md +8 -1
  35. package/docs/security.md +46 -12
  36. package/docs/testing.md +1 -1
  37. package/package.json +1 -1
  38. package/plugin/.claude-plugin/plugin.json +1 -1
  39. package/plugin/channel/bus.js +1 -1
  40. package/plugin/channel/bus.ts +1 -1
  41. package/plugin/channel/runtime/codex.ts +1 -1
  42. package/plugin/channel/runtime-gateway.js +1 -1
  43. package/plugin/scripts/actualiza.py +198 -0
  44. package/plugin/scripts/atajos.py +506 -0
  45. package/plugin/scripts/busca.py +436 -0
  46. package/plugin/scripts/cage.py +266 -26
  47. package/plugin/scripts/capabilities.py +17 -10
  48. package/plugin/scripts/card.py +10 -0
  49. package/plugin/scripts/cities.py +34 -0
  50. package/plugin/scripts/city-session.sh +33 -7
  51. package/plugin/scripts/doctor.py +122 -0
  52. package/plugin/scripts/find-repos.sh +12 -105
  53. package/plugin/scripts/read-card.py +6 -2
  54. package/plugin/scripts/report.py +5 -6
  55. package/plugin/scripts/reset.py +50 -14
  56. package/plugin/scripts/seat.py +445 -103
  57. package/plugin/scripts/workspace.py +197 -0
package/bin/serve.py CHANGED
@@ -27,6 +27,7 @@ import socketserver
27
27
  import subprocess
28
28
  import sys
29
29
  import threading
30
+ import time
30
31
  import urllib.parse
31
32
  import webbrowser
32
33
 
@@ -35,6 +36,7 @@ sys.path.insert(0, AQUI)
35
36
  import setup as W # noqa: E402 legacy template catalogue used by the Hall
36
37
 
37
38
  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
38
40
  import card # noqa: E402
39
41
  import domains # noqa: E402
40
42
  import gh # noqa: E402
@@ -48,6 +50,8 @@ import avatar # noqa: E402
48
50
  import workspace # noqa: E402
49
51
  import crecimiento # noqa: E402
50
52
  import runtime_processes # noqa: E402
53
+ import reset as reinicio # noqa: E402
54
+ import actualiza # noqa: E402
51
55
  import importlib.machinery as _mach
52
56
  import importlib.util as _iu # noqa: E402
53
57
 
@@ -84,16 +88,38 @@ def es_demo(datos):
84
88
  # every page load and after every sheet edit. Ninety seconds of memory keeps the
85
89
  # sheet honest enough while sparing the walk on each click.
86
90
  _CRECIDO = {}
91
+ _CRECIENDO = threading.Lock()
87
92
 
88
93
 
89
- def _cuenta_git(ruta, extra):
94
+ def _historia_git(ruta, desde):
95
+ """(merges, plain commits, commits since `desde`) in ONE walk of a repo.
96
+
97
+ Three `rev-list --count` calls asked git to walk the same history three
98
+ times, three processes deep; one `log` carrying each commit's timestamp and
99
+ parents answers all three questions from the same pass. On a 57k-commit repo
100
+ that is 0.61s instead of 0.83s, and one process instead of three.
101
+ """
90
102
  salida = subprocess.run(
91
- ["git", "-C", ruta, "rev-list", "--count", *extra, "HEAD"],
103
+ ["git", "-C", ruta, "log", "--format=%ct %p", "HEAD"],
92
104
  capture_output=True,
93
105
  text=True,
94
- timeout=10,
106
+ timeout=30,
95
107
  )
96
- return int(salida.stdout.strip() or 0) if salida.returncode == 0 else 0
108
+ if salida.returncode != 0:
109
+ return 0, 0, 0
110
+ fusiones = sueltos = recientes = 0
111
+ for linea in salida.stdout.splitlines():
112
+ cuando, _, padres = linea.partition(" ")
113
+ if len(padres.split()) > 1:
114
+ fusiones += 1
115
+ else:
116
+ sueltos += 1
117
+ try:
118
+ if int(cuando) >= desde:
119
+ recientes += 1
120
+ except ValueError:
121
+ pass
122
+ return fusiones, sueltos, recientes
97
123
 
98
124
 
99
125
  def contador_git_local(a, datos):
@@ -102,11 +128,16 @@ def contador_git_local(a, datos):
102
128
  Floors are merge commits (a merged PR lands as exactly one), bricks the
103
129
  plain commits, activity the last thirty days. Without this, `_code`
104
130
  honestly answers zero and every code agent's sheet showed an empty house
105
- no matter how much it had built."""
131
+ 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.
133
+ """
134
+ import time
135
+
106
136
  if a.legacy:
107
137
  rutas = [capabilities.ruta_de(a.nombre)]
108
138
  else:
109
139
  rutas = [a.workspace] + list(workspace.mount_targets(a, datos) or [])
140
+ desde = int(time.time()) - crecimiento.DIAS_RECIENTE * 24 * 3600
110
141
  prs = commits = act = 0
111
142
  vistos = set()
112
143
  for r in rutas:
@@ -114,12 +145,64 @@ def contador_git_local(a, datos):
114
145
  if not real or real in vistos or not os.path.exists(os.path.join(real, ".git")):
115
146
  continue
116
147
  vistos.add(real)
117
- prs += _cuenta_git(real, ["--merges"])
118
- commits += _cuenta_git(real, ["--no-merges"])
119
- act += _cuenta_git(real, ["--since=30.days"])
148
+ f, s, rec = _historia_git(real, desde)
149
+ prs += f
150
+ commits += s
151
+ act += rec
120
152
  return prs, commits, act
121
153
 
122
154
 
155
+ _SKILLS = {}
156
+
157
+
158
+ def skills_de_ciudad(datos):
159
+ """Live skill discovery, remembered while the card has not changed.
160
+
161
+ Discovery reads a SKILL.md for every mount of every agent, and /api/estado
162
+ runs on every page load and after every mount, agent-add and skill install.
163
+ The card's mtime is the honest key: everything discovery depends on — who
164
+ the agents are and what they mount — is written there, so a card that has
165
+ not moved cannot have a different answer, and one that has invalidates
166
+ immediately rather than after a timeout.
167
+ """
168
+ owner = cities.lee_clave(datos, "owner") or seat.quien_soy()
169
+ ficha = os.path.join(datos, f"{owner}.md")
170
+ try:
171
+ sello = (os.path.realpath(datos), os.path.getmtime(ficha))
172
+ except OSError:
173
+ return capabilities.descubre_ciudad(datos)
174
+ if _SKILLS.get("sello") == sello:
175
+ return _SKILLS["valor"]
176
+ valor = capabilities.descubre_ciudad(datos)
177
+ _SKILLS["sello"], _SKILLS["valor"] = sello, valor
178
+ return valor
179
+
180
+
181
+ def estado_seguro_agentes(datos):
182
+ """How many agents this city has, or none when the card cannot be read."""
183
+ owner = cities.lee_clave(datos, "owner") or seat.quien_soy()
184
+ try:
185
+ texto = card.lee(os.path.join(datos, f"{owner}.md")).get("texto") or ""
186
+ return workspace.agentes(texto, datos)
187
+ except (OSError, ValueError):
188
+ return []
189
+
190
+
191
+ def olvida_skills():
192
+ """Forget the discovery memo: a skill was installed or removed, and that
193
+ changes the answer without touching the card."""
194
+ _SKILLS.pop("sello", None)
195
+
196
+
197
+ def olvida_crecimiento(datos, slug):
198
+ """Drop one agent's remembered growth: it just changed for a known reason.
199
+
200
+ Mounting a folder changes exactly what growth counts, and a sheet that
201
+ answers with the pre-mount number for the next ninety seconds looks like
202
+ the mount did not take."""
203
+ _CRECIDO.pop((os.path.realpath(datos), slug), None)
204
+
205
+
123
206
  def _crecimiento_cacheado(a, datos, vida=90):
124
207
  import time
125
208
 
@@ -127,11 +210,18 @@ def _crecimiento_cacheado(a, datos, vida=90):
127
210
  momento, valor = _CRECIDO.get(clave, (0, None))
128
211
  if valor is not None and time.monotonic() - momento < vida:
129
212
  return valor
130
- try:
131
- valor = crecimiento.crece(a, datos, contador_git_local)
132
- except (OSError, ValueError, subprocess.SubprocessError):
133
- valor = {"floors": 0, "bricks": 0, "activity30": 0, "signal": "unavailable"}
134
- _CRECIDO[clave] = (time.monotonic(), valor)
213
+ # One walk at a time, and the loser of the race reads the winner's answer:
214
+ # this is a directory walk plus git behind a threaded server, so two tabs
215
+ # loading at once would otherwise both pay for it.
216
+ with _CRECIENDO:
217
+ momento, valor = _CRECIDO.get(clave, (0, None))
218
+ if valor is not None and time.monotonic() - momento < vida:
219
+ return valor
220
+ try:
221
+ valor = crecimiento.crece(a, datos, contador_git_local)
222
+ except (OSError, ValueError, subprocess.SubprocessError):
223
+ valor = {"floors": 0, "bricks": 0, "activity30": 0, "signal": "unavailable"}
224
+ _CRECIDO[clave] = (time.monotonic(), valor)
135
225
  return valor
136
226
 
137
227
 
@@ -157,11 +247,28 @@ def ficha_de_agente(a, texto, datos, ventanas):
157
247
  "avatar": avatar.data_uri(a.nombre, a.clase, semilla=semilla, rol=a.rol),
158
248
  "cli": estado_del_cli(a, ventanas),
159
249
  "legacy": a.legacy,
160
- "mounts": len(a.mounts),
250
+ "mounts": montajes_de_agente(a, datos),
161
251
  "growth": crecido,
162
252
  }
163
253
 
164
254
 
255
+ def montajes_de_agente(a, datos):
256
+ """What this agent actually works on: the mounts materialised in its
257
+ workspace, or the sources its card declares when nothing is on disk yet.
258
+
259
+ The sheet used to carry only how MANY there were, which is the one thing a
260
+ person cannot act on — you cannot unmount a number."""
261
+ if a.legacy:
262
+ return [{"label": a.slug, "target": a.workspace, "fixed": True}]
263
+ en_disco = workspace.mounts_en_disco(datos, a.slug)
264
+ if en_disco:
265
+ return [{"label": e, "target": t, "fixed": False} for e, t in en_disco]
266
+ return [
267
+ {"label": card.ventana(os.path.basename(str(m).rstrip("/"))), "target": m, "fixed": False}
268
+ for m in a.mounts
269
+ ]
270
+
271
+
165
272
  def binario_del_agente(a):
166
273
  """The executable behind this agent's runtime: the first token of the
167
274
  configured command, basename only — a path or flags never leak into which()."""
@@ -231,16 +338,7 @@ def hogar_escribible(a, datos, crear=True):
231
338
  return hogar, ""
232
339
 
233
340
 
234
- def raiz_de_skills(hogar):
235
- """The agent home's own `.claude/skills`, verified to still be inside the
236
- home after resolution. A repo can commit `.claude/skills` (or `.claude`)
237
- as a symlink to anywhere this user can write — the global skills folder
238
- included — and both the install and the rmtree would follow it. A linked
239
- root returns '' and every caller refuses."""
240
- real = os.path.realpath(os.path.join(hogar, ".claude", "skills"))
241
- if real != os.path.join(os.path.realpath(hogar), ".claude", "skills"):
242
- return ""
243
- return real
341
+ raiz_de_skills = workspace.raiz_de_skills
244
342
 
245
343
 
246
344
  def skills_con_gestion(a, hogar, skills):
@@ -262,64 +360,12 @@ def skills_con_gestion(a, hogar, skills):
262
360
  return fuera
263
361
 
264
362
 
265
- # One skill's extraction budget. The base64 cap in p_skill bounds the upload;
266
- # only these bound what a hostile deflate ratio inflates it to on disk.
267
- MAX_ENTRADAS_ZIP = 2048
268
- MAX_EXTRAIDO = 64 * 1024 * 1024
269
-
270
-
271
- def _zip_inseguro(entradas):
272
- """The shapes a hostile zip uses, refused by name before a byte lands."""
273
- if len(entradas) > MAX_ENTRADAS_ZIP:
274
- return "the zip holds too many files for one skill"
275
- if sum(info.file_size for info in entradas) > MAX_EXTRAIDO:
276
- return "the zip inflates too large for one skill"
277
- for info in entradas:
278
- nombre = info.filename
279
- if nombre.startswith(("/", "\\")) or ".." in nombre.split("/"):
280
- return "the zip tries to escape its folder"
281
- if (info.external_attr >> 16) & 0o170000 == 0o120000:
282
- return "symlinks in a skill are refused"
283
- return ""
284
-
285
-
286
- def _nombre_de_skill(entradas, pedido):
287
- """The skill's name and its base inside the zip: the single top folder when
288
- there is one, else the zip root plus a name from the payload."""
289
- raices = {e.filename.split("/")[0] for e in entradas if e.filename.strip("/")}
290
- con_carpeta = len(raices) == 1 and any(
291
- "/" in e.filename or e.filename.endswith("/") for e in entradas
292
- )
293
- base = (next(iter(raices)) + "/") if con_carpeta else ""
294
- crudo = next(iter(raices)) if con_carpeta else str(pedido or "")
295
- nombre = card.rol_seguro(crudo, defecto="")
296
- rutas = {e.filename[len(base):] for e in entradas if e.filename.startswith(base)}
297
- return nombre, base, rutas
298
-
299
-
300
- def _extrae_skill(archivo, entradas, base, destino):
301
- """Write the zip's files under `destino`, containment resolved twice and
302
- the decompressed bytes metered as they land — the headers already passed
303
- _zip_inseguro, but a header's declared size is the sender's word."""
304
- presupuesto = MAX_EXTRAIDO
305
- for info in entradas:
306
- relativa = info.filename[len(base):] if info.filename.startswith(base) else ""
307
- if not relativa or relativa.endswith("/"):
308
- continue
309
- ruta = os.path.realpath(os.path.join(destino, relativa))
310
- if not ruta.startswith(destino + os.sep) and ruta != destino:
311
- return "the zip tries to escape its folder"
312
- os.makedirs(os.path.dirname(ruta), exist_ok=True)
313
- with archivo.open(info) as origen, open(ruta, "wb") as salida:
314
- while True:
315
- trozo = origen.read(1024 * 64)
316
- if not trozo:
317
- break
318
- presupuesto -= len(trozo)
319
- if presupuesto < 0:
320
- return "the zip inflates too large for one skill"
321
- salida.write(trozo)
322
- return ""
363
+ # The zip guards and the skills root live in workspace.py: the Hall's upload and
364
+ # the wizard's question are two doors onto one deliberate write, and containment
365
+ # rules that exist twice are containment rules that drift apart.
366
+ _zip_inseguro = workspace.zip_inseguro
367
+ _nombre_de_skill = workspace._nombre_de_skill_en_zip
368
+ _extrae_skill = workspace.extrae_zip
323
369
 
324
370
 
325
371
  def _instala_skill(archivo, entradas, base, nombre, hogar):
@@ -393,10 +439,20 @@ def lee_clave(fichero, clave, defecto=""):
393
439
 
394
440
 
395
441
  def lista_con(datos):
442
+ """Every city this owner has, with the one being looked at marked — and how
443
+ many agents live in each.
444
+
445
+ The count is not decoration: an agent belongs to ONE city because its
446
+ workspace and its mounts live inside that city's folder, and a menu that
447
+ lists "Cities" and "Agents" as two unrelated things hides exactly that. One
448
+ card read per city, which is cheaper than the page that draws it.
449
+ """
396
450
  real = os.path.realpath(datos)
397
451
  fuera = [dict(c, actual=(c["ruta"] == real)) for c in cities.lista(seat.quien_soy())]
398
452
  if not any(c["actual"] for c in fuera):
399
453
  fuera.insert(0, {"ruta": real, "nombre": cities.nombre(real), "actual": True})
454
+ for c in fuera:
455
+ c["agentes"] = len(estado_seguro_agentes(c["ruta"]))
400
456
  return fuera
401
457
 
402
458
 
@@ -466,6 +522,14 @@ def actividad_viva(datos):
466
522
  return {"online": False, "url": "", "city": "", "started_at": ""}
467
523
 
468
524
 
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:
529
+ return ""
530
+ return time.strftime("%Y-%m-%d", time.localtime(cuando))
531
+
532
+
469
533
  def carpetas_probables():
470
534
  """Existing folders under $HOME that look like where the work lives."""
471
535
  casa = os.path.expanduser("~")
@@ -646,8 +710,12 @@ class Manejador(http.server.BaseHTTPRequestHandler):
646
710
  "/api/mapa": "p_mapa",
647
711
  "/api/sesion": "p_sesion",
648
712
  "/api/ciudades": "p_ciudades",
713
+ "/api/ciudad-archiva": "p_archiva_ciudad",
714
+ "/api/ciudad-reinicia": "p_reinicia_ciudad",
649
715
  "/api/roads": "p_roads",
650
716
  "/api/agente": "p_agente",
717
+ "/api/agentes": "p_agentes",
718
+ "/api/montaje": "p_montaje",
651
719
  "/api/motor": "p_motor",
652
720
  "/api/instrucciones": "p_instrucciones",
653
721
  "/api/skill": "p_skill",
@@ -713,7 +781,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
713
781
  retratos = {"seat": avatar.data_uri("seat", "coordinator", rol="chair")}
714
782
  agentes = []
715
783
  ventanas = ventanas_vivas(owner, datos)
716
- habilidades = capabilities.descubre_ciudad(datos)
784
+ habilidades = skills_de_ciudad(datos)
717
785
  for f in sorted(glob_fichas(datos)):
718
786
  c = card.lee(f)
719
787
  if c.get("user") == owner:
@@ -773,6 +841,10 @@ class Manejador(http.server.BaseHTTPRequestHandler):
773
841
  "avatars": retratos,
774
842
  "agents": agentes,
775
843
  "demo": es_demo(datos),
844
+ # A person who opened the Hall deliberately is exactly who
845
+ # should hear that a newer version exists; the check is cached
846
+ # for a day and never runs on a plain terminal command.
847
+ "update": actualiza.aviso(),
776
848
  "paleta": [{"hex": c_, "nombre": n} for c_, n in W.PALETA],
777
849
  }
778
850
  )
@@ -781,28 +853,36 @@ class Manejador(http.server.BaseHTTPRequestHandler):
781
853
  return self.responde(actividad_viva(self.ciudad(q)))
782
854
 
783
855
  def g_misrepos(self, q):
784
- # The whole disk by remote (find-repos.sh's cached index), with the
785
- # ones this person has commits in already marked — the seat's own
786
- # question, served to a page instead of a terminal.
787
- quien = q.get("user", [seat.quien_soy()])[0]
788
- # ?refresh=1 rebuilds the disk index first. The cache lasts a day, and
789
- # the repo somebody just cloned is exactly the one they came to tick.
790
- if q.get("refresh", [""])[0]:
791
- gh.sh(
792
- [
793
- os.path.join(os.path.dirname(AQUI), "plugin", "scripts", "find-repos.sh"),
794
- "--refresh",
795
- ],
796
- timeout=600,
797
- )
798
- todos = seat.repos_del_disco()
799
- correo = gh.sh(["git", "config", "user.email"]).strip()
800
- mios = seat.mios(quien, correo, todos)
856
+ """Everything on this disk an agent could be given to work on.
857
+
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.
861
+
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.
867
+ """
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.
870
+ try:
871
+ sitios = busca.lugares(refrescar=bool(q.get("refresh", [""])[0]))
872
+ except OSError:
873
+ sitios = []
874
+ frontera = time.time() - 60 * 86400
801
875
  return self.responde(
802
876
  {
803
877
  "repos": [
804
- {"nombre": n, "ruta": r, "cuando": seat.ultimo_commit(r), "mio": n in mios}
805
- for n, r in todos
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
806
886
  ]
807
887
  }
808
888
  )
@@ -917,38 +997,39 @@ class Manejador(http.server.BaseHTTPRequestHandler):
917
997
  dominio = domains.canonico(cuerpo.get("domain") or domains.de_ciudad(datos))
918
998
  if not domains.obtiene(dominio):
919
999
  return self.responde({"error": f"unknown domain: {dominio}"}, codigo=400)
920
- repos_e = [str(r) for r in (cuerpo.get("repos") or [])]
921
- repo_roles_raw = cuerpo.get("repo_roles", {})
922
- if repo_roles_raw is None:
923
- repo_roles_raw = {}
924
- if not isinstance(repo_roles_raw, dict):
925
- return self.responde({"error": "repo_roles must be an object"}, codigo=400)
1000
+ # The roster is NOT this endpoint's business: /api/agentes and
1001
+ # /api/montaje own it, and the wizard owns it in the terminal. What is
1002
+ # already on the card is carried over verbatim, so saving your seat
1003
+ # never silently empties your city.
1004
+ ficha_previa = os.path.join(datos, f"{quien}.md")
926
1005
  try:
927
- repo_roles = card.normaliza_roles_repos(repos_e, repo_roles_raw)
928
- except ValueError as e:
929
- return self.responde({"error": str(e)}, codigo=400)
930
- obj = cuerpo.get("objetivo") or None
931
- if obj is not None and not isinstance(obj, dict):
932
- obj = None
933
- if obj and not str(obj.get("title", "")).strip():
934
- obj = None
1006
+ texto_previo = card.lee(ficha_previa).get("texto") or ""
1007
+ roster = [workspace.como_ficha(a) for a in workspace.agentes(texto_previo, datos)]
1008
+ except (OSError, ValueError):
1009
+ roster = []
1010
+ # Absent means unchanged, present means replace. This endpoint rewrites
1011
+ # the whole card, so anything it does not carry over is destroyed: a
1012
+ # page that saves your role must not silently drop the goal you set in
1013
+ # the terminal (the roster above is the same rule).
1014
+ if "objetivo" not in cuerpo:
1015
+ obj = card.objetivo(texto_previo, quien)
1016
+ else:
1017
+ obj = cuerpo.get("objetivo") or None
1018
+ if obj is not None and not isinstance(obj, dict):
1019
+ obj = None
1020
+ if obj and not str(obj.get("title", "")).strip():
1021
+ obj = None
935
1022
  try:
936
1023
  seat.escribe_ficha(
937
- os.path.join(datos, f"{quien}.md"),
938
- quien,
939
- rol,
940
- repos_e,
941
- obj,
942
- cities.slug_ciudad(datos),
943
- repo_roles,
1024
+ ficha_previa, quien, rol, roster, obj, cities.slug_ciudad(datos)
944
1025
  )
945
1026
  hechos = []
946
1027
  if cities.gestionada(datos, quien):
947
- hechos = seat.escribe_suelo(datos, repos_e, dominio)
1028
+ hechos = seat.escribe_suelo(datos, [a["nombre"] for a in roster], dominio)
948
1029
  domains.selecciona(datos, dominio)
949
1030
  hechos += domains.materializa(datos, dominio, rol)
950
- for rol_repo in sorted(set(repo_roles.values()) - {"blank", rol}):
951
- hechos += domains.materializa(datos, dominio, rol_repo)
1031
+ for rol_agente in sorted({a["rol"] for a in roster} - {"blank", rol}):
1032
+ hechos += domains.materializa(datos, dominio, rol_agente)
952
1033
  except OSError as e:
953
1034
  return self.responde({"error": str(e)}, codigo=500)
954
1035
  cities.registra(datos)
@@ -1009,6 +1090,70 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1009
1090
  {"ok": True, "city": datos, "address": cities.direccion(usuario, datos)}
1010
1091
  )
1011
1092
 
1093
+ def p_archiva_ciudad(self, q, cuerpo):
1094
+ """Take one city out of use — recoverably, and never the last one.
1095
+
1096
+ Not a delete: `cities.archiva` MOVES the folder into the owner's
1097
+ backups. A city is somebody's cards, deliberations and map, and a
1098
+ product that erases that on one click will erase the wrong one.
1099
+ """
1100
+ pedida = str(cuerpo.get("city") or "")
1101
+ usuario = seat.quien_soy()
1102
+ datos = cities.resuelve(pedida, usuario) if pedida else ""
1103
+ if not datos:
1104
+ return self.responde({"error": f"no city called {pedida!r}"}, codigo=404)
1105
+ try:
1106
+ copia = cities.archiva(datos, usuario)
1107
+ except (OSError, ValueError) as e:
1108
+ return self.responde({"error": str(e)}, codigo=409)
1109
+ return self.responde(
1110
+ {"ok": True, "backup": copia, "ciudades": lista_con(cities.actual(usuario))}
1111
+ )
1112
+
1113
+ def p_reinicia_ciudad(self, q, cuerpo):
1114
+ """Take a city back to its first day — after showing exactly what that
1115
+ means, and only when the person types its name.
1116
+
1117
+ Two calls, on purpose. Without `confirm` it answers with the effects and
1118
+ changes nothing; with `confirm` matching the city's own name it does the
1119
+ work. A dangerous button that fires on one click will eventually be
1120
+ clicked by an elbow, and the answer "are you sure?" is not information —
1121
+ the list below is.
1122
+ """
1123
+ datos = self.ciudad(q)
1124
+ usuario = cities.lee_clave(datos, "owner") or seat.quien_soy()
1125
+ nombre = cities.nombre(datos)
1126
+ try:
1127
+ copia = reinicio.reinicia(datos, usuario, dry_run=True)
1128
+ except (OSError, ValueError) as e:
1129
+ return self.responde({"error": str(e)}, codigo=409)
1130
+ agentes = len(estado_seguro_agentes(datos))
1131
+ efectos = {
1132
+ "city": nombre,
1133
+ "backup": copia,
1134
+ "agents": agentes,
1135
+ "roads": len(roads.lee(datos)),
1136
+ "deliberations": len(deliberations.lista(datos)),
1137
+ "keeps": [
1138
+ "every repository and folder your agents mount — untouched",
1139
+ f"a full copy of this city at {copia}",
1140
+ ],
1141
+ "loses": [
1142
+ "your seat card: role, goal and the whole roster",
1143
+ "each agent's workspace, its mounts and the skills installed in it",
1144
+ "the committee history and every recorded decision",
1145
+ "the map's districts and houses",
1146
+ ],
1147
+ }
1148
+ if str(cuerpo.get("confirm") or "") != nombre:
1149
+ return self.responde({"ok": False, "preview": efectos})
1150
+ try:
1151
+ copia = reinicio.reinicia(datos, usuario)
1152
+ except (OSError, ValueError) as e:
1153
+ return self.responde({"error": str(e)}, codigo=409)
1154
+ olvida_skills()
1155
+ return self.responde({"ok": True, "backup": copia, "preview": efectos})
1156
+
1012
1157
  def p_roads(self, q, cuerpo):
1013
1158
  origen = self.ciudad(q)
1014
1159
  accion = str(cuerpo.get("action") or "")
@@ -1100,6 +1245,115 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1100
1245
  "avatar": ("avatar", re.compile(r"[a-z0-9-]{1,16}"), "an avatar seed is short and plain"),
1101
1246
  }
1102
1247
 
1248
+ def p_agentes(self, q, cuerpo):
1249
+ """Add one agent to this city, from the web: name, kind and role.
1250
+
1251
+ The same roster the wizard builds one question at a time, reachable
1252
+ from the Hall — a person who never opens a terminal must be able to say
1253
+ who works in their city, not just tune whoever is already there. Its
1254
+ engine, mounts and skills are the sheet's own controls afterwards.
1255
+ """
1256
+ datos = self.ciudad(q)
1257
+ owner = cities.lee_clave(datos, "owner") or seat.quien_soy()
1258
+ ficha = os.path.join(datos, f"{owner}.md")
1259
+ texto = card.lee(ficha).get("texto") or ""
1260
+ if not texto:
1261
+ return self.responde({"error": "this city has no owner card yet"}, codigo=409)
1262
+ nombre = " ".join(str(cuerpo.get("name") or "").split())
1263
+ slug = card.ventana(nombre)
1264
+ if not nombre or not card.ventana_valida(slug):
1265
+ return self.responde({"error": "an agent needs a plain name"}, codigo=400)
1266
+ clase = str(cuerpo.get("kind") or workspace.CLASE_DEFECTO).strip().lower()
1267
+ if clase not in workspace.CLASES:
1268
+ return self.responde(
1269
+ {"error": "kind is code, knowledge or coordinator"}, codigo=400
1270
+ )
1271
+ rol = card.rol_seguro(str(cuerpo.get("role") or "blank"), defecto="")
1272
+ if not rol:
1273
+ return self.responde({"error": "that is not a role id"}, codigo=400)
1274
+ try:
1275
+ ya = workspace.agentes(texto, datos)
1276
+ except ValueError as e:
1277
+ return self.responde({"error": str(e)}, codigo=409)
1278
+ if any(x.slug == slug for x in ya):
1279
+ return self.responde({"error": f"{slug} is already an agent here"}, codigo=409)
1280
+
1281
+ # A legacy `repos:` card is upgraded in place rather than half-migrated:
1282
+ # every repo it listed is written back as the agent it always was, and
1283
+ # the new one joins them. Nothing that was working stops working. The
1284
+ # keys come from workspace, which owns what a roster looks like on a
1285
+ # card — the wizard writes the very same ones after its seven questions.
1286
+ roster = [workspace.como_ficha(x) for x in ya]
1287
+ roster.append(
1288
+ {"nombre": nombre, "slug": slug, "clase": clase, "rol": rol,
1289
+ "mounts": [], "motor": {}, "skills": []}
1290
+ )
1291
+ for clave, valor in workspace.claves_de_roster(roster).items():
1292
+ card.pon_campo(ficha, clave, valor)
1293
+ workspace.crea_workspace(datos, slug)
1294
+ return self.responde({"ok": True, "agent": slug, "name": nombre})
1295
+
1296
+ def p_montaje(self, q, cuerpo):
1297
+ """Add or remove one of an agent's mounts — a repo, a worktree, or a
1298
+ folder of documents — from the Hall.
1299
+
1300
+ The card is what the launcher and the cage read, so both the symlink and
1301
+ the card key move together; a mount that exists on disk but not on the
1302
+ card would vanish on the next sync.
1303
+ """
1304
+ a, ficha, datos = self._agente(q, cuerpo.get("agent"))
1305
+ if not a:
1306
+ return self.responde({"error": "no such agent here"}, codigo=404)
1307
+ if a.legacy:
1308
+ return self.responde(
1309
+ {"error": "this agent is a legacy repo; add an agents-first one to mount folders"},
1310
+ codigo=409,
1311
+ )
1312
+ quitar = str(cuerpo.get("remove") or "").strip()
1313
+ anadir = str(cuerpo.get("add") or "").strip()
1314
+ fuentes = list(a.mounts)
1315
+ if quitar:
1316
+ reales = {e: t for e, t in workspace.mounts_en_disco(datos, a.slug)}
1317
+ etiqueta = card.ventana(quitar)
1318
+ if etiqueta not in reales:
1319
+ return self.responde({"error": f"{quitar} is not mounted here"}, codigo=404)
1320
+ destino = reales[etiqueta]
1321
+ workspace.desmonta(datos, a.slug, etiqueta)
1322
+ fuentes = [
1323
+ m for m in fuentes
1324
+ if os.path.realpath(os.path.expanduser(m)) != destino
1325
+ and card.ventana(os.path.basename(str(m).rstrip("/"))) != etiqueta
1326
+ ]
1327
+ elif anadir:
1328
+ 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)
1331
+ try:
1332
+ workspace.monta(datos, a.slug, destino)
1333
+ except (OSError, ValueError) as e:
1334
+ return self.responde({"error": str(e)}, codigo=400)
1335
+ if destino not in [os.path.realpath(os.path.expanduser(m)) for m in fuentes]:
1336
+ fuentes.append(destino)
1337
+ else:
1338
+ return self.responde({"error": "add or remove a folder"}, codigo=400)
1339
+ # An empty value REMOVES the key: `mounts.x: []` is a leftover that says
1340
+ # "this agent declares no mounts" in a longer, staler way.
1341
+ lista = ("[" + ", ".join(fuentes) + "]") if fuentes else ""
1342
+ card.pon_campo(ficha, f"mounts.{a.slug}", lista)
1343
+ # What this agent works on just changed, which is exactly what growth
1344
+ # counts: remembering the old number for 90s would read as a failed mount.
1345
+ olvida_crecimiento(datos, a.slug)
1346
+ return self.responde(
1347
+ {
1348
+ "ok": True,
1349
+ "agent": a.slug,
1350
+ "mounts": [
1351
+ {"label": e, "target": t}
1352
+ for e, t in workspace.mounts_en_disco(datos, a.slug)
1353
+ ],
1354
+ }
1355
+ )
1356
+
1103
1357
  def p_agente(self, q, cuerpo):
1104
1358
  """Tune one agent from its character sheet: model, effort, runtime and
1105
1359
  avatar seed, written to the card keys the launcher already resolves. An
@@ -1154,7 +1408,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1154
1408
  except ValueError:
1155
1409
  return None, None, None
1156
1410
  pedido = str(nombre or "").strip().lower()
1157
- if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,79}", pedido):
1411
+ if not card.ventana_valida(pedido):
1158
1412
  pedido = ""
1159
1413
  return agentes.get(pedido), ficha, datos
1160
1414
 
@@ -1256,6 +1510,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1256
1510
  destino, mal, codigo = _instala_skill(archivo, entradas, base_zip, nombre_skill, hogar)
1257
1511
  if mal:
1258
1512
  return self.responde({"error": mal}, codigo=codigo)
1513
+ olvida_skills()
1259
1514
  return self.responde({"ok": True, "agent": a.slug, "skill": nombre_skill, "home": destino})
1260
1515
 
1261
1516
  def p_motor(self, q, cuerpo):
@@ -1326,6 +1581,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1326
1581
  {"error": f"no skill called {nombre} in this agent's home"}, codigo=404
1327
1582
  )
1328
1583
  shutil.rmtree(destino)
1584
+ olvida_skills()
1329
1585
  return self.responde({"ok": True, "agent": a.slug, "removed": nombre})
1330
1586
 
1331
1587
  def p_demo(self, q, cuerpo):