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.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +11 -0
  3. package/README.md +10 -0
  4. package/benchmarks/latency/fake-claude-cli.mjs +3 -2
  5. package/bin/serve.py +63 -6
  6. package/bin/test +2 -2
  7. package/bin/test-alcance.py +470 -0
  8. package/bin/test-arnes.py +109 -0
  9. package/bin/test-channel.py +295 -9
  10. package/bin/test-cities.py +57 -0
  11. package/bin/test-claude-runtime.py +17 -6
  12. package/bin/test-connect-client.mjs +8 -0
  13. package/bin/test-connect.py +1 -1
  14. package/bin/test-contracts.py +129 -3
  15. package/bin/test-e2e.py +371 -0
  16. package/bin/test-seat.py +154 -1
  17. package/bin/test-serve.py +83 -0
  18. package/city/web/dist-hall/hall.js +384 -3240
  19. package/docs/managed-connect.md +5 -0
  20. package/package.json +1 -1
  21. package/plugin/.claude-plugin/plugin.json +1 -1
  22. package/plugin/channel/hub/lo-que-te-llega.ts +78 -0
  23. package/plugin/channel/hub/local-roads.ts +36 -0
  24. package/plugin/channel/hub/road-controller.ts +30 -5
  25. package/plugin/channel/local-hub.js +177 -105
  26. package/plugin/channel/local-hub.ts +8 -0
  27. package/plugin/channel/managed-connect-client.js +53 -3
  28. package/plugin/channel/managed-connect-client.manifest.json +2 -2
  29. package/plugin/channel/protocol.ts +24 -0
  30. package/plugin/commands/notice.md +14 -0
  31. package/plugin/hooks/ask-the-house.sh +25 -0
  32. package/plugin/hooks/hooks.json +10 -0
  33. package/plugin/hooks/notice-on-stop.sh +7 -1
  34. package/plugin/scripts/alcance.py +318 -0
  35. package/plugin/scripts/arnes.py +10 -1
  36. package/plugin/scripts/busca.py +13 -0
  37. package/plugin/scripts/card.py +40 -0
  38. package/plugin/scripts/cities.py +46 -4
  39. package/plugin/scripts/city-session.sh +80 -7
  40. package/plugin/scripts/domains.py +7 -0
  41. package/plugin/scripts/seat.py +33 -13
  42. 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
- + '\n[ "$1" = "has-session" ] && exit 1\nexit 0\n'
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"},