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
@@ -15,13 +15,14 @@ import './es'; // the Spanish dictionary registers itself on import
15
15
  import { FormularioDeCasa } from './casa';
16
16
  import { confirma, pregunta } from './dialogo';
17
17
  import { Demos } from './demo';
18
+ import type { Vista } from './vista';
18
19
  import { Explorador } from './explorador';
19
20
  import { idioma, plural, ponIdioma, t as _ } from './idioma';
20
21
  import {
21
22
  ESFUERZOS,
22
- MODELOS,
23
23
  NIVEL_ESFUERZO,
24
24
  RUNTIMES,
25
+ motorDe,
25
26
  nivelDeMotor,
26
27
  opciones as opcionesDe,
27
28
  } from './motores';
@@ -90,6 +91,44 @@ interface Road {
90
91
  domain?: string;
91
92
  role?: string;
92
93
  }
94
+ interface ReceptionSummary {
95
+ pending: number;
96
+ pendingBytes: number;
97
+ routingMode: 'manual' | 'auto';
98
+ reviewPolicy: 'every_message' | 'new_thread';
99
+ routerProfile: string | null;
100
+ autoAvailable: boolean;
101
+ error?: string;
102
+ }
103
+ interface ReceptionState {
104
+ protocol: 'agents-city-reception/1';
105
+ error?: string;
106
+ settings: {
107
+ routingMode: 'manual' | 'auto';
108
+ reviewPolicy: 'every_message' | 'new_thread';
109
+ routerProfile: string | null;
110
+ autoAvailable: boolean;
111
+ autoRules: Array<{ cityId: string; address: string; keywords: string[] }>;
112
+ };
113
+ summary: { pending: number; pendingBytes: number; shown: number };
114
+ messages: Array<{
115
+ id: string;
116
+ from: string;
117
+ fromName: string;
118
+ kind: 'message' | 'rejection';
119
+ inReplyTo: string | null;
120
+ createdAt: string;
121
+ receivedAt: string;
122
+ receivedVia: string;
123
+ text: string;
124
+ connectionId: string | null;
125
+ roadId: string | null;
126
+ agentExposure: false;
127
+ }>;
128
+ cities: Array<{ id: string; name: string; address: string }>;
129
+ connections: Array<{ id: string; roadId: string; name: string; connected: boolean }>;
130
+ outbox: { queued: number };
131
+ }
93
132
  interface SkillInfo {
94
133
  name: string;
95
134
  description: string;
@@ -179,6 +218,7 @@ interface Estado {
179
218
  sesion: string;
180
219
  ciudades: Ciudad[];
181
220
  roads: Road[];
221
+ reception: ReceptionSummary;
182
222
  invitation: Invitation;
183
223
  skills: Record<string, AgentCapabilities>;
184
224
  deliberations: Deliberation[];
@@ -239,14 +279,52 @@ declare global {
239
279
  const PASE = window.PASE;
240
280
  const CIUDAD = new URLSearchParams(location.search).get('city') ?? '';
241
281
 
282
+ /**
283
+ * Tell the city's journal something happened here.
284
+ *
285
+ * The page is half of this product and it used to keep its failures to itself:
286
+ * an error became a toast, the toast went away, and a person reporting it had
287
+ * only their memory. This writes into the same file the server writes, so one
288
+ * file answers "what happened" — and `agents-city doctor --report` can hand it
289
+ * to somebody else without them having to have been watching.
290
+ *
291
+ * It never throws and never awaits: a log that can break the thing it is
292
+ * logging, or slow it down, is worse than no log.
293
+ */
294
+ function anota(que: string, detalle?: unknown, donde?: string): void {
295
+ try {
296
+ let u = '/api/diario?PASE=' + encodeURIComponent(PASE);
297
+ if (CIUDAD) u += '&city=' + encodeURIComponent(CIUDAD);
298
+ void fetch(u, {
299
+ method: 'POST',
300
+ headers: { 'X-City-Pase': PASE, 'Content-Type': 'application/json' },
301
+ body: JSON.stringify({ que, detalle, donde: donde ?? location.hash ?? '' }),
302
+ keepalive: true,
303
+ }).catch(() => {});
304
+ } catch {
305
+ /* the journal is a courtesy, never a dependency */
306
+ }
307
+ }
308
+
242
309
  async function api<T>(ruta: string, opts?: RequestInit): Promise<T> {
243
310
  let u = ruta + (ruta.includes('?') ? '&' : '?') + 'PASE=' + encodeURIComponent(PASE);
244
311
  if (CIUDAD) u += '&city=' + encodeURIComponent(CIUDAD);
245
- const r = await fetch(u, {
246
- headers: { 'X-City-Pase': PASE, 'Content-Type': 'application/json' },
247
- ...opts,
248
- });
249
- return r.json() as Promise<T>;
312
+ let r: Response;
313
+ try {
314
+ r = await fetch(u, {
315
+ headers: { 'X-City-Pase': PASE, 'Content-Type': 'application/json' },
316
+ ...opts,
317
+ });
318
+ } catch (e) {
319
+ // A request that never came back. This is the one a person cannot report,
320
+ // because nothing on screen says it happened.
321
+ if (ruta !== '/api/diario') anota('fetch failed', String(e), ruta);
322
+ throw e;
323
+ }
324
+ const cuerpo = (await r.json()) as T & { error?: string };
325
+ if (ruta !== '/api/diario' && (!r.ok || cuerpo?.error))
326
+ anota('api refused', { estado: r.status, error: cuerpo?.error }, ruta);
327
+ return cuerpo as T;
250
328
  }
251
329
 
252
330
  /** querySelector that refuses to hand back null: a missing element here is a bug
@@ -291,6 +369,7 @@ const SECCIONES: Array<[string, string]> = [
291
369
  ['mapa', 'The map'],
292
370
  ['puesto', 'My seat'],
293
371
  ['barrios', 'Districts'],
372
+ ['recepcion', 'Reception'],
294
373
  ['red', 'Roads'],
295
374
  ['committee', 'Committee'],
296
375
  ['gente', 'Houses'],
@@ -351,9 +430,9 @@ function pintaDemoControles(): void {
351
430
  const caja = document.createElement('div');
352
431
  caja.id = 'demoControles';
353
432
  caja.innerHTML =
354
- `<span>guided committee</span>` +
355
- `<button id="demoReplay" type="button" title="Play the guided committee from the top">⟳ replay</button>` +
356
- `<button id="demoPausa" type="button" title="Pause or resume mid-scene">⏸ pause</button>`;
433
+ `<span>${_('guided committee')}</span>` +
434
+ `<button id="demoReplay" type="button" title="${_('Play the guided committee from the top')}">${_('⟳ replay')}</button>` +
435
+ `<button id="demoPausa" type="button" title="${_('Pause or resume mid-scene')}">${_('⏸ pause')}</button>`;
357
436
  header.appendChild(caja);
358
437
  const pausa = q<HTMLButtonElement>('#demoPausa', caja);
359
438
  const manda = (action: string) =>
@@ -496,10 +575,10 @@ function pintaActividad(seguir = false): void {
496
575
  if (!dot || !state || !list || !filter || !context || !workToggle) return;
497
576
  dot.classList.toggle('on', liveConnected);
498
577
  state.textContent = liveConnected
499
- ? 'websocket live'
578
+ ? _('websocket live')
500
579
  : liveUrl
501
- ? 'reconnecting'
502
- : 'session offline';
580
+ ? _('reconnecting')
581
+ : _('session offline');
503
582
 
504
583
  const opened = new Map<string, string>();
505
584
  for (const event of liveEvents) {
@@ -513,7 +592,7 @@ function pintaActividad(seguir = false): void {
513
592
  }
514
593
  }
515
594
  filter.innerHTML =
516
- '<option value="">all conversations</option>' +
595
+ `<option value="">${_('all conversations')}</option>` +
517
596
  [...opened.entries()]
518
597
  .reverse()
519
598
  .map(
@@ -530,7 +609,7 @@ function pintaActividad(seguir = false): void {
530
609
  };
531
610
  workToggle.classList.toggle('on', liveShowWork);
532
611
  workToggle.setAttribute('aria-pressed', String(liveShowWork));
533
- workToggle.textContent = liveShowWork ? 'hide work' : 'show work';
612
+ workToggle.textContent = liveShowWork ? _('hide work') : _('show work');
534
613
  workToggle.onclick = () => {
535
614
  liveShowWork = !liveShowWork;
536
615
  pintaActividad(true);
@@ -548,8 +627,8 @@ function pintaActividad(seguir = false): void {
548
627
  if (!visible.length) {
549
628
  list.innerHTML = `<li class="liveEmpty">${
550
629
  liveConnected
551
- ? 'The bus is live. Questions, positions and moderated replies will appear here.'
552
- : 'Start the city session. Its visible conversation will appear here as it happens.'
630
+ ? _('The bus is live. Questions, positions and moderated replies will appear here.')
631
+ : _('Start the city session. Its visible conversation will appear here as it happens.')
553
632
  }</li>`;
554
633
  return;
555
634
  }
@@ -580,7 +659,7 @@ function renderActividad(event: ActivityEvent): string {
580
659
  }</summary><ul>${event.details.map((detail) => `<li>${esc(detail)}</li>`).join('')}</ul></details>`
581
660
  : '';
582
661
  const full = long
583
- ? `<details class="liveFull"><summary>read full message</summary><p>${esc(summary)}</p></details>`
662
+ ? `<details class="liveFull"><summary>${_('read full message')}</summary><p>${esc(summary)}</p></details>`
584
663
  : '';
585
664
  return `<li class="liveTurn ${side} ${esc(event.tone)}">
586
665
  <div class="liveAvatar${E?.avatars?.[event.actor] ? ' conCara' : ''}"
@@ -645,7 +724,7 @@ function pintaContexto(thread: string, container: HTMLElement): void {
645
724
  actor === 'seat' ? 'seat · chair' : `${actor} · ${rolOperativo(actor) || 'member'}`,
646
725
  )}" style="--actor-hue:${tonoActor(actor)}">${cara(actor, actor === 'seat')}</span>`,
647
726
  )
648
- .join('')}</div><span class="liveContextText"><b>seat moderates</b> · ${esc(
727
+ .join('')}</div><span class="liveContextText"><b>${_('seat moderates')}</b> · ${esc(
649
728
  received,
650
729
  )}/${esc(total)} positions · ${esc(status)}</span>`;
651
730
  }
@@ -770,29 +849,40 @@ function rail(): void {
770
849
  ? String(Object.keys(E.skills).length)
771
850
  : id === 'committee'
772
851
  ? String(E.deliberations.length)
773
- : id === 'red'
774
- ? String(E.roads.length)
775
- : id === 'barrios'
776
- ? String(E.parcelas.length)
777
- : '';
778
- return `<li class="${id === SECCION ? 'aqui' : ''}" data-s="${id}">
852
+ : id === 'recepcion'
853
+ ? String(E.reception?.pending ?? 0)
854
+ : id === 'red'
855
+ ? String(E.roads.length)
856
+ : id === 'barrios'
857
+ ? String(E.parcelas.length)
858
+ : '';
859
+ return `<li class="${id === SECCION ? 'aqui' : ''}" data-s="${id}" role="button"
860
+ tabindex="0" ${id === SECCION ? 'aria-current="page"' : ''}>
779
861
  <span>${esc(_(et))}</span>${n ? `<span class="n">${n}</span>` : ''}</li>`;
780
862
  })
781
863
  .join('');
782
864
  q('#railOtras').innerHTML =
783
- `<li class="${SECCION === 'ciudades' ? 'aqui' : ''}" data-s="ciudades">
865
+ `<li class="${SECCION === 'ciudades' ? 'aqui' : ''}" data-s="ciudades" role="button"
866
+ tabindex="0" ${SECCION === 'ciudades' ? 'aria-current="page"' : ''}>
784
867
  <span>${_('All cities')}</span><span class="n">${E.ciudades.length}</span></li>`;
785
868
  todos<HTMLElement>('#rail li,#railOtras li').forEach((li) => {
786
- li.onclick = () => {
869
+ const abre = () => {
787
870
  document.body.classList.remove('enGuia');
788
871
  SECCION = li.dataset.s ?? 'mapa';
789
872
  pinta();
790
873
  };
874
+ li.onclick = abre;
875
+ li.onkeydown = (event) => {
876
+ if (event.key !== 'Enter' && event.key !== ' ') return;
877
+ event.preventDefault();
878
+ abre();
879
+ };
791
880
  });
792
881
  q('#dondeDatos').textContent = corto(E.datos);
793
882
  const cs = q<HTMLElement>('#ciudades');
794
883
  if (E.ciudades.length > 1) {
795
- cs.innerHTML = `<select id="cambiaCiudad" title="which city this hall manages">
884
+ cs.innerHTML = `<select id="cambiaCiudad" aria-label="${_('which city this hall manages')}"
885
+ title="${_('which city this hall manages')}">
796
886
  ${E.ciudades
797
887
  .map(
798
888
  (c) => `<option value="${esc(c.ruta)}" ${c.actual ? 'selected' : ''}>
@@ -837,10 +927,15 @@ function rail(): void {
837
927
  };
838
928
  }
839
929
 
930
+ /** The view on screen, if it is one that has anything to stop. */
931
+ let montada: Vista | null = null;
932
+
840
933
  function pinta(): void {
841
- // A playing demo left running in a section nobody is looking at is a timer
842
- // firing into a detached node — and a soundtrack to the wrong screen.
843
- if (SECCION !== 'demos') demos?.para();
934
+ // Whatever was here is being left. The dispatcher used to name one view and
935
+ // one of its teardown methods by hand, which is a line it would have grown
936
+ // again for the next view that owned a timer or a socket.
937
+ montada?.desmonta?.();
938
+ montada = null;
844
939
  rail();
845
940
  q<HTMLElement>('.cuerpo').style.padding = '';
846
941
  q<HTMLElement>('#lienzo').style.maxWidth = '';
@@ -871,7 +966,8 @@ VISTAS.bienvenida = () => {
871
966
  yo: E.yo,
872
967
  datos: corto(E.datos),
873
968
  });
874
- void guia.pinta(q<HTMLElement>('#lienzo'));
969
+ guia.monta(q<HTMLElement>('#lienzo'));
970
+ montada = guia;
875
971
  };
876
972
 
877
973
  VISTAS.ciudades = () => {
@@ -896,23 +992,24 @@ VISTAS.ciudades = () => {
896
992
  .join('');
897
993
  q('#lienzo').innerHTML = `<div><span class="sub">cities</span>
898
994
  <h1 style="margin-top:6px">${_('Your cities')}</h1>
899
- <p class="prosa" style="margin-top:8px">One person, several autonomous cities: each has
995
+ <p class="prosa" style="margin-top:8px">${_(`One person, several autonomous cities: each has
900
996
  its own identity, domain, chair, <b>its own houses</b> and its own roads. A house is not
901
997
  shared — it stands inside the city that owns it, with its workspace and mounts under that
902
998
  city's folder — so two cities can each have a <code class="mono">docs</code> house and
903
999
  they are two different workers. They share nothing unless you build a road between
904
- them.</p></div>
1000
+ them.`)}</p></div>
905
1001
  <div class="lista"><div class="filas">${filas}</div></div>
906
1002
  <div class="campos" style="max-width:420px">
907
1003
  <div class="campo"><label>${_('Start another city')}</label>
908
- <input type="text" id="nuevaCiudad" placeholder="client-a, research, the book…"></div>
1004
+ <input type="text" id="nuevaCiudad" placeholder="${_('client-a, research, the book…')}"></div>
909
1005
  <button class="bt ppal" id="creaCiudad">Create it</button>
910
1006
  </div>
911
1007
  <div class="peligro">
912
1008
  <h3>${_('Start this city over')}</h3>
913
- <p class="prosa">Takes <b>${esc(E.city_name)}</b> back to its first day: no seat, no
914
- agents, no committee history, no map. Your repositories and document folders are
915
- <b>never touched</b> — only the city that points at them.</p>
1009
+ <p class="prosa">${_(
1010
+ 'Takes {city} back to its first day: no seat, no agents, no committee history, no map. Your repositories and document folders are <b>never touched</b> — only the city that points at them.',
1011
+ { city: `<b>${esc(E.city_name)}</b>` },
1012
+ )}</p>
916
1013
  <button class="bt malo" id="reiniciaCiudad">${_('Start over…')}</button>
917
1014
  </div>
918
1015
  <p class="pista">Archiving <b>moves</b> a city into
@@ -1009,9 +1106,10 @@ VISTAS.ciudades = () => {
1009
1106
  let demos: Demos | null = null;
1010
1107
 
1011
1108
  VISTAS.demos = () => {
1012
- if (!demos) demos = new Demos({ api, esc, pinta: renderActividad });
1109
+ demos ??= new Demos({ api, esc, pinta: renderActividad });
1013
1110
  q('#lienzo').innerHTML = '<div id="demoHueco"></div>';
1014
1111
  demos.monta(q<HTMLElement>('#demoHueco'));
1112
+ montada = demos;
1015
1113
  };
1016
1114
 
1017
1115
  VISTAS.resumen = () => {
@@ -1084,19 +1182,19 @@ VISTAS.resumen = () => {
1084
1182
  counted with <code class="mono">${esc(E.grow || 'nothing yet')}</code>.</p>`
1085
1183
  }</div>
1086
1184
  <div class="cifras">
1087
- <div class="cifra"><b>${Object.keys(E.skills).length}</b><span>repo agents</span></div>
1185
+ <div class="cifra"><b>${Object.keys(E.skills).length}</b><span>${_('repo agents')}</span></div>
1088
1186
  <div class="cifra"><b>${E.parcelas.length}</b><span>houses</span></div>
1089
1187
  <div class="cifra"><b>${E.roads.length}</b><span>roads</span></div>
1090
- <div class="cifra"><b>${E.deliberations.length}</b><span>committee acts</span></div>
1091
- <div class="cifra"><b>${Object.values(E.skills).reduce((n, a) => n + a.skills.length, 0)}</b><span>skills recognised</span></div>
1188
+ <div class="cifra"><b>${E.deliberations.length}</b><span>${_('committee acts')}</span></div>
1189
+ <div class="cifra"><b>${Object.values(E.skills).reduce((n, a) => n + a.skills.length, 0)}</b><span>${_('skills recognised')}</span></div>
1092
1190
  </div>
1093
1191
  <div class="luces">
1094
- <span class="luz ${E.tarjetas.length ? 'on' : ''}">data repo</span>
1192
+ <span class="luz ${E.tarjetas.length ? 'on' : ''}">${_('data repo')}</span>
1095
1193
  <span class="luz ${E.gh ? 'on' : 'neutra'}">github${E.gh ? '' : ' — optional'}</span>
1096
- <span class="luz ${E.plugin ? 'on' : ''}">plugin installed</span>
1194
+ <span class="luz ${E.plugin ? 'on' : ''}">${_('plugin installed')}</span>
1097
1195
  <span class="luz ${sesionArriba ? 'on' : 'neutra'}">tmux session${sesionArriba ? ' up' : ''}</span>
1098
1196
  </div>
1099
- <div><span class="sub">what is left</span>
1197
+ <div><span class="sub">${_('what is left')}</span>
1100
1198
  <div class="tareas" style="margin-top:9px">${tareas
1101
1199
  .map(
1102
1200
  (t) => `
@@ -1107,7 +1205,7 @@ VISTAS.resumen = () => {
1107
1205
  </div></div>
1108
1206
  <div><span class="sub">work</span>
1109
1207
  <div style="display:flex;flex-direction:column;gap:9px;margin-top:9px">
1110
- <div class="orden"><span class="et2">your day</span>
1208
+ <div class="orden"><span class="et2">${_('your day')}</span>
1111
1209
  <code>${
1112
1210
  sesionArriba
1113
1211
  ? 'tmux attach -t ' + esc(E.sesion)
@@ -1116,7 +1214,7 @@ VISTAS.resumen = () => {
1116
1214
  ${
1117
1215
  sesionArriba
1118
1216
  ? `<button class="bt mini" data-copia="tmux attach -t ${esc(E.sesion)}">copy</button>`
1119
- : '<button class="bt mini ppal" id="abreSesion">open my session</button>'
1217
+ : `<button class="bt mini ppal" id="abreSesion">${_('open my session')}</button>`
1120
1218
  }
1121
1219
  </div>
1122
1220
  <div class="orden"><span class="et2">the map</span>
@@ -1230,26 +1328,26 @@ VISTAS.puesto = () => {
1230
1328
 
1231
1329
  async function puesto(): Promise<void> {
1232
1330
  const mia = E.tarjetas.find((t) => t.user === E.yo);
1233
- q('#lienzo').innerHTML = `<div><span class="sub">my seat</span>
1331
+ q('#lienzo').innerHTML = `<div><span class="sub">${_('my seat')}</span>
1234
1332
  <h1 style="margin-top:6px">${esc(E.yo)}</h1>
1235
1333
  <p class="prosa" style="margin-top:8px">This is your chair: the work domain, your
1236
1334
  role inside it, and one goal. The seat stays the boss even when its
1237
1335
  professional role is blank. Saving writes your card —
1238
1336
  <code class="mono">${esc(corto(E.datos))}/${esc(E.yo)}.md</code> — the same file
1239
1337
  every other door writes, and it never touches your roster.</p></div>
1240
- <div><span class="sub">work domain</span><div class="rolejilla" id="domains" style="margin-top:9px">
1241
- <p class="cargando">reading the domain packs</p></div></div>
1338
+ <div><span class="sub">${_('work domain')}</span><div class="rolejilla" id="domains" style="margin-top:9px">
1339
+ <p class="cargando">${_('reading the domain packs')}</p></div></div>
1242
1340
  <div><span class="sub">role</span><div class="rolejilla" id="roles" style="margin-top:9px">
1243
- <p class="cargando">reading the role files</p></div></div>
1244
- <div><span class="sub">the agents</span>
1341
+ <p class="cargando">${_('reading the role files')}</p></div></div>
1342
+ <div><span class="sub">${_('the agents')}</span>
1245
1343
  <p class="prosa" style="margin-top:8px">Who works in this city — and what each
1246
- one works on — lives in <b>Agents &amp; skills</b>, where an agent is asked for
1344
+ one works on — lives in <b>${_('Agents &amp; skills')}</b>, where an agent is asked for
1247
1345
  whole: its kind, its role, its repositories and document folders, its engine
1248
1346
  and its skills. One place, so the terminal and this page cannot disagree.
1249
- <button class="bt" type="button" data-ir="gente" style="margin-left:8px">Open the roster</button></p></div>
1250
- <div><span class="sub">one goal — optional</span><div class="campos" id="meta" style="margin-top:9px"></div></div>
1347
+ <button class="bt" type="button" data-ir="gente" style="margin-left:8px">${_('Open the roster')}</button></p></div>
1348
+ <div><span class="sub">${_('one goal — optional')}</span><div class="campos" id="meta" style="margin-top:9px"></div></div>
1251
1349
  <div style="display:flex;gap:10px;align-items:center">
1252
- <button class="bt ppal" id="guardaPuesto">Save my seat</button>
1350
+ <button class="bt ppal" id="guardaPuesto">${_('Save my seat')}</button>
1253
1351
  <span class="cargando" id="puestoEstado" style="display:none">writing</span></div>`;
1254
1352
 
1255
1353
  // Domain first, then only the roles relevant inside it. Both are copied into
@@ -1307,28 +1405,28 @@ async function puesto(): Promise<void> {
1307
1405
  // goal
1308
1406
  const o: Partial<Objetivo> = (mia && mia.objetivo) || {};
1309
1407
  q('#meta').innerHTML = `
1310
- <div class="campo"><label>The goal, in one line</label>
1311
- <input type="text" id="g_title" value="${esc(o.title)}" placeholder="Concrete enough to argue with — empty skips it">
1408
+ <div class="campo"><label>${_('The goal, in one line')}</label>
1409
+ <input type="text" id="g_title" value="${esc(o.title)}" placeholder="${_('Concrete enough to argue with — empty skips it')}">
1312
1410
  </div>
1313
1411
  <div class="dos">
1314
- <div class="campo"><label>How it is measured</label>
1412
+ <div class="campo"><label>${_('How it is measured')}</label>
1315
1413
  <input type="text" id="g_signal" value="${esc(o.signal)}"></div>
1316
- <div class="campo"><label>The command that returns it, if a command can</label>
1414
+ <div class="campo"><label>${_('The command that returns it, if a command can')}</label>
1317
1415
  <input type="text" class="mono" id="g_command" value="${esc(o.command)}"
1318
- placeholder="empty for a qualitative goal"></div>
1416
+ placeholder="${_('empty for a qualitative goal')}"></div>
1319
1417
  </div>
1320
- <div class="campo"><label>…or who judges it, and how often</label>
1418
+ <div class="campo"><label>${_('…or who judges it, and how often')}</label>
1321
1419
  <input type="text" id="g_manual" value="${esc(o.manual)}"
1322
- placeholder="in prose: the architect reads the AGENTS.md files on Fridays">
1420
+ placeholder="${_('in prose: the architect reads the AGENTS.md files on Fridays')}">
1323
1421
  <p class="pista">A goal judged by a person is a real goal — plenty of quality
1324
1422
  is prose, not a number. Used when the command is empty.</p></div>
1325
1423
  <div class="dos">
1326
- <div class="campo"><label>What it returns today</label>
1424
+ <div class="campo"><label>${_('What it returns today')}</label>
1327
1425
  <input type="text" class="mono" id="g_baseline" value="${esc(o.baseline)}"></div>
1328
- <div class="campo"><label>Where it has to get to</label>
1426
+ <div class="campo"><label>${_('Where it has to get to')}</label>
1329
1427
  <input type="text" class="mono" id="g_target" value="${esc(o.target)}"></div>
1330
1428
  </div>
1331
- <div class="campo" style="max-width:240px"><label>By when</label>
1429
+ <div class="campo" style="max-width:240px"><label>${_('By when')}</label>
1332
1430
  <input type="text" id="g_by" value="${esc(o.by ?? 'this quarter')}"></div>`;
1333
1431
 
1334
1432
  const val = (id: string): string => q<HTMLInputElement>(id).value.trim();
@@ -1390,23 +1488,23 @@ VISTAS.barrios = () => {
1390
1488
 
1391
1489
  const render = (): void => {
1392
1490
  const idsValidos = new Set([...unidades.map((u) => u.id), 'none']);
1393
- q('#lienzo').innerHTML = `<div><span class="sub">districts & houses</span>
1394
- <h1 style="margin-top:6px">The modelling no tool can do for you</h1>
1395
- <p class="prosa" style="margin-top:8px">A house is <b>not a repo</b> — it is a
1491
+ q('#lienzo').innerHTML = `<div><span class="sub">${_('districts & houses')}</span>
1492
+ <h1 style="margin-top:6px">${_('The modelling no tool can do for you')}</h1>
1493
+ <p class="prosa" style="margin-top:8px">${_(`A house is <b>not a repo</b> — it is a
1396
1494
  parcel, a slice of one serving a single business unit. Split the interesting
1397
1495
  repos, give every house its district, and the map can say
1398
- “this change touches banking” instead of “this change touches src/lib”.</p></div>
1496
+ “this change touches banking” instead of “this change touches src/lib”.`)}</p></div>
1399
1497
 
1400
1498
  <div><span class="sub">districts</span>
1401
1499
  <div class="tabla" id="unidades" style="margin-top:9px"></div>
1402
1500
  <div style="display:flex;gap:9px;margin-top:9px">
1403
- <input type="text" id="nuevaU" placeholder="another district…" style="flex:1">
1501
+ <input type="text" id="nuevaU" placeholder="${_('another district…')}" style="flex:1">
1404
1502
  <button class="bt" id="masU">Add</button></div></div>
1405
1503
 
1406
1504
  <div><span class="sub">houses</span><div id="parcelas" style="margin-top:9px"></div></div>
1407
1505
 
1408
1506
  <div style="display:flex;gap:10px;align-items:center">
1409
- <button class="bt ppal" id="guardaBarrios">Save districts & houses</button>
1507
+ <button class="bt ppal" id="guardaBarrios">${_('Save districts & houses')}</button>
1410
1508
  <span style="font-size:12px;color:var(--tinta3);font-style:italic">
1411
1509
  writes units.yml and parcels.yml — reseed the map to see it drawn</span></div>`;
1412
1510
 
@@ -1562,7 +1660,7 @@ VISTAS.red = () => {
1562
1660
  const locales = E.ciudades.filter((c) => !c.actual && c.id && !conectadas.has(c.id));
1563
1661
  const invitation = JSON.stringify(E.invitation);
1564
1662
  q('#lienzo').innerHTML = `<div><span class="sub">roads</span>
1565
- <h1 style="margin-top:6px">Cities this one may reach</h1>
1663
+ <h1 style="margin-top:6px">${_('Cities this one may reach')}</h1>
1566
1664
  <p class="prosa" style="margin-top:8px">A road joins city seats. It may stay on
1567
1665
  this machine or continue over the remote bus; the city sees one explicit
1568
1666
  connection either way.</p></div>
@@ -1574,9 +1672,9 @@ VISTAS.red = () => {
1574
1672
  <span class="rp">${r.local ? 'local road' : 'remote road'}</span>
1575
1673
  <button class="bt mini" data-road-close="${esc(r.id)}">close</button></div>`,
1576
1674
  )
1577
- .join('') || '<p class="prosa">No roads yet. This city is isolated on purpose.</p>'
1675
+ .join('') || `<p class="prosa">${_('No roads yet. This city is isolated on purpose.')}</p>`
1578
1676
  }</div>
1579
- <div><span class="sub">other cities on this machine</span>
1677
+ <div><span class="sub">${_('other cities on this machine')}</span>
1580
1678
  <div class="gente" style="margin-top:9px">${
1581
1679
  locales
1582
1680
  .map(
@@ -1584,9 +1682,9 @@ VISTAS.red = () => {
1584
1682
  <span class="ag">${esc(c.slug ?? c.ruta)}</span>
1585
1683
  <button class="bt mini ppal" data-road-open="${esc(c.id)}">open road</button></div>`,
1586
1684
  )
1587
- .join('') || '<p class="prosa">No unconnected local cities.</p>'
1685
+ .join('') || `<p class="prosa">${_('No unconnected local cities.')}</p>`
1588
1686
  }</div></div>
1589
- <div class="orden"><span class="et2">remote invitation · public, no token</span>
1687
+ <div class="orden"><span class="et2">${_('remote invitation · public, no token')}</span>
1590
1688
  <code>${esc(invitation)}</code>
1591
1689
  <button class="bt mini" data-copia="${esc(invitation)}">copy</button>
1592
1690
  </div>`;
@@ -1615,16 +1713,305 @@ VISTAS.red = () => {
1615
1713
  });
1616
1714
  };
1617
1715
 
1716
+ // ── owner reception ─────────────────────────────────────────────────────────
1717
+ VISTAS.recepcion = () => {
1718
+ q('#lienzo').innerHTML = `<div><span class="sub">${_('reception')}</span>
1719
+ <h1 style="margin-top:6px">${_('Messages wait for you, not your agents')}</h1>
1720
+ <p class="prosa" style="margin-top:8px">${_(
1721
+ 'Remote text stops here as inert text. Read it, reject it with a reason, or choose the cities that should receive it. Until then no model can read it.',
1722
+ )}</p></div>
1723
+ <div class="recModo" id="recModo">
1724
+ <div><span class="et2">${_('routing mode')}</span><b>${_('Manual review')}</b>
1725
+ <p>${_('Every message needs a person before it reaches a city.')}</p></div>
1726
+ <button class="bt mini" type="button" data-rec-open-config>${_('Configure Auto')}</button>
1727
+ </div>
1728
+ <div id="recConfig" hidden></div>
1729
+ <div id="recLista"><p class="prosa">${_('Reading your reception…')}</p></div>`;
1730
+ void cargaRecepcion();
1731
+ };
1732
+
1733
+ async function cargaRecepcion(): Promise<void> {
1734
+ const hueco = q<HTMLElement>('#recLista');
1735
+ let estado: ReceptionState;
1736
+ try {
1737
+ estado = await api<ReceptionState>('/api/reception');
1738
+ } catch {
1739
+ hueco.innerHTML = `<div class="recError">${_('Could not read your reception')}</div>`;
1740
+ return;
1741
+ }
1742
+ if (estado.error) {
1743
+ hueco.innerHTML = `<div class="recError">${esc(estado.error)}</div>`;
1744
+ return;
1745
+ }
1746
+ E.reception = {
1747
+ pending: estado.summary.pending,
1748
+ pendingBytes: estado.summary.pendingBytes,
1749
+ routingMode: estado.settings.routingMode,
1750
+ reviewPolicy: estado.settings.reviewPolicy,
1751
+ routerProfile: estado.settings.routerProfile,
1752
+ autoAvailable: estado.settings.autoAvailable,
1753
+ };
1754
+ const reglas = new Map(estado.settings.autoRules.map((rule) => [rule.cityId, rule.keywords]));
1755
+ const modo = q<HTMLElement>('#recModo');
1756
+ modo.innerHTML = `<div><span class="et2">${_('routing mode')}</span>
1757
+ <b>${estado.settings.routingMode === 'auto' ? _('Automatic routing') : _('Manual review')}</b>
1758
+ <p>${
1759
+ estado.settings.routingMode === 'auto'
1760
+ ? _('Only one clear, low-risk rule match can leave the human queue automatically.')
1761
+ : _('Every message needs a person before it reaches a city.')
1762
+ }</p></div>
1763
+ <button class="bt mini" type="button" data-rec-open-config>${_('Configure Auto')}</button>`;
1764
+ const configuracion = q<HTMLElement>('#recConfig');
1765
+ configuracion.innerHTML = `<form class="recAutoConfig" data-rec-config>
1766
+ <fieldset><legend>${_('Routing policy')}</legend>
1767
+ <label><input type="radio" name="routing_mode" value="manual"
1768
+ ${estado.settings.routingMode === 'manual' ? 'checked' : ''}> ${_('Keep human review')}</label>
1769
+ <label><input type="radio" name="routing_mode" value="auto"
1770
+ ${estado.settings.routingMode === 'auto' ? 'checked' : ''}> ${_('Use the local rule router')}</label>
1771
+ </fieldset>
1772
+ <div class="recReglas"><p>${_('Write comma-separated words or phrases for each destination. Empty cities are never selected.')}</p>
1773
+ ${estado.cities
1774
+ .map(
1775
+ (city) => `<label><span><b>${esc(city.name)}</b><small>${esc(city.address)}</small></span>
1776
+ <input type="text" name="rule-${esc(city.id)}" maxlength="1200"
1777
+ data-rule-city="${esc(city.id)}" value="${esc((reglas.get(city.id) ?? []).join(', '))}"
1778
+ placeholder="${_('e.g. contract, privacy, legal review')}"></label>`,
1779
+ )
1780
+ .join('')}</div>
1781
+ <p class="recAutoAviso">${_('Suspicious, unmatched, or ambiguous text always waits for you. Auto never executes, answers, or opens links.')}</p>
1782
+ <button class="bt ppal" type="submit">${_('Save routing policy')}</button>
1783
+ </form>`;
1784
+ q<HTMLButtonElement>('[data-rec-open-config]', modo).onclick = () => {
1785
+ configuracion.hidden = !configuracion.hidden;
1786
+ };
1787
+ rail();
1788
+ const conexiones = estado.connections.length
1789
+ ? `<section class="recConexiones"><div class="recSeccion"><b>${_('New encrypted message')}</b>
1790
+ <span>${estado.outbox.queued ? plural(estado.outbox.queued, 'message queued', 'messages queued') : _('Messages leave from this computer with end-to-end encryption.')}</span></div>
1791
+ <form class="recCompose" data-rec-send>
1792
+ <label class="recComposePersona" for="rec-send-connection"><span>${_('Write to')}</span>
1793
+ <select id="rec-send-connection" name="connection" required>
1794
+ ${estado.connections
1795
+ .map(
1796
+ (conexion) => `<option value="${esc(conexion.id)}">${esc(conexion.name)}</option>`,
1797
+ )
1798
+ .join('')}
1799
+ </select>
1800
+ </label>
1801
+ <label class="recComposeTexto" for="rec-send-text"><span>${_('Message')}</span>
1802
+ <textarea id="rec-send-text" name="text" rows="3" maxlength="11500" required
1803
+ placeholder="${_('Your message will stop in their private reception.')}"></textarea>
1804
+ </label>
1805
+ <button class="bt ppal" type="submit">${_('Send securely')}</button>
1806
+ </form></section>`
1807
+ : '';
1808
+ const vacia = !estado.messages.length
1809
+ ? `<div class="recVacia"><b>${_('Reception clear')}</b>
1810
+ <p>${_('No remote message is waiting for a decision.')}</p></div>`
1811
+ : '';
1812
+ const ciudades = estado.cities
1813
+ .map(
1814
+ (ciudad) => `<label class="recDestino"><input type="checkbox" name="destination"
1815
+ value="${esc(ciudad.id)}"><span><b>${esc(ciudad.name)}</b>
1816
+ <small>${esc(ciudad.address)}</small></span></label>`,
1817
+ )
1818
+ .join('');
1819
+ const pendientes = estado.messages.length
1820
+ ? `<div class="recResumen"><b>${estado.summary.pending}</b>
1821
+ <span>${plural(estado.summary.pending, 'message waiting', 'messages waiting')}</span>
1822
+ <small>${formateaBytes(estado.summary.pendingBytes)} · ${_('local only')}</small></div>
1823
+ <div class="recMensajes">${estado.messages
1824
+ .map(
1825
+ (mensaje) => `<article class="recMensaje">
1826
+ <header><div><span class="recOrigen">${esc(mensaje.fromName)}</span>
1827
+ <span>${esc(fechaRecepcion(mensaje.receivedAt))}</span></div>
1828
+ <span class="recSeguro">${mensaje.kind === 'rejection' ? _('Reply to a rejected message') : _('No agent has read this')}</span></header>
1829
+ <pre class="recTexto">${esc(mensaje.text)}</pre>
1830
+ ${
1831
+ mensaje.kind === 'rejection'
1832
+ ? `<div class="recAvisoAccion">
1833
+ <button class="bt mini" type="button" data-rec-dismiss="${esc(mensaje.id)}">${_('Dismiss')}</button>
1834
+ </div>`
1835
+ : `<div class="recAcciones">
1836
+ <form data-rec-route="${esc(mensaje.id)}">
1837
+ <fieldset><legend>${_('Send to')}</legend><div class="recDestinos">${ciudades}</div></fieldset>
1838
+ <button class="bt ppal" type="submit" disabled>${_('Route to selected cities')}</button>
1839
+ </form>
1840
+ <details><summary>${_('Reject and reply with a reason')}</summary>
1841
+ <form data-rec-reject="${esc(mensaje.id)}">
1842
+ <label for="rec-reason-${esc(mensaje.id)}">${_('Reason sent back securely')}</label>
1843
+ <input id="rec-reason-${esc(mensaje.id)}" type="text" name="reason"
1844
+ maxlength="500" required
1845
+ placeholder="${_('Reason for rejecting this message')}">
1846
+ <button class="bt malo" type="submit">${_('Reject and send reason')}</button>
1847
+ </form>
1848
+ </details>
1849
+ </div>`
1850
+ }
1851
+ </article>`,
1852
+ )
1853
+ .join('')}</div>`
1854
+ : '';
1855
+ hueco.innerHTML = `${vacia}${pendientes}${conexiones}`;
1856
+ enlazaRecepcion();
1857
+ }
1858
+
1859
+ function enlazaRecepcion(): void {
1860
+ q<HTMLFormElement>('[data-rec-config]').onsubmit = async (evento) => {
1861
+ evento.preventDefault();
1862
+ const formulario = evento.currentTarget as HTMLFormElement;
1863
+ const boton = q<HTMLButtonElement>('button[type=submit]', formulario);
1864
+ const routingMode = q<HTMLInputElement>('input[name=routing_mode]:checked', formulario).value;
1865
+ const rules = todos<HTMLInputElement>('[data-rule-city]', formulario)
1866
+ .map((campo) => ({
1867
+ city_id: campo.dataset.ruleCity,
1868
+ keywords: campo.value
1869
+ .split(',')
1870
+ .map((value) => value.trim())
1871
+ .filter(Boolean),
1872
+ }))
1873
+ .filter((rule) => rule.keywords.length);
1874
+ boton.disabled = true;
1875
+ const respuesta = await api<{ ok?: boolean; error?: string }>('/api/reception', {
1876
+ method: 'POST',
1877
+ body: JSON.stringify({ action: 'configure', routing_mode: routingMode, rules }),
1878
+ });
1879
+ if (!respuesta.ok) {
1880
+ boton.disabled = false;
1881
+ toast(respuesta.error ?? _('Could not save the routing policy'), true);
1882
+ return;
1883
+ }
1884
+ toast(_('Routing policy saved.'));
1885
+ await cargaRecepcion();
1886
+ };
1887
+ todos<HTMLFormElement>('[data-rec-send]').forEach((formulario) => {
1888
+ formulario.onsubmit = async (evento) => {
1889
+ evento.preventDefault();
1890
+ const boton = q<HTMLButtonElement>('button[type=submit]', formulario);
1891
+ const campo = q<HTMLTextAreaElement>('textarea[name=text]', formulario);
1892
+ const connectionId = q<HTMLSelectElement>('select[name=connection]', formulario).value;
1893
+ if (!connectionId || !campo.value.trim()) return;
1894
+ boton.disabled = true;
1895
+ const respuesta = await api<{ ok?: boolean; error?: string }>('/api/reception', {
1896
+ method: 'POST',
1897
+ body: JSON.stringify({
1898
+ action: 'send',
1899
+ connection_id: connectionId,
1900
+ text: campo.value,
1901
+ }),
1902
+ });
1903
+ if (!respuesta.ok) {
1904
+ boton.disabled = false;
1905
+ toast(respuesta.error ?? _('Could not queue the message'), true);
1906
+ return;
1907
+ }
1908
+ campo.value = '';
1909
+ toast(_('Message queued on this computer.'));
1910
+ await cargaRecepcion();
1911
+ };
1912
+ });
1913
+ todos<HTMLButtonElement>('[data-rec-dismiss]').forEach((boton) => {
1914
+ boton.onclick = async () => {
1915
+ boton.disabled = true;
1916
+ const respuesta = await api<{ ok?: boolean; error?: string }>('/api/reception', {
1917
+ method: 'POST',
1918
+ body: JSON.stringify({
1919
+ action: 'reject',
1920
+ message_id: boton.dataset.recDismiss,
1921
+ reason: 'Dismissed response',
1922
+ }),
1923
+ });
1924
+ if (!respuesta.ok) {
1925
+ boton.disabled = false;
1926
+ toast(respuesta.error ?? _('Could not dismiss the response'), true);
1927
+ return;
1928
+ }
1929
+ await cargaRecepcion();
1930
+ };
1931
+ });
1932
+ todos<HTMLFormElement>('[data-rec-route]').forEach((formulario) => {
1933
+ const boton = q<HTMLButtonElement>('button[type=submit]', formulario);
1934
+ const actualiza = () => {
1935
+ boton.disabled = !formulario.querySelector<HTMLInputElement>('input:checked');
1936
+ };
1937
+ formulario.onchange = actualiza;
1938
+ formulario.onsubmit = async (evento) => {
1939
+ evento.preventDefault();
1940
+ const destinations = todos<HTMLInputElement>(
1941
+ 'input[name=destination]:checked',
1942
+ formulario,
1943
+ ).map((campo) => campo.value);
1944
+ if (!destinations.length) return;
1945
+ boton.disabled = true;
1946
+ const respuesta = await api<{ ok?: boolean; error?: string }>('/api/reception', {
1947
+ method: 'POST',
1948
+ body: JSON.stringify({
1949
+ action: 'route',
1950
+ message_id: formulario.dataset.recRoute,
1951
+ destinations,
1952
+ }),
1953
+ });
1954
+ if (!respuesta.ok) {
1955
+ boton.disabled = false;
1956
+ toast(respuesta.error ?? _('Could not route the message'), true);
1957
+ return;
1958
+ }
1959
+ toast(_('Message routed. Only the selected cities can now read it.'));
1960
+ await cargaRecepcion();
1961
+ };
1962
+ });
1963
+ todos<HTMLFormElement>('[data-rec-reject]').forEach((formulario) => {
1964
+ formulario.onsubmit = async (evento) => {
1965
+ evento.preventDefault();
1966
+ const boton = q<HTMLButtonElement>('button[type=submit]', formulario);
1967
+ const reason = q<HTMLInputElement>('input[name=reason]', formulario).value.trim();
1968
+ if (!reason) return;
1969
+ boton.disabled = true;
1970
+ const respuesta = await api<{ ok?: boolean; error?: string }>('/api/reception', {
1971
+ method: 'POST',
1972
+ body: JSON.stringify({
1973
+ action: 'reject',
1974
+ message_id: formulario.dataset.recReject,
1975
+ reason,
1976
+ }),
1977
+ });
1978
+ if (!respuesta.ok) {
1979
+ boton.disabled = false;
1980
+ toast(respuesta.error ?? _('Could not reject the message'), true);
1981
+ return;
1982
+ }
1983
+ toast(_('Message rejected. Your reason is queued for encrypted delivery.'));
1984
+ await cargaRecepcion();
1985
+ };
1986
+ });
1987
+ }
1988
+
1989
+ function fechaRecepcion(value: string): string {
1990
+ const date = new Date(value);
1991
+ return Number.isNaN(date.getTime())
1992
+ ? value
1993
+ : date.toLocaleString(idioma() === 'es' ? 'es-ES' : 'en-GB', {
1994
+ dateStyle: 'medium',
1995
+ timeStyle: 'short',
1996
+ });
1997
+ }
1998
+
1999
+ function formateaBytes(bytes: number): string {
2000
+ if (bytes < 1024) return `${bytes} B`;
2001
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2002
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2003
+ }
2004
+
1618
2005
  // ── committee acts ───────────────────────────────────────────────────────────
1619
2006
  VISTAS.committee = () => {
1620
2007
  q('#lienzo').innerHTML = `<div><span class="sub">committee</span>
1621
- <h1 style="margin-top:6px">Decisions with a visible chain of custody</h1>
2008
+ <h1 style="margin-top:6px">${_('Decisions with a visible chain of custody')}</h1>
1622
2009
  <p class="prosa" style="margin-top:8px">The seat selects relevant repo agents,
1623
2010
  gathers isolated positions, controls the floor, decides and assigns an
1624
2011
  independent verification. This view is read-only; the vendor-neutral CLI
1625
2012
  drives the protocol.</p></div>
1626
2013
  <div class="orden"><span class="et2">start</span>
1627
- <code>agents-city committee schema open</code>
2014
+ <code>${_('agents-city committee schema open')}</code>
1628
2015
  <button class="bt mini" data-copia="agents-city committee schema open">copy</button>
1629
2016
  </div>
1630
2017
  <div class="gente">${
@@ -1642,7 +2029,7 @@ VISTAS.committee = () => {
1642
2029
  </div>`,
1643
2030
  )
1644
2031
  .join('') ||
1645
- '<p class="prosa">No committee acts yet. Open one only when the seat needs specialised evidence.</p>'
2032
+ `<p class="prosa">${_('No committee acts yet. Open one only when the seat needs specialised evidence.')}</p>`
1646
2033
  }</div>`;
1647
2034
  enlaza();
1648
2035
  };
@@ -1675,12 +2062,17 @@ VISTAS.gente = () => {
1675
2062
  const g = a.growth;
1676
2063
  const crecido = Math.min(1, Math.log2(g.floors + 1) / 8);
1677
2064
  const skills = E.skills[a.name]?.skills ?? [];
1678
- const esClaude = a.runtime === 'claude';
2065
+ const motorDelAgente = motorDe(a.runtime);
1679
2066
  // "custom…" keeps the curated lineup from ever holding the card hostage:
1680
- // the alias is resolved by the CLI at launch, not by this list.
2067
+ // the alias is resolved by the CLI at launch, not by this list. Where the
2068
+ // names are not ours to list at all, the whole field is that free text.
1681
2069
  const opcionesModelo =
1682
- opciones([''].concat(MODELOS), a.model) + '<option value="__otro__">custom…</option>';
1683
- const opcionesEsfuerzo = opciones([''].concat(ESFUERZOS), a.effort);
2070
+ opciones([''].concat(motorDelAgente.modelos), a.model) +
2071
+ `<option value="__otro__">${_('custom…')}</option>`;
2072
+ const opcionesEsfuerzo = opciones(
2073
+ [''].concat(ESFUERZOS),
2074
+ motorDelAgente.esfuerzo ? a.effort : '',
2075
+ );
1684
2076
  const opcionesRuntime = opciones(RUNTIMES, a.runtime);
1685
2077
  const montajes = a.mounts ?? [];
1686
2078
  return `
@@ -1692,22 +2084,25 @@ VISTAS.gente = () => {
1692
2084
  <span class="rpgHogar">${a.legacy ? 'repo' : `workspace · ${montajes.length} ${montajes.length === 1 ? 'mount' : 'mounts'}`}</span></span></div>
1693
2085
  <span class="rpgBotones">
1694
2086
  <button class="rpgMini rpgDado" type="button" data-agente="${esc(a.slug)}"
1695
- title="Reroll this agent's face — deterministic, persisted on the card">🎲</button>
2087
+ title="${_("Reroll this agent's face — deterministic, persisted on the card")}">🎲</button>
1696
2088
  <button class="rpgMini rpgIns" type="button" data-agente="${esc(a.slug)}"
1697
- data-file="CLAUDE.md" title="Instructions the Claude runtime reads">CLAUDE.md</button>
2089
+ data-file="CLAUDE.md" title="${_('Instructions the Claude runtime reads')}">CLAUDE.md</button>
1698
2090
  <button class="rpgMini rpgIns" type="button" data-agente="${esc(a.slug)}"
1699
- data-file="AGENTS.md" title="Instructions Codex, OpenCode and Kimi read">AGENTS.md</button>
2091
+ data-file="AGENTS.md" title="${_('Instructions Codex, OpenCode and Kimi read')}">AGENTS.md</button>
1700
2092
  </span>
1701
2093
  </div>
1702
2094
  <div class="rpgFila"><label>${_('engine')}</label>
1703
2095
  ${barra(motor.ancho, motor.defecto ? 'defecto' : '')}
1704
- <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="model"
1705
- ${esClaude ? '' : 'disabled title="This window runs its own CLI; its model lives in its own flags"'}>
2096
+ <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="model">
1706
2097
  ${opcionesModelo}</select></div>
1707
2098
  <div class="rpgFila"><label>${_('effort')}</label>
1708
2099
  ${barra(esfuerzo / 5, esfuerzo ? '' : 'defecto')}
1709
2100
  <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="effort"
1710
- ${esClaude ? '' : 'disabled title="This window runs its own CLI; its effort lives in its own flags"'}>
2101
+ ${
2102
+ motorDelAgente.esfuerzo
2103
+ ? ''
2104
+ : `disabled title="${_('This CLI has no effort setting.')}"`
2105
+ }>
1711
2106
  ${opcionesEsfuerzo}</select></div>
1712
2107
  <div class="rpgFila"><label>${_('provider')}</label>
1713
2108
  <span class="rpgDato"><span class="cliDot ${
@@ -1722,7 +2117,7 @@ VISTAS.gente = () => {
1722
2117
  _(a.cli.connected ? 'connected' : a.cli.installed ? 'idle' : 'missing'),
1723
2118
  )} · ${esc(a.cli.binary)}
1724
2119
  <button class="rpgMini rpgTest" type="button" data-agente="${esc(a.slug)}"
1725
- title="Run the engine for real: --version, and the login state on Claude">test</button></span>
2120
+ title="${_('Run the engine for real: --version, and the login state on Claude')}">test</button></span>
1726
2121
  <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="runtime">
1727
2122
  ${opcionesRuntime}</select></div>
1728
2123
  <div class="rpgSkills"><label>${_('works on')} · ${montajes.length}</label>
@@ -1744,7 +2139,7 @@ VISTAS.gente = () => {
1744
2139
  }
1745
2140
  ${
1746
2141
  a.legacy
1747
- ? '<span class="rpgDato">a legacy repo agent works on its own repo</span>'
2142
+ ? `<span class="rpgDato">${_('a legacy repo agent works on its own repo')}</span>`
1748
2143
  : `<button class="rpgMini rpgMonta" type="button" data-agente="${esc(a.slug)}"
1749
2144
  title="A repo, a worktree or a folder of documents — this agent works on all of them">+ folder</button>`
1750
2145
  }</div>
@@ -1760,7 +2155,7 @@ VISTAS.gente = () => {
1760
2155
  `<code class="mono" title="${esc(s.description)}">${esc(s.name)}${
1761
2156
  s.removable && s.dir
1762
2157
  ? `<button class="rpgQuitar" type="button" data-agente="${esc(a.slug)}"
1763
- data-skill="${esc(s.dir)}" title="Remove this skill from the agent's home">×</button>`
2158
+ data-skill="${esc(s.dir)}" title="${_("Remove this skill from the agent's home")}">×</button>`
1764
2159
  : ''
1765
2160
  }</code>`,
1766
2161
  )
@@ -1768,21 +2163,21 @@ VISTAS.gente = () => {
1768
2163
  : '<span class="rpgDato">none discovered</span>'
1769
2164
  }
1770
2165
  <label class="rpgMini rpgSubir"
1771
- title="Install a skill zip into this agent's own home — the Claude runtime reads skills; other engines ignore them">
2166
+ title="${_("Install a skill zip into this agent's own home — the Claude runtime reads skills; other engines ignore them")}">
1772
2167
  + zip<input type="file" accept=".zip" data-agente="${esc(a.slug)}"></label></div>
1773
2168
  </div>`;
1774
2169
  };
1775
2170
 
1776
2171
  q('#lienzo').innerHTML = `<div><span class="sub">the houses of ${esc(E.city_name)}</span>
1777
2172
  <h1 style="margin-top:6px">${_('Who lives in {city}', { city: esc(E.city_name) })}</h1>
1778
- <p class="prosa" style="margin-top:8px"><b>A house is where an agent lives and works</b>,
2173
+ <p class="prosa" style="margin-top:8px">${_(`<b>A house is where an agent lives and works</b>,
1779
2174
  and many houses are a city — it is the same thing the map draws, growing with what that
1780
2175
  agent actually does. The card and the CLI call them <code class="mono">agents</code>;
1781
- here you see their houses.</p>
1782
- <p class="prosa">They belong to <b>this</b> city and only
1783
- to it: each one's workspace and its mounts live inside
1784
- <code class="mono">${esc(corto(E.datos))}/agents/</code>, so another city has its own
1785
- people even if you give them the same names. Every agent is whole here: the kind of work
2176
+ here you see their houses.`)}</p>
2177
+ <p class="prosa">${_(
2178
+ 'They belong to <b>this</b> city and only to it: each one’s workspace and its mounts live inside {donde}, so another city has its own people even if you give them the same names.',
2179
+ { donde: `<code class="mono">${esc(corto(E.datos))}/agents/</code>` },
2180
+ )} Every agent is whole here: the kind of work
1786
2181
  it does, its role, everything it works on — any number of repositories and document
1787
2182
  folders at once — the engine and effort that run it, and the skills in its own home.</p>
1788
2183
  <p class="prosa">Every number is real: growth from what the agent actually produced,
@@ -2009,8 +2404,23 @@ VISTAS.gente = () => {
2009
2404
  },
2010
2405
  });
2011
2406
  if (dicho === null || !elegidas.length) return;
2012
- for (const ruta of elegidas)
2013
- await montaje({ agent: boton.dataset.agente ?? '', add: ruta }, _('Mounted'));
2407
+ // One refresh and one toast for the whole dialog: mounting five folders
2408
+ // used to cost five serialised posts, five state fetches and five full
2409
+ // repaints of the Hall, for five independent writes.
2410
+ const agente = boton.dataset.agente ?? '';
2411
+ const fallos = await Promise.all(
2412
+ elegidas.map((ruta) =>
2413
+ api<{ ok?: boolean; error?: string }>('/api/montaje', {
2414
+ method: 'POST',
2415
+ body: JSON.stringify({ agent: agente, add: ruta }),
2416
+ })
2417
+ .then((r) => (r.ok ? '' : `${ruta}: ${r.error ?? 'could not mount'}`))
2418
+ .catch((e) => `${ruta}: ${String(e)}`),
2419
+ ),
2420
+ );
2421
+ const malos = fallos.filter(Boolean);
2422
+ toast(malos.length ? malos.join('; ') : _('Mounted'), malos.length > 0);
2423
+ void refresca();
2014
2424
  };
2015
2425
  });
2016
2426
  todos<HTMLButtonElement>('.rpgDesmonta').forEach((boton) => {
@@ -2024,8 +2434,6 @@ VISTAS.gente = () => {
2024
2434
  { aceptar: _('Unmount it') },
2025
2435
  );
2026
2436
  if (!vale) return;
2027
- // Unmounting removes a symlink and a card key. What it points at is not
2028
- // touched — say so, because "remove" reads as "delete my folder".
2029
2437
  void montaje(
2030
2438
  { agent: boton.dataset.agente ?? '', remove: etiqueta },
2031
2439
  `${etiqueta} unmounted — the folder itself is untouched`,
@@ -2314,6 +2722,15 @@ function interruptorDeIdioma(): void {
2314
2722
  };
2315
2723
  }
2316
2724
 
2725
+ // Nothing in a browser reports itself. These two are why a person can say "it
2726
+ // just did nothing" and be exactly right.
2727
+ window.addEventListener('error', (e) =>
2728
+ anota('uncaught error', { mensaje: e.message, fichero: e.filename, linea: e.lineno }),
2729
+ );
2730
+ window.addEventListener('unhandledrejection', (e) =>
2731
+ anota('unhandled rejection', String((e as PromiseRejectionEvent).reason)),
2732
+ );
2733
+
2317
2734
  arrastreDelRail();
2318
2735
  interruptorDeIdioma();
2319
2736
  tema();