agents-city 0.4.0 → 0.5.2

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 (98) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +240 -11
  3. package/README.md +238 -11
  4. package/benchmarks/latency/fake-claude-cli.mjs +2 -1
  5. package/benchmarks/reception/README.md +16 -0
  6. package/benchmarks/reception/run.mjs +84 -0
  7. package/bin/agents-city.js +1 -0
  8. package/bin/connect +5 -0
  9. package/bin/hall.html +117 -18
  10. package/bin/navegador.mjs +170 -1
  11. package/bin/serve.py +201 -101
  12. package/bin/test +15 -3
  13. package/bin/test-arnes.py +192 -0
  14. package/bin/test-cage.py +9 -0
  15. package/bin/test-channel.py +178 -12
  16. package/bin/test-claude-runtime.py +132 -4
  17. package/bin/test-connect-client.mjs +609 -0
  18. package/bin/test-connect.py +52 -0
  19. package/bin/test-contracts.py +214 -0
  20. package/bin/test-diario.py +185 -0
  21. package/bin/test-doctor.py +31 -0
  22. package/bin/test-i18n.py +189 -0
  23. package/bin/test-navegador.py +61 -0
  24. package/bin/test-seat.py +78 -20
  25. package/bin/test-security.py +2 -0
  26. package/bin/test-serve.py +366 -0
  27. package/city/web/dist-hall/hall.js +789 -214
  28. package/city/web/src/bienvenida.ts +14 -13
  29. package/city/web/src/casa.ts +143 -56
  30. package/city/web/src/demo.ts +61 -31
  31. package/city/web/src/es.ts +187 -27
  32. package/city/web/src/explorador.ts +70 -43
  33. package/city/web/src/hall.ts +520 -103
  34. package/city/web/src/idioma.ts +21 -1
  35. package/city/web/src/motores.ts +48 -1
  36. package/city/web/src/vista.ts +58 -0
  37. package/demo/graba.py +5 -10
  38. package/docs/managed-connect.md +237 -0
  39. package/docs/security.md +41 -2
  40. package/package.json +8 -4
  41. package/plugin/.claude-plugin/plugin.json +1 -1
  42. package/plugin/channel/adapter-prompts.ts +10 -0
  43. package/plugin/channel/adapter.js +78 -31
  44. package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
  45. package/plugin/channel/bus.js +109 -65
  46. package/plugin/channel/bus.ts +11 -1
  47. package/plugin/channel/client.js +67 -30
  48. package/plugin/channel/delivery-queue.ts +160 -21
  49. package/plugin/channel/hub/remote-roads.ts +32 -1
  50. package/plugin/channel/hub/road-controller.ts +116 -16
  51. package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
  52. package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
  53. package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
  54. package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
  55. package/plugin/channel/licenses/keyring-MIT.txt +21 -0
  56. package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
  57. package/plugin/channel/local-hub.js +2080 -97
  58. package/plugin/channel/local-hub.ts +5 -4
  59. package/plugin/channel/managed-connect/bridge.ts +208 -0
  60. package/plugin/channel/managed-connect/cli.ts +416 -0
  61. package/plugin/channel/managed-connect/device.ts +15 -0
  62. package/plugin/channel/managed-connect/local-cities.ts +94 -0
  63. package/plugin/channel/managed-connect/person-message.ts +74 -0
  64. package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
  65. package/plugin/channel/managed-connect/relay-session.ts +7 -0
  66. package/plugin/channel/managed-connect/storage.ts +653 -0
  67. package/plugin/channel/managed-connect/transport.ts +87 -0
  68. package/plugin/channel/managed-connect-cli.js +4777 -0
  69. package/plugin/channel/managed-connect-cli.ts +7 -0
  70. package/plugin/channel/managed-connect-client.d.ts +261 -0
  71. package/plugin/channel/managed-connect-client.js +6572 -0
  72. package/plugin/channel/managed-connect-client.manifest.json +38 -0
  73. package/plugin/channel/package-lock.json +123 -105
  74. package/plugin/channel/package.json +4 -4
  75. package/plugin/channel/protocol.ts +4 -0
  76. package/plugin/channel/reception.ts +896 -0
  77. package/plugin/channel/road-cli.ts +1 -1
  78. package/plugin/channel/runtime/arnes.json +189 -0
  79. package/plugin/channel/runtime/arnes.ts +47 -0
  80. package/plugin/channel/runtime/claude.ts +18 -0
  81. package/plugin/channel/runtime/codex-config.ts +51 -1
  82. package/plugin/channel/runtime/codex.ts +31 -11
  83. package/plugin/channel/runtime/kimi.ts +6 -4
  84. package/plugin/channel/runtime-files.ts +39 -5
  85. package/plugin/channel/runtime-gateway.js +426 -100
  86. package/plugin/channel/runtime-gateway.ts +12 -0
  87. package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
  88. package/plugin/scripts/arnes.py +271 -0
  89. package/plugin/scripts/busca.py +46 -10
  90. package/plugin/scripts/cage.py +5 -0
  91. package/plugin/scripts/city-session.sh +144 -28
  92. package/plugin/scripts/crecimiento.py +4 -1
  93. package/plugin/scripts/demos.py +58 -43
  94. package/plugin/scripts/desinstala.py +36 -21
  95. package/plugin/scripts/diario.py +119 -0
  96. package/plugin/scripts/doctor.py +90 -15
  97. package/plugin/scripts/read-card.py +48 -8
  98. package/plugin/scripts/reception.py +665 -0
@@ -100,11 +100,19 @@ if tmux has-session -t "$SESSION" 2>/dev/null; then
100
100
  exec tmux attach -d -t "$SESSION"
101
101
  fi
102
102
 
103
- # Claude's native cross-session path is explicitly closed. The city bus is the
104
- # only route, so Claude, Codex, OpenCode and Kimi obey the same topology.
105
- # skipDangerousModePermissionPrompt only suppresses Claude's local yolo notice.
106
- SETTINGS='{"crossSessionInbound":"refuse","skipDangerousModePermissionPrompt":true}'
107
- NO_PEER_TOOLS=' --disallowed-tools SendMessage,ListAgents'
103
+ # Claude's half of the deal, asked for rather than respelled.
104
+ #
105
+ # `arnes.json` declares what this product adds to somebody else's CLI, and
106
+ # `agents-city doctor --config` prints that declaration as a promise about their
107
+ # machine. These two lines used to be a second spelling of it — which meant the
108
+ # drift guard, which reads the connectors, could not see the one runtime whose
109
+ # values live here. One `python3` in a script that already runs dozens buys the
110
+ # promise being true for all four runtimes instead of two.
111
+ #
112
+ # What it contains: Claude's native cross-session path closed and its peer tools
113
+ # denied, so the city bus is the only route; and the local yolo notice
114
+ # suppressed, which grants nothing.
115
+ CLAUDE_TRATO=" $(python3 "$(dirname "$0")/arnes.py" flags claude)"
108
116
  YOLO_FLAG=""
109
117
  [ "$YOLO" -eq 1 ] && YOLO_FLAG=" --dangerously-skip-permissions"
110
118
 
@@ -195,17 +203,82 @@ jaula_de() { # window, cwd, [broker token file], [colon-joined mount targets] -
195
203
  # then the card's per-window key (`model.dbt:`), then the card's default
196
204
  # (`model:`). Nothing set means no flag at all — the owner's Claude default,
197
205
  # which is the right silence.
198
- motor_de() { # $1 = window name -> prints the extra claude flags
199
- local ventana="$1" modelo esfuerzo salida=""
200
- modelo="${CITY_MODEL:-$(python3 "$LEER" "$FICHA" "model.$ventana")}"
201
- [ -z "$modelo" ] && modelo="$(python3 "$LEER" "$FICHA" model)"
202
- esfuerzo="${CITY_EFFORT:-$(python3 "$LEER" "$FICHA" "effort.$ventana")}"
203
- [ -z "$esfuerzo" ] && esfuerzo="$(python3 "$LEER" "$FICHA" effort)"
206
+ # Used only by con_motor below, which is the single entry point: a window whose
207
+ # card says `model.dbt` must get it whichever of the six launch branches it
208
+ # lands in, and one of them used to give it nothing at all.
209
+ motor_de() { # $1 = window name, $2 = runtime (default claude) -> extra flags
210
+ local ventana="$1" cual="${2:-claude}" modelo esfuerzo salida="" cuatro
211
+ # Four fields, one interpreter — and none at all when the window loop has
212
+ # already read this window's card line. This used to be four `python3` starts
213
+ # each re-parsing the same small card, per window; a city with eighteen agents
214
+ # paid seventy-two of them before tmux attached.
215
+ if [ "$ventana" = "${CARTA_VENTANA:-}" ]; then
216
+ # Already read, by the loop that is launching this very window.
217
+ cuatro="$(printf '%s' "$CARTA_DATOS" | sed -n '3,6p')"
218
+ else
219
+ cuatro="$(python3 "$LEER" --varios "$FICHA" \
220
+ "model.$ventana" model "effort.$ventana" effort)"
221
+ fi
222
+ modelo="${CITY_MODEL:-$(printf '%s' "$cuatro" | sed -n 1p)}"
223
+ [ -z "$modelo" ] && modelo="$(printf '%s' "$cuatro" | sed -n 2p)"
224
+ esfuerzo="${CITY_EFFORT:-$(printf '%s' "$cuatro" | sed -n 3p)}"
225
+ [ -z "$esfuerzo" ] && esfuerzo="$(printf '%s' "$cuatro" | sed -n 4p)"
204
226
  [ -n "$modelo" ] && salida=" --model $modelo"
205
- [ -n "$esfuerzo" ] && salida="$salida --effort $esfuerzo"
227
+ # Only where it is actually read. Claude takes it as a CLI flag; Codex's
228
+ # gateway parses it out of the command and sends it with the turn. OpenCode
229
+ # and Kimi have no such setting, and writing a flag nothing reads is how a
230
+ # control ends up looking like it works.
231
+ if [ -n "$esfuerzo" ]; then
232
+ case "$cual" in claude|codex) salida="$salida --effort $esfuerzo" ;; esac
233
+ fi
206
234
  printf '%s' "$salida"
207
235
  }
208
236
 
237
+ # The card says which model a window runs, once, whatever CLI runs it. Claude
238
+ # takes these as flags; the native gateways parse the very same spelling out of
239
+ # the command string and send it with the turn — which is why one key on the
240
+ # card can mean the same thing for all four. A command that already carries the
241
+ # flag keeps it: somebody who wrote `runs.dbt: codex --model x` said what they
242
+ # meant, and a generic key must not overrule a specific sentence.
243
+ con_motor() { # $1 = window, $2 = runtime, $3 = the command
244
+ local ventana="$1" cual="$2" orden="$3" extra
245
+ extra="$(motor_de "$ventana" "$cual")"
246
+ case "$orden" in
247
+ *" --model "*|*" -m "*) extra="$(printf '%s' "$extra" | sed 's/ --model [^ ]*//')" ;;
248
+ esac
249
+ case "$orden" in
250
+ *" --effort "*) extra="$(printf '%s' "$extra" | sed 's/ --effort [^ ]*//')" ;;
251
+ esac
252
+ printf '%s%s' "$orden" "$extra"
253
+ }
254
+
255
+ # How a window's Claude is presented: its own interface, or the city's prompt.
256
+ #
257
+ # tui Claude Code as you run it by hand — your plugins, your statusline,
258
+ # your slash commands. It reaches the bus through the city plugin's
259
+ # SessionStart/UserPromptSubmit/Stop hooks, which report the same
260
+ # `conversation.*` events the gateway reports.
261
+ # gateway Claude headless behind the city gateway, driven from a `city>`
262
+ # prompt. The bus can PUSH work into it, which is what an agent
263
+ # house needs and a chair does not.
264
+ #
265
+ # The seat defaults to `tui` because the seat is where a person works by hand,
266
+ # and taking their own harness away to give them a bare prompt was the wrong
267
+ # trade. `ui.seat: gateway` on the card, or CITY_UI, puts the old shape back.
268
+ #
269
+ # Agent houses are NOT asked: a house exists to receive assignments from the
270
+ # bus, and the gateway is what makes that possible. The helper takes a window
271
+ # and a default because that is the honest shape of the question — but only the
272
+ # chair asks it, and the docs say only that.
273
+ ui_de() { # $1 = window, $2 = default -> tui | gateway
274
+ local elegido
275
+ elegido="${CITY_UI:-$(python3 "$LEER" "$FICHA" "ui.$1")}"
276
+ case "$elegido" in
277
+ tui|gateway) printf '%s' "$elegido" ;;
278
+ *) printf '%s' "$2" ;;
279
+ esac
280
+ }
281
+
209
282
  runtime_de() { # configured command -> claude | codex | opencode | kimi | terminal | unknown
210
283
  local raw="$1" first
211
284
  case "$raw" in terminal:*) printf 'terminal'; return ;; esac
@@ -399,10 +472,52 @@ tmux set-environment -t "$SESSION" AGENTS_CITY_USER "$USUARIO" 2>/dev/null
399
472
  tmux set-environment -t "$SESSION" CITY_ADDRESS "$ADDRESS" 2>/dev/null
400
473
  tmux set-environment -t "$SESSION" CITY_SEAT_NAME "$SEAT_NAME" 2>/dev/null
401
474
 
475
+ # What every window is told about the city it belongs to.
476
+ #
477
+ # This was spelled out seven times, in seven three-hundred-column lines, and two
478
+ # of the copies existed only because the seat's non-Claude branches bypassed the
479
+ # helper that already had it. Adding one variable meant finding all seven.
480
+ #
481
+ # The chair and a house differ in exactly three keys: a house carries its
482
+ # operating role and has the remote-road variables blanked, because a road is
483
+ # the seat's to hold; the chair carries its bus agent instead.
484
+ entorno_de() { # $1 = actor ("seat" or a window name)
485
+ local actor="$1" propio
486
+ if [ "$actor" = seat ]; then
487
+ propio="CITY_BUS_ACTOR=seat CITY_RUNTIME_KIND=seat CITY_BUS_AGENT=$AGENTE "
488
+ else
489
+ propio="CITY_BUS_ACTOR=$actor CITY_AGENT_ROLE=$ROL_REPO CITY_RUNTIME_KIND=repo CITY_BUS_URL= CITY_BUS_TOKEN= "
490
+ fi
491
+ printf '%s%s' "$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME " "$propio"
492
+ }
493
+
494
+ # The chair's Claude, opened the way the card asks for.
495
+ #
496
+ # Both spellings carry the SAME flags. That matters: `--settings` closes
497
+ # Claude's own cross-session inbound and `--disallowed-tools` removes the peer
498
+ # tools, and those two are what make the city bus the only route between
499
+ # agents. A TUI without them would be a quieter product with a hole in it.
500
+ lanza_asiento_claude() { # $1 = the full claude command
501
+ local orden="$1" entorno
502
+ entorno="$(entorno_de seat)"
503
+ if [ "$(ui_de seat tui)" = tui ]; then
504
+ # Claude Code itself, in the pane. The city plugin is installed at user
505
+ # scope, so its hooks report this session's prompts and answers onto the
506
+ # bus exactly as the gateway would; the difference is that the city cannot
507
+ # push a prompt in, and instead types one through the registered fallback.
508
+ lanza "$SESSION:seat" seat "$EQUIPO" "$entorno${CLAUDE_AUTH_PREFIX}$orden"
509
+ "$RUNTIME" fallback seat "$SESSION:seat" claude
510
+ else
511
+ lanza "$SESSION:seat" seat "$EQUIPO" \
512
+ "$entorno${CLAUDE_AUTH_PREFIX}$(gateway_line seat "$EQUIPO" "$orden" "$SEAT_AUTO")"
513
+ fi
514
+ }
515
+
402
516
  # The seat runs Claude unless the card says otherwise. `runs.seat` is the same key
403
517
  # the repo windows use, and it makes a city with no Claude in it possible at all:
404
518
  # what that seat gives up is the `/city:` commands, which are Claude's, and what it
405
519
  # keeps is the folder, the identity, and everything the terminal does.
520
+ CARTA_VENTANA="" # set by the window loop below; the seat is not in it
406
521
  SEAT_OTRO="$(python3 "$LEER" "$FICHA" "runs.seat")"
407
522
  turno=0
408
523
  [ -n "$SEAT_OTRO" ] && [ "$(runtime_de "$SEAT_OTRO")" != claude ] && turno=-1
@@ -411,25 +526,22 @@ if [ "$RUN_CLAUDE" -eq 1 ]; then
411
526
  if [ -n "$SEAT_OTRO" ]; then
412
527
  SEAT_RUNTIME="$(runtime_de "$SEAT_OTRO")"
413
528
  if [ "$SEAT_RUNTIME" = claude ]; then
414
- CLAUDE_SEAT="$SEAT_OTRO --name $SEAT_NAME --settings '$SETTINGS'$NO_PEER_TOOLS$SEAT_YOLO_FLAG"
415
- lanza "$SESSION:seat" seat "$EQUIPO" \
416
- "$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME CITY_BUS_ACTOR=seat CITY_RUNTIME_KIND=seat CITY_BUS_AGENT=$AGENTE ${CLAUDE_AUTH_PREFIX}$(gateway_line seat "$EQUIPO" "$CLAUDE_SEAT" "$SEAT_AUTO")"
529
+ CLAUDE_SEAT="$(con_motor seat claude "$SEAT_OTRO --name $SEAT_NAME")$CLAUDE_TRATO$SEAT_YOLO_FLAG"
530
+ lanza_asiento_claude "$CLAUDE_SEAT"
417
531
  elif [ "$SEAT_RUNTIME" = codex ] || [ "$SEAT_RUNTIME" = opencode ] || [ "$SEAT_RUNTIME" = kimi ]; then
418
532
  lanza "$SESSION:seat" seat "$EQUIPO" \
419
- "$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME CITY_BUS_ACTOR=seat CITY_RUNTIME_KIND=seat CITY_BUS_AGENT=$AGENTE $(gateway_line seat "$EQUIPO" "$SEAT_OTRO" "$SEAT_AUTO")"
533
+ "$(entorno_de seat)$(gateway_line seat "$EQUIPO" "$(con_motor seat "$SEAT_RUNTIME" "$SEAT_OTRO")" "$SEAT_AUTO")"
420
534
  elif [ "$SEAT_RUNTIME" = terminal ]; then
421
535
  SEAT_COMMAND="${SEAT_OTRO#terminal:}"
422
536
  lanza "$SESSION:seat" seat "$EQUIPO" \
423
- "$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME CITY_BUS_ACTOR=seat CITY_RUNTIME_KIND=seat CITY_BUS_AGENT=$AGENTE $SEAT_COMMAND"
537
+ "$(entorno_de seat)$SEAT_COMMAND"
424
538
  "$RUNTIME" fallback seat "$SESSION:seat" "${SEAT_COMMAND%% *}"
425
539
  else
426
540
  tmux send-keys -t "$SESSION:seat" \
427
541
  "echo 'No native Agents City gateway for: $SEAT_OTRO. Use terminal:$SEAT_OTRO only if you explicitly accept terminal injection.'" C-m
428
542
  fi
429
543
  else
430
- CLAUDE_SEAT="claude --name $SEAT_NAME$(motor_de seat) --settings '$SETTINGS'$NO_PEER_TOOLS$SEAT_YOLO_FLAG"
431
- lanza "$SESSION:seat" seat "$EQUIPO" \
432
- "$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME CITY_BUS_ACTOR=seat CITY_RUNTIME_KIND=seat CITY_BUS_AGENT=$AGENTE ${CLAUDE_AUTH_PREFIX}$(gateway_line seat "$EQUIPO" "$CLAUDE_SEAT" "$SEAT_AUTO")"
544
+ lanza_asiento_claude "$(con_motor seat claude "claude --name $SEAT_NAME")$CLAUDE_TRATO$SEAT_YOLO_FLAG"
433
545
  fi
434
546
  fi
435
547
 
@@ -463,7 +575,11 @@ for path in ${RUTAS[@]+"${RUTAS[@]}"}; do
463
575
  # Professional specialty is separate from bus authority. Every repo actor is
464
576
  # still a member; only `seat` is chair. A malformed or legacy value resolves
465
577
  # to the explicit blank role in read-card.py.
466
- ROL_REPO="$(python3 "$LEER" --actor-role "$FICHA" "$win")"
578
+ # One read for this whole window: its role, what runs there, and the engine.
579
+ # Three interpreters per window used to re-parse the same card three times.
580
+ CARTA_VENTANA="$win"
581
+ CARTA_DATOS="$(python3 "$LEER" --ventana "$FICHA" "$win")"
582
+ ROL_REPO="$(printf '%s' "$CARTA_DATOS" | sed -n 1p)"
467
583
  # This window's cage and, when the broker runs, its own single-repo token.
468
584
  # The token travels as a file path, never on the command line, and the cage
469
585
  # profile re-allows reading exactly that file and nothing else in the broker.
@@ -479,16 +595,16 @@ for path in ${RUTAS[@]+"${RUTAS[@]}"}; do
479
595
  [ -n "$JAULA" ] && CAGE_RUNTIME_ENV="env CITY_OUTER_CAGE=1 "
480
596
  tmux new-window -t "$SESSION" -n "$win" -c "$path"
481
597
  if [ "$RUN_CLAUDE" -eq 1 ]; then
482
- OTRO="$(python3 "$LEER" "$FICHA" "runs.$win")"
598
+ OTRO="$(printf '%s' "$CARTA_DATOS" | sed -n 2p)"
483
599
  if [ -n "$OTRO" ]; then
484
600
  KIND="$(runtime_de "$OTRO")"
485
601
  if [ "$KIND" = claude ]; then
486
602
  espera="$(retraso "$turno")"
487
603
  turno=$((turno + 1))
488
604
  if [ "$espera" = "0" ]; then espera=''; else espera="sleep $espera; "; fi
489
- CLAUDE_REPO="$OTRO --name $SESSION-$win --settings '$SETTINGS'$NO_PEER_TOOLS$YOLO_FLAG"
605
+ CLAUDE_REPO="$(con_motor "$win" claude "$OTRO --name $SESSION-$win")$CLAUDE_TRATO$YOLO_FLAG"
490
606
  lanza "$SESSION:$win" "$win" "$path" \
491
- "$espera$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME CITY_BUS_ACTOR=$win CITY_AGENT_ROLE=$ROL_REPO CITY_RUNTIME_KIND=repo CITY_BUS_URL= CITY_BUS_TOKEN= $BROKER_ENV$JAULA$CAGE_RUNTIME_ENV${CLAUDE_AUTH_PREFIX}$(gateway_line "$win" "$path" "$CLAUDE_REPO")"
607
+ "$espera$(entorno_de "$win")$BROKER_ENV$JAULA$CAGE_RUNTIME_ENV${CLAUDE_AUTH_PREFIX}$(gateway_line "$win" "$path" "$CLAUDE_REPO")"
492
608
  elif [ "$KIND" = codex ] || [ "$KIND" = opencode ] || [ "$KIND" = kimi ]; then
493
609
  # Native servers have their own credentials and do not share Claude's OAuth race.
494
610
  # Codex stays outside the outer cage ON macOS ONLY: its node_repl MCP
@@ -505,11 +621,11 @@ for path in ${RUTAS[@]+"${RUTAS[@]}"}; do
505
621
  RUNTIME_CAGE_ENV=""
506
622
  fi
507
623
  lanza "$SESSION:$win" "$win" "$path" \
508
- "$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME CITY_BUS_ACTOR=$win CITY_AGENT_ROLE=$ROL_REPO CITY_RUNTIME_KIND=repo CITY_BUS_URL= CITY_BUS_TOKEN= $BROKER_ENV$RUNTIME_CAGE$RUNTIME_CAGE_ENV$(gateway_line "$win" "$path" "$OTRO")"
624
+ "$(entorno_de "$win")$BROKER_ENV$RUNTIME_CAGE$RUNTIME_CAGE_ENV$(gateway_line "$win" "$path" "$(con_motor "$win" "$KIND" "$OTRO")")"
509
625
  elif [ "$KIND" = terminal ]; then
510
626
  FALLBACK_COMMAND="${OTRO#terminal:}"
511
627
  lanza "$SESSION:$win" "$win" "$path" \
512
- "$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME CITY_BUS_ACTOR=$win CITY_AGENT_ROLE=$ROL_REPO CITY_RUNTIME_KIND=repo CITY_BUS_URL= CITY_BUS_TOKEN= $BROKER_ENV$JAULA$FALLBACK_COMMAND"
628
+ "$(entorno_de "$win")$BROKER_ENV$JAULA$FALLBACK_COMMAND"
513
629
  "$RUNTIME" fallback "$win" "$SESSION:$win" "${FALLBACK_COMMAND%% *}"
514
630
  else
515
631
  tmux send-keys -t "$SESSION:$win" \
@@ -519,9 +635,9 @@ for path in ${RUTAS[@]+"${RUTAS[@]}"}; do
519
635
  espera="$(retraso "$turno")"
520
636
  turno=$((turno + 1))
521
637
  if [ "$espera" = "0" ]; then espera=''; else espera="sleep $espera; "; fi
522
- CLAUDE_REPO="claude --name $SESSION-$win$(motor_de "$win") --settings '$SETTINGS'$NO_PEER_TOOLS$YOLO_FLAG"
638
+ CLAUDE_REPO="$(con_motor "$win" claude "claude --name $SESSION-$win")$CLAUDE_TRATO$YOLO_FLAG"
523
639
  lanza "$SESSION:$win" "$win" "$path" \
524
- "$espera$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME CITY_BUS_ACTOR=$win CITY_AGENT_ROLE=$ROL_REPO CITY_RUNTIME_KIND=repo CITY_BUS_URL= CITY_BUS_TOKEN= $BROKER_ENV${JAULA}$CAGE_RUNTIME_ENV${CLAUDE_AUTH_PREFIX}$(gateway_line "$win" "$path" "$CLAUDE_REPO")"
640
+ "$espera$(entorno_de "$win")$BROKER_ENV${JAULA}$CAGE_RUNTIME_ENV${CLAUDE_AUTH_PREFIX}$(gateway_line "$win" "$path" "$CLAUDE_REPO")"
525
641
  fi
526
642
  fi
527
643
  done
@@ -21,6 +21,7 @@ filesystem; the git-backed `code` counter is injected so this module needs no
21
21
  network and stays trivially testable.
22
22
  """
23
23
 
24
+ import busca # it owns what counts as a document
24
25
  import os
25
26
  import time
26
27
 
@@ -28,7 +29,9 @@ import deliberations
28
29
  import workspace
29
30
 
30
31
  #: Extensions that count as a knowledge document.
31
- DOC_EXT = ('.md', '.txt', '.rst', '.org', '.pdf', '.doc', '.docx', '.adoc')
32
+ #: What a knowledge house grows on. `busca` owns the list, because the same
33
+ #: question decides which folders the picker offers as one in the first place.
34
+ DOC_EXT = busca.DOCUMENTOS
32
35
  DIAS_RECIENTE = 30
33
36
  _SEG_RECIENTE = DIAS_RECIENTE * 24 * 3600
34
37
 
@@ -17,21 +17,25 @@ AQUI = os.path.dirname(os.path.abspath(__file__))
17
17
  RAIZ = os.path.dirname(os.path.dirname(AQUI))
18
18
  DEMO = os.path.join(RAIZ, "demo")
19
19
 
20
- #: What each story is, in the words somebody choosing one would use. The domain
21
- #: is the one the wizard offers, so "see it in my own field" means something.
22
- DOMINIOS = {
23
- "software": ("Software", "Aurora Games"),
24
- "medicina": ("Healthcare", "Clínica Alba"),
25
- "legal": ("Legal", "Costa & Ley"),
26
- }
20
+ #: The one thing about a demo that lives nowhere else: the label for the field
21
+ #: it belongs to. Everything else — which stories exist, what each is called,
22
+ #: which city it happens in — is read from `stories.py` and from the fixture's
23
+ #: own `city.yml`, because those already own it. A table repeating them meant a
24
+ #: fourth story was invisible in the Hall until somebody remembered this dict,
25
+ #: and renaming a demo city made the shelf quietly lie.
26
+ ETIQUETAS = {"software": "Software", "medicina": "Healthcare", "legal": "Legal"}
27
27
 
28
28
 
29
29
  def carpeta():
30
30
  return os.path.join(DEMO, "grabaciones")
31
31
 
32
32
 
33
- def _titulos():
34
- """The story titles, read from the stories module when it is installed."""
33
+ def _historias():
34
+ """The stories themselves, when the demo folder is installed.
35
+
36
+ An install can be trimmed. A Hall whose demo shelf is empty should say so
37
+ rather than fail to load, so this returns nothing rather than raising.
38
+ """
35
39
  import sys
36
40
 
37
41
  if DEMO not in sys.path:
@@ -39,31 +43,39 @@ def _titulos():
39
43
  try:
40
44
  from stories import STORIES # noqa: PLC0415
41
45
 
42
- return {k: v.get("title", k) for k, v in STORIES.items()}
46
+ return STORIES
43
47
  except (ImportError, AttributeError):
44
48
  return {}
45
49
 
46
50
 
51
+ def ficha(ident, eventos_=None):
52
+ """One demo's card, built from one parse of its recording.
53
+
54
+ `eventos_` lets a caller that already has the events avoid reading the file
55
+ again — which is the difference between the shelf costing one pass per demo
56
+ and the endpoint costing four to serve one of them.
57
+ """
58
+ import cities
59
+
60
+ historia = _historias().get(ident)
61
+ ruta = os.path.join(carpeta(), f"{ident}.jsonl")
62
+ if historia is None or not os.path.isfile(ruta):
63
+ return None
64
+ if eventos_ is None:
65
+ eventos_ = _carga(ruta)
66
+ return {
67
+ "id": ident,
68
+ "dominio": ETIQUETAS.get(ident, ident),
69
+ "ciudad": cities.nombre(os.path.join(DEMO, historia.get("city", ""))),
70
+ "titulo": historia.get("title", ident),
71
+ "turnos": len(eventos_),
72
+ "reparto": _reparto(eventos_),
73
+ }
74
+
75
+
47
76
  def catalogo():
48
77
  """Every demo with a recording on disk, in a stable order."""
49
- titulos = _titulos()
50
- fuera = []
51
- for ident in sorted(DOMINIOS):
52
- ruta = os.path.join(carpeta(), f"{ident}.jsonl")
53
- if not os.path.isfile(ruta):
54
- continue
55
- dominio, ciudad = DOMINIOS[ident]
56
- fuera.append(
57
- {
58
- "id": ident,
59
- "dominio": dominio,
60
- "ciudad": ciudad,
61
- "titulo": titulos.get(ident, ident),
62
- "turnos": _cuenta(ruta),
63
- "reparto": _reparto(ruta),
64
- }
65
- )
66
- return fuera
78
+ return [f for f in (ficha(i) for i in sorted(_historias())) if f]
67
79
 
68
80
 
69
81
  def _lineas(ruta):
@@ -77,18 +89,27 @@ def _lineas(ruta):
77
89
  return
78
90
 
79
91
 
80
- def _cuenta(ruta):
81
- return sum(1 for _ in _lineas(ruta))
82
-
92
+ def _carga(ruta):
93
+ """One recording, read and parsed once.
83
94
 
84
- def _reparto(ruta):
85
- """Who speaks in this story, in the order they first do."""
86
- visto = []
95
+ The catalogue used to walk each file twice — once to count turns, once for
96
+ the cast — and then the endpoint parsed the chosen one a third time. Three
97
+ passes over the same small file to answer three questions about it.
98
+ """
99
+ fuera = []
87
100
  for linea in _lineas(ruta):
88
101
  try:
89
- actor = json.loads(linea).get("actor", "")
102
+ fuera.append(json.loads(linea))
90
103
  except json.JSONDecodeError:
91
104
  continue
105
+ return fuera
106
+
107
+
108
+ def _reparto(eventos_):
109
+ """Who speaks in this story, in the order they first do."""
110
+ visto = []
111
+ for evento in eventos_:
112
+ actor = evento.get("actor", "")
92
113
  if actor and actor not in visto:
93
114
  visto.append(actor)
94
115
  return visto
@@ -96,12 +117,6 @@ def _reparto(ruta):
96
117
 
97
118
  def eventos(ident):
98
119
  """One recording, as the list of activity events it holds."""
99
- if ident not in DOMINIOS:
120
+ if ident not in _historias():
100
121
  return []
101
- fuera = []
102
- for linea in _lineas(os.path.join(carpeta(), f"{ident}.jsonl")):
103
- try:
104
- fuera.append(json.loads(linea))
105
- except json.JSONDecodeError:
106
- continue
107
- return fuera
122
+ return _carga(os.path.join(carpeta(), f"{ident}.jsonl"))
@@ -28,41 +28,56 @@ import sys
28
28
  AQUI = os.path.dirname(os.path.abspath(__file__))
29
29
  sys.path.insert(0, AQUI)
30
30
  import atajos # noqa: E402 the desktop icons, one per city
31
+ import busca # noqa: E402 it owns where the disk index is cached
31
32
  import cities # noqa: E402
32
33
  import city_env # noqa: E402 where the bus keeps its folder
33
34
 
34
35
  def _sitios():
35
36
  """Everything on disk that belongs to the product and to nothing else, as
36
37
  (what it is, absolute path, whether it holds your cities)."""
37
- casa = os.path.expanduser("~")
38
- cache = os.environ.get("XDG_CACHE_HOME") or os.path.join(casa, ".cache")
39
38
  return [
40
39
  ("your cities, their state and their backups", cities.raiz(), True),
41
40
  ("the list of which cities exist", os.path.dirname(cities.REGISTRO), False),
42
- ("the cached index of your disk", os.path.join(cache, "agents-city"), False),
41
+ # Every entry here asks the module that owns the path. This one used to
42
+ # respell the cache rule, which meant moving the cache would silently
43
+ # leave it behind — the exact failure "everything it ever wrote" exists
44
+ # to prevent, and the kind nobody notices.
45
+ ("the cached index of your disk", os.path.dirname(busca.fichero_cache()), False),
43
46
  ("the bus folder, its .env and its hooks", city_env.CANAL, False),
44
47
  ]
45
48
 
46
49
 
47
50
  def _tamano(ruta):
48
- """How much is there, without following a single link out of it."""
49
- if not os.path.exists(ruta):
51
+ """How much is there, without following a single link out of it.
52
+
53
+ Walked with `scandir`, whose entries already carry the type and size the
54
+ directory read fetched — `islink` plus `getsize` per file was two extra
55
+ syscalls each for an answer we were being handed.
56
+ """
57
+ if os.path.islink(ruta):
50
58
  return 0
51
- if os.path.islink(ruta) or os.path.isfile(ruta):
52
- return 0 if os.path.islink(ruta) else os.path.getsize(ruta)
59
+ if os.path.isfile(ruta):
60
+ return os.path.getsize(ruta)
53
61
  total = 0
54
- for base, dirs, ficheros in os.walk(ruta):
55
- # A symlinked directory is somebody's repository. It is not ours to
56
- # measure, and it is certainly not ours to delete.
57
- dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(base, d))]
58
- for f in ficheros:
59
- ruta_f = os.path.join(base, f)
60
- if os.path.islink(ruta_f):
61
- continue
62
- try:
63
- total += os.path.getsize(ruta_f)
64
- except OSError:
65
- continue
62
+ pila = [ruta]
63
+ while pila:
64
+ try:
65
+ with os.scandir(pila.pop()) as it:
66
+ for e in it:
67
+ try:
68
+ if e.is_symlink():
69
+ # A symlinked directory is somebody's repository. It
70
+ # is not ours to measure, and certainly not ours to
71
+ # delete.
72
+ continue
73
+ if e.is_dir(follow_symlinks=False):
74
+ pila.append(e.path)
75
+ else:
76
+ total += e.stat(follow_symlinks=False).st_size
77
+ except OSError:
78
+ continue
79
+ except OSError:
80
+ continue
66
81
  return total
67
82
 
68
83
 
@@ -76,9 +91,9 @@ def _tamano_legible(n):
76
91
  return f"{valor:.0f} {unidades[i]}" if i == 0 else f"{valor:.1f} {unidades[i]}"
77
92
 
78
93
 
79
- def plan(usuario="", conservar_ciudades=False, con_npm=False):
94
+ def plan(conservar_ciudades=False, con_npm=False):
80
95
  """What would be removed, in the order it would be removed."""
81
- usuario = usuario or cities.usuario_actual()
96
+ usuario = cities.usuario_actual()
82
97
  pasos = [{"que": "stop every city session, hall and map this product started",
83
98
  "clase": "procesos"}]
84
99
  for datos in [c["ruta"] for c in cities.lista(usuario)]:
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """What happened, written down, so a failure on somebody else's machine is not a
3
+ story they have to remember.
4
+
5
+ The Hall had no log at all. It answered the browser with an error, the browser
6
+ put a toast on screen, the toast went away, and there was nothing left. When
7
+ somebody said "it says the name is empty and the name is right there", the only
8
+ honest answer was "reproduce it for me" — which is asking a person to debug
9
+ their own bug report.
10
+
11
+ So: one file, always written, per city.
12
+
13
+ ~/.agents-city/.runtime/bus/<city>/hall.jsonl
14
+
15
+ One JSON object per line, oldest first. The browser writes into the same file
16
+ as the server, because half of these failures happen in the browser and a log
17
+ that stops at the network boundary tells half a story.
18
+
19
+ Two things it must never do. It must not grow without bound — it rotates at two
20
+ megabytes and keeps one previous file, which is enough to see what happened and
21
+ not enough to matter. And it must not record a secret: `agents-city doctor
22
+ --report` exists so somebody can send this to a stranger, and a log that has to
23
+ be read before it is sent is a log nobody sends.
24
+ """
25
+
26
+ import json
27
+ import os
28
+ import re
29
+ import time
30
+
31
+ #: Rotate here. Small enough that the file is readable, large enough to hold a
32
+ #: session's worth of a person clicking around.
33
+ LIMITE = 2 * 1024 * 1024
34
+
35
+ #: Anything whose NAME says it is a credential. Matched on the key, because the
36
+ #: value of a token looks like the value of an id.
37
+ SECRETO = re.compile(
38
+ r"pase|token|secret|password|passwd|authorization|cookie|api[_-]?key|credential",
39
+ re.I,
40
+ )
41
+
42
+ #: And anything shaped like one wherever it turns up: a long unbroken run of
43
+ #: hex or base64. Slashes are NOT part of it — a temp directory is a long run
44
+ #: of characters with slashes in it, and redacting `/var/folders/...` turned a
45
+ #: useful error message into `[redacted]` while protecting nothing.
46
+ PARECE_CLAVE = re.compile(r"(?<![\w./-])[A-Za-z0-9+_-]{32,}={0,2}(?![\w./-])")
47
+
48
+
49
+ def ruta(datos):
50
+ import runtime_processes
51
+
52
+ return os.path.join(runtime_processes.ruta(datos), "hall.jsonl")
53
+
54
+
55
+ def limpia(valor, profundidad=0):
56
+ """The same value with anything that looks like a credential taken out.
57
+
58
+ Applied on the way IN, not on the way out: a secret that reaches the file
59
+ has already been written to somebody's disk, and the promise this makes is
60
+ that the file is safe to attach to an issue without reading it first.
61
+ """
62
+ if profundidad > 6:
63
+ return "…"
64
+ if isinstance(valor, dict):
65
+ return {
66
+ k: ("[redacted]" if SECRETO.search(str(k)) else limpia(v, profundidad + 1))
67
+ for k, v in list(valor.items())[:40]
68
+ }
69
+ if isinstance(valor, (list, tuple)):
70
+ return [limpia(v, profundidad + 1) for v in valor[:40]]
71
+ if isinstance(valor, str):
72
+ recortado = valor if len(valor) <= 500 else valor[:497] + "…"
73
+ return PARECE_CLAVE.sub("[redacted]", recortado)
74
+ return valor
75
+
76
+
77
+ def _rota(fichero):
78
+ try:
79
+ if os.path.getsize(fichero) < LIMITE:
80
+ return
81
+ except OSError:
82
+ return
83
+ try:
84
+ os.replace(fichero, fichero + ".1")
85
+ except OSError:
86
+ pass
87
+
88
+
89
+ def apunta(datos, tipo, **campos):
90
+ """One line. Never raises: a log that can break the thing it is logging is
91
+ worse than no log, and this is called from inside request handlers."""
92
+ try:
93
+ fichero = ruta(datos)
94
+ os.makedirs(os.path.dirname(fichero), exist_ok=True)
95
+ _rota(fichero)
96
+ linea = {"t": time.strftime("%Y-%m-%dT%H:%M:%S"), "tipo": tipo}
97
+ linea.update(limpia(campos))
98
+ with open(fichero, "a", encoding="utf-8") as f:
99
+ f.write(json.dumps(linea, ensure_ascii=False) + "\n")
100
+ except (OSError, TypeError, ValueError):
101
+ pass
102
+
103
+
104
+ def lee(datos, cuantas=200):
105
+ """The last `cuantas` entries, oldest first, across the rotation."""
106
+ fuera = []
107
+ for fichero in (ruta(datos) + ".1", ruta(datos)):
108
+ try:
109
+ with open(fichero, encoding="utf-8") as f:
110
+ fuera.extend(l.strip() for l in f if l.strip())
111
+ except OSError:
112
+ continue
113
+ salida = []
114
+ for linea in fuera[-cuantas:]:
115
+ try:
116
+ salida.append(json.loads(linea))
117
+ except json.JSONDecodeError:
118
+ salida.append({"tipo": "unreadable", "linea": linea[:200]})
119
+ return salida