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/plugin/scripts/cities.py
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
"""
|
package/plugin/scripts/seat.py
CHANGED
|
@@ -350,19 +350,7 @@ def escribe_ficha(ruta, usuario, rol, agentes=None, objetivo=None, ciudad=""):
|
|
|
350
350
|
"## Agents",
|
|
351
351
|
"",
|
|
352
352
|
]
|
|
353
|
-
|
|
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,
|