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
@@ -314,10 +314,20 @@ def registra(datos, usuario=''):
314
314
  f.write(f'{usuario}\t{r}\n')
315
315
 
316
316
 
317
- def lista(usuario=''):
318
- """Every city this user owns or has explicitly registered."""
317
+ def lista(usuario='', tocando=True):
318
+ """Every city this user owns or has explicitly registered.
319
+
320
+ `tocando=False` for callers that must not change what they are looking at.
321
+ Listing normally HEALS: it migrates a v1 layout and gives every managed city
322
+ its metadata — which does `makedirs` before it decides there is nothing to
323
+ write. That is right when somebody is about to work in a city, and wrong
324
+ when the only reason to ask is to write a log line: the journal runs after
325
+ the handler, so on a request that archived a city, asking where to write
326
+ brought the folder back. A read that writes is not a read.
327
+ """
319
328
  usuario = _slug(usuario or usuario_actual(), 'me')
320
- migra_legacy(usuario)
329
+ if tocando:
330
+ migra_legacy(usuario)
321
331
  candidatas = []
322
332
  propia = carpeta_usuario(usuario)
323
333
  try:
@@ -335,7 +345,8 @@ def lista(usuario=''):
335
345
  if owner_en_disco and _slug(owner_en_disco, 'me') != usuario:
336
346
  continue
337
347
  vistos.add(r)
338
- meta = asegura_metadata(r, usuario) if gestionada(r, usuario) else {}
348
+ meta = (asegura_metadata(r, usuario)
349
+ if tocando and gestionada(r, usuario) else {})
339
350
  fuera.append({
340
351
  'ruta': r,
341
352
  'nombre': nombre(r),
@@ -358,6 +369,37 @@ def selecciona(usuario, datos):
358
369
  _atomico(_fichero_actual(usuario), datos + '\n')
359
370
 
360
371
 
372
+ def actual_sin_tocar(usuario=''):
373
+ """Which city is selected, answered without changing anything.
374
+
375
+ `actual()` is a resolver that also repairs: it migrates a v1 layout, heals
376
+ the metadata of every managed city it lists, records a new selection, and
377
+ will CREATE `home` when there is none. Every one of those is right before
378
+ somebody works in a city. None of them is right when the question is only
379
+ "where does this log line go" — and answering that question is how a city
380
+ somebody had just archived came back from the dead.
381
+
382
+ Returns '' when there is no answer. A caller with nowhere to write must not
383
+ be handed a folder that was invented for it.
384
+ """
385
+ explicita = os.environ.get('AGENTS_CITY_DATA')
386
+ if explicita and os.path.isdir(os.path.expanduser(explicita)):
387
+ return _real(explicita)
388
+ usuario = _slug(usuario or usuario_actual(), 'me')
389
+ try:
390
+ elegida = open(_fichero_actual(usuario), encoding='utf-8').read().strip()
391
+ except OSError:
392
+ elegida = ''
393
+ if elegida and es_ciudad(elegida):
394
+ owner = _lee_clave(elegida, 'owner')
395
+ if not owner or _slug(owner, 'me') == usuario:
396
+ return _real(elegida)
397
+ ciudades = lista(usuario, tocando=False)
398
+ home = next((c for c in ciudades if c['slug'] == 'home'), None)
399
+ elegida = home or (ciudades[0] if ciudades else None)
400
+ return elegida['ruta'] if elegida else ''
401
+
402
+
361
403
  def actual(usuario='', crear=True, migrar=True):
362
404
  """The selected city. Explicit ``AGENTS_CITY_DATA`` always wins."""
363
405
  explicita = os.environ.get('AGENTS_CITY_DATA')
@@ -95,11 +95,31 @@ ADDRESS="$(python3 "$(dirname "$0")/cities.py" address "$USUARIO" "$EQUIPO")"
95
95
  # second local city could address or replace the first one's seat by accident.
96
96
  SEAT_NAME="$SESSION"
97
97
 
98
+ # ── What is already open ───────────────────────────────────────────────────
99
+ #
100
+ # This used to be "the session exists, attach to it, done". Which meant that
101
+ # adding a house to a city that was already open did nothing you could see: the
102
+ # card had four agents, tmux had three windows, and closing the terminal did not
103
+ # help because detaching from a session is not ending it. The only way to meet
104
+ # your new agent was to kill the whole city — every running agent in it — and
105
+ # start again.
106
+ #
107
+ # So an existing session is reconciled instead of re-created: the windows that
108
+ # are missing are opened, the ones already there are left completely alone.
109
+ # Nothing is ever killed here. A window whose agent left the card is REPORTED,
110
+ # not closed: it may be mid-task, and that decision is the owner's.
111
+ SESION_YA=0
112
+ VENTANAS_YA=""
98
113
  if tmux has-session -t "$SESSION" 2>/dev/null; then
99
- echo "Session '$SESSION' is already up — attaching."
100
- exec tmux attach -d -t "$SESSION"
114
+ SESION_YA=1
115
+ VENTANAS_YA="$(tmux list-windows -t "$SESSION" -F '#W' 2>/dev/null || true)"
101
116
  fi
102
117
 
118
+ existe_ventana() {
119
+ [ -n "$VENTANAS_YA" ] || return 1
120
+ printf '%s\n' "$VENTANAS_YA" | grep -Fxq -- "$1"
121
+ }
122
+
103
123
  # Claude's half of the deal, asked for rather than respelled.
104
124
  #
105
125
  # `arnes.json` declares what this product adds to somebody else's CLI, and
@@ -457,7 +477,13 @@ python3 "$(dirname "$0")/trust-repos.py" "$EQUIPO" ${RUTAS[@]+"${RUTAS[@]}"}
457
477
  # ── The seat window: this city's identity on its roads ─────────────────────
458
478
  "$RUNTIME" ensure >/dev/null
459
479
  comodidades
460
- tmux new-session -d -s "$SESSION" -n seat -c "$EQUIPO"
480
+ if [ "$SESION_YA" -eq 0 ]; then
481
+ tmux new-session -d -s "$SESSION" -n seat -c "$EQUIPO"
482
+ elif ! existe_ventana seat; then
483
+ # The session outlived its own chair — somebody closed that one window. The
484
+ # city is not a city without it.
485
+ tmux new-window -t "$SESSION" -n seat -c "$EQUIPO"
486
+ fi
461
487
 
462
488
  # Where the cards live, told to the session rather than assumed.
463
489
  #
@@ -522,7 +548,11 @@ SEAT_OTRO="$(python3 "$LEER" "$FICHA" "runs.seat")"
522
548
  turno=0
523
549
  [ -n "$SEAT_OTRO" ] && [ "$(runtime_de "$SEAT_OTRO")" != claude ] && turno=-1
524
550
  # If the seat runs another engine, the first repo window is the first Claude.
525
- if [ "$RUN_CLAUDE" -eq 1 ]; then
551
+ #
552
+ # `! existe_ventana seat`: a chair that is already sitting is not started again.
553
+ # Sending a second `claude` into a live pane would put one runtime on top of
554
+ # another and lose whatever conversation was in it.
555
+ if [ "$RUN_CLAUDE" -eq 1 ] && ! existe_ventana seat; then
526
556
  if [ -n "$SEAT_OTRO" ]; then
527
557
  SEAT_RUNTIME="$(runtime_de "$SEAT_OTRO")"
528
558
  if [ "$SEAT_RUNTIME" = claude ]; then
@@ -550,7 +580,11 @@ fi
550
580
  # that validates (declared repo only, never the default branch) and audits.
551
581
  # Each window gets its own token file, and its cage re-allows exactly that one.
552
582
  BROKER_URL=""
553
- if [ "${CITY_BROKER:-0}" = "1" ]; then
583
+ if [ "${CITY_BROKER:-0}" = "1" ] && [ "$SESION_YA" -eq 1 ]; then
584
+ # A restart would invalidate the token files of every window already running.
585
+ BROKER_URL="$(python3 "$BROKERPY" url --data "$EQUIPO" 2>/dev/null || true)"
586
+ fi
587
+ if [ "${CITY_BROKER:-0}" = "1" ] && [ -z "$BROKER_URL" ]; then
554
588
  python3 "$BROKERPY" stop --data "$EQUIPO" >/dev/null 2>&1 || true
555
589
  nohup python3 "$BROKERPY" serve --data "$EQUIPO" >/dev/null 2>&1 &
556
590
  for _ in 1 2 3 4 5 6 7 8 9 10; do
@@ -569,9 +603,14 @@ fi
569
603
  # gateways. None receives assignments through tmux. An unknown CLI must be
570
604
  # prefixed with `terminal:` to opt into the visibly labelled compatibility path.
571
605
  i=0
606
+ ABIERTAS=()
572
607
  for path in ${RUTAS[@]+"${RUTAS[@]}"}; do
573
608
  win="${NOMBRES[$i]}"
574
609
  i=$((i + 1))
610
+ # Already open: leave it exactly as it is. Whatever is running in there is
611
+ # somebody's work in progress.
612
+ if existe_ventana "$win"; then continue; fi
613
+ ABIERTAS+=("$win")
575
614
  # Professional specialty is separate from bus authority. Every repo actor is
576
615
  # still a member; only `seat` is chair. A malformed or legacy value resolves
577
616
  # to the explicit blank role in read-card.py.
@@ -642,7 +681,12 @@ for path in ${RUTAS[@]+"${RUTAS[@]}"}; do
642
681
  fi
643
682
  done
644
683
 
645
- if [ ${#faltan[@]-0} -gt 0 ]; then
684
+ # `${#a[@]}`, not `${#a[@]-0}`. The second is accepted by the bash 3.2 macOS
685
+ # ships and rejected as a bad substitution by bash 4.4 and up — so on every
686
+ # Linux this message never printed, and nobody saw it because the error goes to
687
+ # stderr and the script does not stop. Declared arrays make the default
688
+ # unnecessary: `a=()` already answers 0 under `set -u`.
689
+ if [ ${#faltan[@]} -gt 0 ]; then
646
690
  echo >&2
647
691
  echo "I could not find these repos on this machine (window skipped):" >&2
648
692
  printf ' %s\n' "${faltan[@]}" >&2
@@ -664,5 +708,34 @@ if [ "${#RUTAS[@]}" -eq 0 ]; then
664
708
  echo "If that is wrong and you do own folders: ./bin/seat --repos" >&2
665
709
  fi
666
710
 
667
- tmux select-window -t "$SESSION:seat"
711
+ # ── What changed, said out loud ────────────────────────────────────────────
712
+ #
713
+ # Reconciling in silence is how the old bug felt from the outside: you asked for
714
+ # the city, something happened, and you were left to work out whether your new
715
+ # agent was there. So it is stated — including the windows this deliberately did
716
+ # NOT touch.
717
+ DESTACADA="seat"
718
+ if [ "$SESION_YA" -eq 1 ]; then
719
+ if [ ${#ABIERTAS[@]} -gt 0 ]; then
720
+ echo "Session '$SESSION' was already up. New windows opened: ${ABIERTAS[*]}" >&2
721
+ DESTACADA="${ABIERTAS[0]}"
722
+ else
723
+ echo "Session '$SESSION' is already up, with every agent on the card — attaching." >&2
724
+ fi
725
+ sobran=()
726
+ for w in $VENTANAS_YA; do
727
+ [ "$w" = seat ] && continue
728
+ esta=0
729
+ for n in ${NOMBRES[@]+"${NOMBRES[@]}"}; do [ "$n" = "$w" ] && esta=1 && break; done
730
+ [ "$esta" -eq 0 ] && sobran+=("$w")
731
+ done
732
+ if [ ${#sobran[@]} -gt 0 ]; then
733
+ echo >&2
734
+ echo "These windows are no longer on the card: ${sobran[*]}" >&2
735
+ echo "Nothing was closed — one of them may be mid-task. When you are sure:" >&2
736
+ for w in ${sobran[@]+"${sobran[@]}"}; do echo " tmux kill-window -t $SESSION:$w" >&2; done
737
+ fi
738
+ fi
739
+
740
+ tmux select-window -t "$SESSION:$DESTACADA"
668
741
  exec tmux attach -d -t "$SESSION"
@@ -161,6 +161,13 @@ Before acting for this city:
161
161
  asking it for evidence. Use a chaired committee when several matter.
162
162
  Repo agents do not talk directly to each other.
163
163
 
164
+ The seat has no hands inside an agent's mounts. Its own ground is this folder,
165
+ the roads and the record; the repos, worktrees and document folders belong to
166
+ the agents that mounted them, and reaching into one is refused with the name of
167
+ who to ask. Ask one agent when one domain matters, a committee when several do,
168
+ and say you are waiting when you are waiting. `seat_reach: open` in `city.yml`
169
+ is the owner's explicit choice to have a chair that reads for itself.
170
+
164
171
  Skills remain installed in the referenced repos. Agents City only recognises
165
172
  them; it never copies, installs or enables a repo skill from this folder.
166
173
  """
@@ -350,19 +350,7 @@ def escribe_ficha(ruta, usuario, rol, agentes=None, objetivo=None, ciudad=""):
350
350
  "## Agents",
351
351
  "",
352
352
  ]
353
- if agentes:
354
- for a in agentes:
355
- monta = f"{len(a['mounts'])} mount{'s' if len(a['mounts']) != 1 else ''}"
356
- cuerpo.append(f"- `{a['nombre']}` — {a['clase']}, role {a['rol']}, {monta}.")
357
- cuerpo += ["", "Change the roster with `./bin/seat --agents`."]
358
- else:
359
- cuerpo += [
360
- "No agents yet, on purpose. This role holds a property of "
361
- "everybody else's — so it has no houses, and what it is for "
362
- "begins where there is more than one city to reach.",
363
- "",
364
- "If that is wrong: `./bin/seat --agents`.",
365
- ]
353
+ cuerpo += card.bloque_agentes(agentes)
366
354
  cuerpo += ["", "## Current goals", ""]
367
355
 
368
356
  cuerpo += card.bloque_objetivo(objetivo)
@@ -1315,6 +1303,29 @@ def ajusta_seat_yolo(a, datos, nueva):
1315
1303
  return solo
1316
1304
 
1317
1305
 
1306
+ def ajusta_seat_reach(a, datos, nueva):
1307
+ """The --seat-reach flag: whether the chair may work inside its agents' own
1308
+ mounts. Returns True when the invocation was ONLY this adjustment.
1309
+
1310
+ Closed by default, and the reason is that the failure it prevents leaves no
1311
+ trace: a seat that reads the repo and answers looks exactly like a seat that
1312
+ asked, right up until the specialist you configured turns out never to have
1313
+ been consulted about anything. Open is a real choice for whoever wants a
1314
+ chair with its own hands — it is just not one this makes for them.
1315
+ """
1316
+ if not a.seat_reach:
1317
+ return False
1318
+ cities.pon_clave(datos, "seat_reach", a.seat_reach)
1319
+ if a.seat_reach == "open":
1320
+ print(" The chair may work inside its agents' mounts (city.yml seat_reach).")
1321
+ else:
1322
+ print(" The chair asks its agents rather than working their ground.")
1323
+ solo = not hay_ajustes(a, nueva)
1324
+ if solo:
1325
+ print(" It applies to the next tool call the seat makes.\n")
1326
+ return solo
1327
+
1328
+
1318
1329
  def configura(a, ficha, nueva, datos, usuario):
1319
1330
  """The seven questions, or the setting being re-asked. False means quit."""
1320
1331
  previo = open(ficha).read() if not nueva else ""
@@ -1586,6 +1597,13 @@ def argumentos():
1586
1597
  metavar="on|off",
1587
1598
  help="whether the chair itself runs without permission prompts (stored per city)",
1588
1599
  )
1600
+ ap.add_argument(
1601
+ "--seat-reach",
1602
+ default="",
1603
+ choices=["open", "closed"],
1604
+ metavar="open|closed",
1605
+ help="whether the chair may work inside its agents' mounts (stored per city)",
1606
+ )
1589
1607
  ap.add_argument("--no-sync", action="store_true")
1590
1608
  ap.add_argument("--only", default="")
1591
1609
  ap.add_argument(
@@ -1652,6 +1670,8 @@ def main():
1652
1670
 
1653
1671
  if ajusta_seat_yolo(a, datos, nueva):
1654
1672
  return 0
1673
+ if ajusta_seat_reach(a, datos, nueva):
1674
+ return 0
1655
1675
 
1656
1676
  if hay_ajustes(a, nueva):
1657
1677
  if nueva:
@@ -76,6 +76,18 @@ committee envelope into a known runtime's tmux window. The terminal adapter is
76
76
  an explicit compatibility fallback only when the owner wrote
77
77
  `terminal:<command>` for an unknown CLI.
78
78
 
79
+ **The seat has no hands inside an agent's mounts.** Not "should prefer not to":
80
+ a tool call that lands on a repo, worktree or document folder some agent mounted
81
+ is refused, and the refusal names the owner and the command that asks them. The
82
+ chair's own ground is the city folder, the roads, the record and its own goal.
83
+
84
+ That is a boundary, not a courtesy. A seat that reads the code and answers is
85
+ indistinguishable from a seat that consulted its specialists, right up until you
86
+ notice the specialists were never asked anything. Ask one agent when one domain
87
+ matters; open a committee when more than one does; say you are waiting when you
88
+ are waiting. An owner who wants a chair with its own hands sets `seat_reach:
89
+ open` in `city.yml` — explicitly, and per city.
90
+
79
91
  Use a committee only when specialised repo evidence can change the decision:
80
92
 
81
93
  1. Open a precise brief: question, desired outcome, context, constraints,