agents-city 0.5.2 → 0.5.4
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 +11 -0
- package/README.md +10 -0
- package/benchmarks/latency/fake-claude-cli.mjs +3 -2
- package/bin/serve.py +63 -6
- package/bin/test +2 -2
- package/bin/test-alcance.py +470 -0
- package/bin/test-arnes.py +109 -0
- package/bin/test-channel.py +295 -9
- package/bin/test-cities.py +57 -0
- package/bin/test-claude-runtime.py +17 -6
- package/bin/test-connect-client.mjs +8 -0
- package/bin/test-connect.py +1 -1
- package/bin/test-contracts.py +129 -3
- package/bin/test-e2e.py +371 -0
- package/bin/test-seat.py +154 -1
- package/bin/test-serve.py +83 -0
- package/city/web/dist-hall/hall.js +384 -3240
- package/docs/managed-connect.md +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/hub/lo-que-te-llega.ts +78 -0
- package/plugin/channel/hub/local-roads.ts +36 -0
- package/plugin/channel/hub/road-controller.ts +30 -5
- package/plugin/channel/local-hub.js +177 -105
- package/plugin/channel/local-hub.ts +8 -0
- package/plugin/channel/managed-connect-client.js +53 -3
- package/plugin/channel/managed-connect-client.manifest.json +2 -2
- package/plugin/channel/protocol.ts +24 -0
- package/plugin/commands/notice.md +14 -0
- package/plugin/hooks/ask-the-house.sh +25 -0
- package/plugin/hooks/hooks.json +10 -0
- package/plugin/hooks/notice-on-stop.sh +7 -1
- package/plugin/scripts/alcance.py +318 -0
- package/plugin/scripts/arnes.py +10 -1
- package/plugin/scripts/busca.py +13 -0
- package/plugin/scripts/card.py +40 -0
- package/plugin/scripts/cities.py +46 -4
- package/plugin/scripts/city-session.sh +80 -7
- package/plugin/scripts/domains.py +7 -0
- package/plugin/scripts/seat.py +33 -13
- package/plugin/skills/city/SKILL.md +12 -0
package/bin/test-seat.py
CHANGED
|
@@ -1101,6 +1101,56 @@ def motores_del_puesto():
|
|
|
1101
1101
|
)
|
|
1102
1102
|
|
|
1103
1103
|
|
|
1104
|
+
def el_settings_sobrevive_a_la_shell(asiento, api):
|
|
1105
|
+
"""The launch line, parsed the way a shell parses it.
|
|
1106
|
+
|
|
1107
|
+
Asserting the words appear somewhere in the string is not enough, and the
|
|
1108
|
+
gap cost every Claude window in a city: `--settings {"a":"b","c":true}`
|
|
1109
|
+
emitted without quotes is split by brace expansion on the comma and
|
|
1110
|
+
stripped of its double quotes, so what reached Claude was `{a:b}` and it
|
|
1111
|
+
exited with "Invalid JSON provided to --settings". The string contained
|
|
1112
|
+
every word a substring check looks for.
|
|
1113
|
+
"""
|
|
1114
|
+
import shlex
|
|
1115
|
+
|
|
1116
|
+
def ajustes_de(linea):
|
|
1117
|
+
"""The `--settings` value, dug out through however many shells wrap it.
|
|
1118
|
+
|
|
1119
|
+
A repo window is launched through `tmux send-keys`, so its whole command
|
|
1120
|
+
is one quoted word inside the line; the chair's is not. Splitting once
|
|
1121
|
+
and giving up would test the chair and quietly skip the houses — which
|
|
1122
|
+
is exactly the window that broke.
|
|
1123
|
+
"""
|
|
1124
|
+
pendientes, visto = [linea], 0
|
|
1125
|
+
while pendientes and visto < 6:
|
|
1126
|
+
visto += 1
|
|
1127
|
+
actual = pendientes.pop(0)
|
|
1128
|
+
try:
|
|
1129
|
+
palabras = shlex.split(actual)
|
|
1130
|
+
except ValueError:
|
|
1131
|
+
continue
|
|
1132
|
+
if "--settings" in palabras:
|
|
1133
|
+
return palabras[palabras.index("--settings") + 1]
|
|
1134
|
+
pendientes.extend(x for x in palabras if "--settings" in x and x != actual)
|
|
1135
|
+
return None
|
|
1136
|
+
|
|
1137
|
+
for etiqueta, linea in (("the chair", asiento), ("an agent house", api)):
|
|
1138
|
+
crudo = ajustes_de(linea)
|
|
1139
|
+
if crudo is None:
|
|
1140
|
+
afirma(f"· {etiqueta} is launched with --settings", False, linea[:250])
|
|
1141
|
+
continue
|
|
1142
|
+
try:
|
|
1143
|
+
ajustes = json.loads(crudo)
|
|
1144
|
+
except json.JSONDecodeError as e:
|
|
1145
|
+
afirma(f"· {etiqueta}'s --settings is still JSON after the shell has it",
|
|
1146
|
+
False, f"{crudo!r}: {e}")
|
|
1147
|
+
continue
|
|
1148
|
+
afirma(f"· {etiqueta}'s --settings is still JSON after the shell has it",
|
|
1149
|
+
isinstance(ajustes, dict), crudo)
|
|
1150
|
+
comprueba(f"· and {etiqueta} still refuses cross-session inbound",
|
|
1151
|
+
ajustes.get("crossSessionInbound"), "refuse")
|
|
1152
|
+
|
|
1153
|
+
|
|
1104
1154
|
def arranque_escalonado():
|
|
1105
1155
|
"""Claude windows must not all start in the same millisecond.
|
|
1106
1156
|
|
|
@@ -1205,10 +1255,16 @@ def arranque_escalonado():
|
|
|
1205
1255
|
|
|
1206
1256
|
registro = os.path.join(casa, "tmux.log")
|
|
1207
1257
|
falso = os.path.join(fbin, "tmux")
|
|
1258
|
+
# A tmux that writes down what it is told. FAKE_SESSION/FAKE_WINDOWS let a
|
|
1259
|
+
# test say "this city is already open, with these windows in it", which is
|
|
1260
|
+
# the only way to exercise the reconcile path without a real tmux server.
|
|
1208
1261
|
open(falso, "w").write(
|
|
1209
1262
|
'#!/bin/bash\nprintf "%s\\n" "$*" >> '
|
|
1210
1263
|
+ registro
|
|
1211
|
-
+ '\
|
|
1264
|
+
+ '\nif [ "$1" = "has-session" ]; then [ -n "${FAKE_SESSION:-}" ] || exit 1; exit 0; fi'
|
|
1265
|
+
+ '\nif [ "$1" = "list-windows" ]; then'
|
|
1266
|
+
+ ' for w in ${FAKE_WINDOWS:-}; do echo "$w"; done; exit 0; fi'
|
|
1267
|
+
+ '\nexit 0\n'
|
|
1212
1268
|
)
|
|
1213
1269
|
os.chmod(falso, 0o755)
|
|
1214
1270
|
falso_node = os.path.join(fbin, "node")
|
|
@@ -1310,6 +1366,8 @@ def arranque_escalonado():
|
|
|
1310
1366
|
"CITY_BUS_URL= CITY_BUS_TOKEN=" in api and "CITY_BUS_URL= CITY_BUS_TOKEN=" in docs,
|
|
1311
1367
|
)
|
|
1312
1368
|
claude_contract = (asiento + "\n" + api).replace("\\", "")
|
|
1369
|
+
el_settings_sobrevive_a_la_shell(asiento, api)
|
|
1370
|
+
|
|
1313
1371
|
afirma(
|
|
1314
1372
|
"· Claude native peer messaging is refused and its tools denied",
|
|
1315
1373
|
claude_contract.count("crossSessionInbound") == 2
|
|
@@ -1446,6 +1504,101 @@ def arranque_escalonado():
|
|
|
1446
1504
|
"city-runtime.sh gateway seat" in asiento, asiento[:200],
|
|
1447
1505
|
)
|
|
1448
1506
|
card.pon_campo(ficha, "ui.seat", "")
|
|
1507
|
+
|
|
1508
|
+
# ── a city that is already open ──────────────────────────────────────────
|
|
1509
|
+
#
|
|
1510
|
+
# Adding a house to a running city used to do nothing you could see. The
|
|
1511
|
+
# launcher's first act was "session exists → attach", so the card had four
|
|
1512
|
+
# agents and tmux had three windows, and closing the terminal did not help:
|
|
1513
|
+
# detaching from a tmux session is not ending it. The only way to meet the
|
|
1514
|
+
# new agent was to kill the city — every agent running in it — and start
|
|
1515
|
+
# over.
|
|
1516
|
+
#
|
|
1517
|
+
# These read the RAW tmux log rather than the decoded launch commands,
|
|
1518
|
+
# because what is being asserted is which windows were created and which
|
|
1519
|
+
# were left alone.
|
|
1520
|
+
def crudo(**extra):
|
|
1521
|
+
corre(**extra)
|
|
1522
|
+
return open(registro).read()
|
|
1523
|
+
|
|
1524
|
+
log = crudo(FAKE_SESSION="1", FAKE_WINDOWS="seat api", CITY_SETTLE="0", CITY_STAGGER="0")
|
|
1525
|
+
afirma(
|
|
1526
|
+
"· happy: an agent added to a running city gets its window",
|
|
1527
|
+
"new-window" in log and " -n docs " in log,
|
|
1528
|
+
log[:600],
|
|
1529
|
+
)
|
|
1530
|
+
afirma(
|
|
1531
|
+
"· and the window that was already open is not created twice",
|
|
1532
|
+
log.count(" -n api ") == 0,
|
|
1533
|
+
log[:600],
|
|
1534
|
+
)
|
|
1535
|
+
afirma(
|
|
1536
|
+
"· non-happy: nothing is killed, ever",
|
|
1537
|
+
"kill-window" not in log and "kill-session" not in log,
|
|
1538
|
+
log[:600],
|
|
1539
|
+
)
|
|
1540
|
+
afirma(
|
|
1541
|
+
"· non-happy: and the running session is not re-created underneath it",
|
|
1542
|
+
"new-session" not in log,
|
|
1543
|
+
log[:600],
|
|
1544
|
+
)
|
|
1545
|
+
afirma(
|
|
1546
|
+
"· the new house is what you are looking at when it opens",
|
|
1547
|
+
"select-window" in log and ":docs" in log.split("select-window")[-1][:40],
|
|
1548
|
+
log.split("select-window")[-1][:80],
|
|
1549
|
+
)
|
|
1550
|
+
lineas = corre(FAKE_SESSION="1", FAKE_WINDOWS="seat api", CITY_SETTLE="0", CITY_STAGGER="0")
|
|
1551
|
+
afirma(
|
|
1552
|
+
"· non-happy: no runtime is started in a chair that is already sitting",
|
|
1553
|
+
not any("CITY_BUS_ACTOR=seat" in l for l in lineas)
|
|
1554
|
+
and any("CITY_BUS_ACTOR=docs" in l for l in lineas),
|
|
1555
|
+
str(lineas)[:400],
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
log = crudo(FAKE_SESSION="1", FAKE_WINDOWS="seat api docs", CITY_SETTLE="0", CITY_STAGGER="0")
|
|
1559
|
+
afirma(
|
|
1560
|
+
"· happy: a city already whole opens no windows at all",
|
|
1561
|
+
" -n api " not in log and " -n docs " not in log and "new-session" not in log,
|
|
1562
|
+
log[:600],
|
|
1563
|
+
)
|
|
1564
|
+
afirma("· and still attaches", "attach" in log, log[-200:])
|
|
1565
|
+
|
|
1566
|
+
# The seat is the one window whose absence is not somebody's work in
|
|
1567
|
+
# progress: a city without its chair is not a city.
|
|
1568
|
+
log = crudo(FAKE_SESSION="1", FAKE_WINDOWS="api docs", CITY_SETTLE="0", CITY_STAGGER="0")
|
|
1569
|
+
afirma(
|
|
1570
|
+
"· happy: a session that outlived its own chair gets the chair back",
|
|
1571
|
+
" -n seat " in log,
|
|
1572
|
+
log[:600],
|
|
1573
|
+
)
|
|
1574
|
+
lineas = corre(FAKE_SESSION="1", FAKE_WINDOWS="api docs", CITY_SETTLE="0", CITY_STAGGER="0")
|
|
1575
|
+
afirma(
|
|
1576
|
+
"· and only the chair — the houses are left running",
|
|
1577
|
+
any("CITY_BUS_ACTOR=seat" in l for l in lineas)
|
|
1578
|
+
and not any("CITY_BUS_ACTOR=api" in l for l in lineas),
|
|
1579
|
+
str(lineas)[:400],
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
# A window whose agent left the card may be mid-task. Say so; do not close
|
|
1583
|
+
# it. The owner decides what to do with somebody's unfinished work.
|
|
1584
|
+
sobra = subprocess.run(
|
|
1585
|
+
["bash", guion, "ana", "--claude", "--no-sync"],
|
|
1586
|
+
capture_output=True, text=True, cwd=casa,
|
|
1587
|
+
env=dict(os.environ, HOME=casa, AGENTS_CITY_DATA=datos, CITY_CODE_DIR=codigo,
|
|
1588
|
+
PATH=fbin + os.pathsep + os.environ["PATH"],
|
|
1589
|
+
FAKE_SESSION="1", FAKE_WINDOWS="seat api docs viejo"),
|
|
1590
|
+
)
|
|
1591
|
+
afirma(
|
|
1592
|
+
"· non-happy: a window no longer on the card is reported, not closed",
|
|
1593
|
+
"viejo" in sobra.stderr and "kill-window" in sobra.stderr,
|
|
1594
|
+
sobra.stderr[-400:],
|
|
1595
|
+
)
|
|
1596
|
+
afirma(
|
|
1597
|
+
"· and the killing is left to the person whose work it is",
|
|
1598
|
+
"kill-window" not in open(registro).read(),
|
|
1599
|
+
open(registro).read()[:400],
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1449
1602
|
shutil.rmtree(casa)
|
|
1450
1603
|
|
|
1451
1604
|
|
package/bin/test-serve.py
CHANGED
|
@@ -937,7 +937,52 @@ def main():
|
|
|
937
937
|
not os.path.exists(os.path.join(hallDatos, "units.yml"))
|
|
938
938
|
or "u1" not in open(os.path.join(hallDatos, "units.yml")).read(),
|
|
939
939
|
)
|
|
940
|
+
# Observing must not create.
|
|
941
|
+
#
|
|
942
|
+
# The journal runs after the handler, and it used to resolve its city
|
|
943
|
+
# through `ciudad`, which does `makedirs` and writes `city.yml`. On a
|
|
944
|
+
# request that archived a city, the log line recreated the folder that
|
|
945
|
+
# had just been taken away — a ghost city, left behind by the act of
|
|
946
|
+
# recording that it had gone. It also raced this very cleanup.
|
|
940
947
|
shutil.rmtree(otra)
|
|
948
|
+
# The resolver the journal uses, on its own: it must answer where
|
|
949
|
+
# to write without bringing anything into being.
|
|
950
|
+
#
|
|
951
|
+
# (A handler is a different matter. It resolves through `ciudad`, which
|
|
952
|
+
# creates metadata for a city it is about to act on, so a request naming
|
|
953
|
+
# a deleted city can recreate its folder. That predates the journal and
|
|
954
|
+
# is not what this is about.)
|
|
955
|
+
fantasma = os.path.join(tempfile.mkdtemp(), "no-existe")
|
|
956
|
+
manejador = serve.Manejador.__new__(serve.Manejador)
|
|
957
|
+
donde = manejador._ciudad_para_apuntar(
|
|
958
|
+
{"city": [fantasma]}
|
|
959
|
+
)
|
|
960
|
+
afirma(
|
|
961
|
+
"· the journal's own resolver creates nothing",
|
|
962
|
+
not os.path.exists(fantasma) and isinstance(donde, str),
|
|
963
|
+
f"{fantasma} exists after only asking where to log",
|
|
964
|
+
)
|
|
965
|
+
# And the same thing through the real server, which is where it actually
|
|
966
|
+
# bit: the resolver did not create the folder, but it asked `cities.lista`
|
|
967
|
+
# — and listing HEALS, which does `makedirs` on every managed city before
|
|
968
|
+
# deciding there is nothing to write. So the journal recreated the city it
|
|
969
|
+
# was recording the removal of, and raced this very cleanup while doing it.
|
|
970
|
+
# A 404 is chosen deliberately: its handler touches no city at all, so the
|
|
971
|
+
# only thing that can bring the folder back is the log line.
|
|
972
|
+
pide(puerto, "/api/no-such-thing?city=" + otra, metodo="POST", cuerpo={})
|
|
973
|
+
afirma(
|
|
974
|
+
"· non-happy: and a request naming a city that is gone leaves it gone",
|
|
975
|
+
not os.path.exists(otra),
|
|
976
|
+
f"{otra} came back from a log line",
|
|
977
|
+
)
|
|
978
|
+
# The listing itself, which is the part that writes.
|
|
979
|
+
antes_lista = os.path.exists(otra)
|
|
980
|
+
cities_mod = serve.cities
|
|
981
|
+
cities_mod.lista("halltest", tocando=False)
|
|
982
|
+
afirma(
|
|
983
|
+
"· non-happy: and listing this user's cities does not raise the dead either",
|
|
984
|
+
not antes_lista and not os.path.exists(otra),
|
|
985
|
+
)
|
|
941
986
|
|
|
942
987
|
# ── the demo's remote control ────────────────────────────────────────
|
|
943
988
|
print(" the demo's remote control")
|
|
@@ -1242,6 +1287,44 @@ def main():
|
|
|
1242
1287
|
and "role.urgencias-web: triage" in carta,
|
|
1243
1288
|
carta,
|
|
1244
1289
|
)
|
|
1290
|
+
# The frontmatter is what the launcher and the cage read. The BODY is
|
|
1291
|
+
# what the SEAT reads — and for a while only the first one moved, so a
|
|
1292
|
+
# city could have three agents and tell its own chair, in prose, that it
|
|
1293
|
+
# had one. This fixture's card is frontmatter only, which is a card a
|
|
1294
|
+
# person may legitimately hand-write, so the check needs a card shaped
|
|
1295
|
+
# the way the wizard writes them.
|
|
1296
|
+
import seat as _seat
|
|
1297
|
+
from testlib import roster as _roster
|
|
1298
|
+
|
|
1299
|
+
ficha_completa = os.path.join(hallDatos, "halltest.md")
|
|
1300
|
+
antes_carta = open(ficha_completa, encoding="utf-8").read()
|
|
1301
|
+
_seat.escribe_ficha(
|
|
1302
|
+
ficha_completa, "halltest", "cpto", _roster(("notas", "knowledge", "apuntes"))
|
|
1303
|
+
)
|
|
1304
|
+
st, _ = pide(
|
|
1305
|
+
puerto,
|
|
1306
|
+
"/api/agentes",
|
|
1307
|
+
metodo="POST",
|
|
1308
|
+
cuerpo={"name": "guardia", "kind": "code", "role": "dev"},
|
|
1309
|
+
)
|
|
1310
|
+
entera = open(ficha_completa, encoding="utf-8").read()
|
|
1311
|
+
cuerpo_carta = entera.split("## Agents", 1)[-1].split("## ", 1)[0]
|
|
1312
|
+
afirma(
|
|
1313
|
+
"· happy: and the seat is told about it in the half of the card it reads",
|
|
1314
|
+
st == 200 and "`guardia`" in cuerpo_carta and "`notas`" in cuerpo_carta,
|
|
1315
|
+
cuerpo_carta[:300],
|
|
1316
|
+
)
|
|
1317
|
+
afirma(
|
|
1318
|
+
"· non-happy: adding a house never eats the goals or the round history",
|
|
1319
|
+
"## Round history" in entera and "## Current goals" in entera,
|
|
1320
|
+
entera[-400:],
|
|
1321
|
+
)
|
|
1322
|
+
with open(ficha_completa, "w", encoding="utf-8") as f:
|
|
1323
|
+
f.write(antes_carta)
|
|
1324
|
+
afirma(
|
|
1325
|
+
"· non-happy: a hand-written card with no roster section is left alone",
|
|
1326
|
+
"## Agents" not in open(ficha_completa, encoding="utf-8").read(),
|
|
1327
|
+
)
|
|
1245
1328
|
st, _ = pide(
|
|
1246
1329
|
puerto, "/api/agentes", metodo="POST",
|
|
1247
1330
|
cuerpo={"name": "urgencias web", "kind": "code", "role": "dev"},
|