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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +310 -70
- package/README.md +297 -69
- package/bin/agents-city.js +3 -0
- package/bin/doctor +3 -0
- package/bin/hall.html +164 -24
- package/bin/navegador.mjs +415 -0
- package/bin/serve.py +383 -127
- package/bin/shortcut +3 -0
- package/bin/test +5 -2
- package/bin/test-actualiza.py +130 -0
- package/bin/test-atajos.py +301 -0
- package/bin/test-busca.py +216 -0
- package/bin/test-cage.py +170 -2
- package/bin/test-card.py +2 -2
- package/bin/test-cities.py +45 -0
- package/bin/test-contracts.py +12 -5
- package/bin/test-doctor.py +33 -0
- package/bin/test-navegador.py +164 -0
- package/bin/test-seat.py +245 -25
- package/bin/test-serve.py +214 -9
- package/bin/test-workspace.py +63 -0
- package/bin/testlib.py +23 -0
- package/bin/update +3 -0
- package/city/web/dist/city.js +47 -47
- package/city/web/dist/index.html +1 -1
- package/city/web/dist-hall/hall.js +2193 -174
- package/city/web/src/bienvenida.ts +686 -0
- package/city/web/src/es.ts +180 -0
- package/city/web/src/hall.ts +520 -168
- package/city/web/src/idioma.ts +86 -0
- package/city/web/src/main.ts +27 -0
- package/city/web/src/motores.ts +54 -0
- package/docs/agents-first.md +8 -1
- package/docs/security.md +46 -12
- package/docs/testing.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/bus.js +1 -1
- package/plugin/channel/bus.ts +1 -1
- package/plugin/channel/runtime/codex.ts +1 -1
- package/plugin/channel/runtime-gateway.js +1 -1
- package/plugin/scripts/actualiza.py +198 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/busca.py +436 -0
- package/plugin/scripts/cage.py +266 -26
- package/plugin/scripts/capabilities.py +17 -10
- package/plugin/scripts/card.py +10 -0
- package/plugin/scripts/cities.py +34 -0
- package/plugin/scripts/city-session.sh +33 -7
- package/plugin/scripts/doctor.py +122 -0
- package/plugin/scripts/find-repos.sh +12 -105
- package/plugin/scripts/read-card.py +6 -2
- package/plugin/scripts/report.py +5 -6
- package/plugin/scripts/reset.py +50 -14
- package/plugin/scripts/seat.py +445 -103
- 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
|
|
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, "
|
|
103
|
+
["git", "-C", ruta, "log", "--format=%ct %p", "HEAD"],
|
|
92
104
|
capture_output=True,
|
|
93
105
|
text=True,
|
|
94
|
-
timeout=
|
|
106
|
+
timeout=30,
|
|
95
107
|
)
|
|
96
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
#
|
|
266
|
-
#
|
|
267
|
-
|
|
268
|
-
|
|
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 =
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
{
|
|
805
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
951
|
-
hechos += domains.materializa(datos, dominio,
|
|
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
|
|
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):
|