agents-city 0.1.0 → 0.2.1

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.
@@ -11,6 +11,7 @@
11
11
  * ever serves this file.
12
12
  */
13
13
 
14
+ import { Bienvenida } from './bienvenida';
14
15
  import {
15
16
  ActivityEvent,
16
17
  MAP_ACTIVITY_PROTOCOL,
@@ -63,6 +64,8 @@ interface Ciudad {
63
64
  slug?: string;
64
65
  id?: string;
65
66
  actual: boolean;
67
+ /** How many agents live in it. An agent belongs to exactly one city. */
68
+ agentes?: number;
66
69
  }
67
70
  interface Road {
68
71
  id: string;
@@ -187,6 +190,16 @@ interface DomainInfo {
187
190
  name: string;
188
191
  summary: string;
189
192
  }
193
+ /** What a reset would do, as the server computes it — never guessed here. */
194
+ interface Efectos {
195
+ city: string;
196
+ backup: string;
197
+ agents: number;
198
+ roads: number;
199
+ deliberations: number;
200
+ keeps: string[];
201
+ loses: string[];
202
+ }
190
203
  interface RepoLocal {
191
204
  nombre: string;
192
205
  ruta: string;
@@ -263,14 +276,33 @@ const SECCIONES: Array<[string, string]> = [
263
276
  ['resumen', 'Overview'],
264
277
  ['mapa', 'The map'],
265
278
  ['puesto', 'My seat'],
266
- ['barrios', 'Districts & houses'],
279
+ ['barrios', 'Districts'],
267
280
  ['red', 'Roads'],
268
281
  ['committee', 'Committee'],
269
- ['gente', 'Agents & skills'],
282
+ ['gente', 'Houses'],
283
+ ['ciudades', 'Cities'],
270
284
  ];
271
285
 
286
+ /** True until the person navigates: the landing section is decided once, from
287
+ * the state, and never yanked out from under them afterwards. */
288
+ let primeraCarga = true;
289
+
272
290
  async function refresca(): Promise<void> {
273
291
  E = await api<Estado>('/api/estado');
292
+ if (primeraCarga) {
293
+ primeraCarga = false;
294
+ // A city with no seat or no agents has nothing to draw, and "Not drawn
295
+ // yet" is a dead end as a first impression. Start where the next step is.
296
+ const mia = E.tarjetas.find((t) => t.user === E.yo);
297
+ // Never land on a dead end. Three cases, in order of what the person needs:
298
+ // nothing set up -> the guide, which asks the questions
299
+ // set up, no map yet -> the overview, which says what is left and how
300
+ // set up, map running -> the map, which is the point of the product
301
+ // Landing on the map view without a map was how "Not drawn yet" became the
302
+ // first thing an owner saw every single morning.
303
+ if (!mia || (E.agents ?? []).length === 0) SECCION = 'bienvenida';
304
+ else if (!E.mapa) SECCION = 'resumen';
305
+ }
274
306
  pinta();
275
307
  pintaAvisoDeVersion();
276
308
  pintaDemoControles();
@@ -706,22 +738,36 @@ function icono(kind: string): string {
706
738
  }
707
739
 
708
740
  function rail(): void {
709
- q('#rail').innerHTML = SECCIONES.map(([id, et]) => {
710
- const n =
711
- id === 'gente'
712
- ? String(Object.keys(E.skills).length)
713
- : id === 'committee'
714
- ? String(E.deliberations.length)
715
- : id === 'red'
716
- ? String(E.roads.length)
717
- : id === 'barrios'
718
- ? String(E.parcelas.length)
719
- : '';
720
- return `<li class="${id === SECCION ? 'aqui' : ''}" data-s="${id}">
741
+ // Everything above the divider belongs to ONE city — its seat, its agents,
742
+ // its map. Saying so here is the difference between a menu and a place: an
743
+ // agent's workspace and mounts live inside this city's folder, and a flat
744
+ // list of "Cities" and "Agents" as siblings hid that completely.
745
+ const cuantos = (E.agents ?? []).length;
746
+ q('#railCiudad').innerHTML = `<span class="railEtiqueta">you are in</span>
747
+ <b>${esc(E.city_name)}</b>
748
+ <span class="railDe">${esc(E.domain)} · ${cuantos} house${cuantos === 1 ? '' : 's'}</span>`;
749
+ q('#rail').innerHTML = SECCIONES.filter(([id]) => id !== 'ciudades')
750
+ .map(([id, et]) => {
751
+ const n =
752
+ id === 'gente'
753
+ ? String(Object.keys(E.skills).length)
754
+ : id === 'committee'
755
+ ? String(E.deliberations.length)
756
+ : id === 'red'
757
+ ? String(E.roads.length)
758
+ : id === 'barrios'
759
+ ? String(E.parcelas.length)
760
+ : '';
761
+ return `<li class="${id === SECCION ? 'aqui' : ''}" data-s="${id}">
721
762
  <span>${et}</span>${n ? `<span class="n">${n}</span>` : ''}</li>`;
722
- }).join('');
723
- todos<HTMLElement>('#rail li').forEach((li) => {
763
+ })
764
+ .join('');
765
+ q('#railOtras').innerHTML =
766
+ `<li class="${SECCION === 'ciudades' ? 'aqui' : ''}" data-s="ciudades">
767
+ <span>All cities</span><span class="n">${E.ciudades.length}</span></li>`;
768
+ todos<HTMLElement>('#rail li,#railOtras li').forEach((li) => {
724
769
  li.onclick = () => {
770
+ document.body.classList.remove('enGuia');
725
771
  SECCION = li.dataset.s ?? 'mapa';
726
772
  pinta();
727
773
  };
@@ -768,17 +814,185 @@ function pinta(): void {
768
814
  const VISTAS: Record<string, () => void> = {};
769
815
 
770
816
  // ── overview ─────────────────────────────────────────────────────────────────
817
+ /** The guided first run. Built once and kept, so a half-finished answer
818
+ * survives a repaint. */
819
+ let guia: Bienvenida | null = null;
820
+
821
+ VISTAS.bienvenida = () => {
822
+ document.body.classList.add('enGuia');
823
+ guia ??= new Bienvenida({
824
+ api,
825
+ esc,
826
+ aviso: toast,
827
+ refresca,
828
+ vete: (seccion: string) => {
829
+ document.body.classList.remove('enGuia');
830
+ SECCION = seccion;
831
+ pinta();
832
+ },
833
+ ciudad: E.city_name,
834
+ yo: E.yo,
835
+ datos: corto(E.datos),
836
+ });
837
+ void guia.pinta(q<HTMLElement>('#lienzo'));
838
+ };
839
+
840
+ VISTAS.ciudades = () => {
841
+ const filas = E.ciudades
842
+ .map(
843
+ (c) => `
844
+ <div class="fila ${c.actual ? 'on' : ''}">
845
+ <span class="et">${esc(c.nombre)}${c.actual ? ' <b class="de">· open now</b>' : ''}</span>
846
+ <span class="de">${c.agentes ?? 0} house${(c.agentes ?? 0) === 1 ? '' : 's'} of its own</span>
847
+ <span class="de mono">${esc(corto(c.ruta))}</span>
848
+ <span style="margin-left:auto;display:flex;gap:6px">
849
+ ${
850
+ c.actual
851
+ ? ''
852
+ : `<a class="bt mini" href="?city=${encodeURIComponent(c.ruta)}">open</a>
853
+ <button class="bt mini archiva" data-ciudad="${esc(c.ruta)}"
854
+ data-nombre="${esc(c.nombre)}">archive</button>`
855
+ }
856
+ </span>
857
+ </div>`,
858
+ )
859
+ .join('');
860
+ q('#lienzo').innerHTML = `<div><span class="sub">cities</span>
861
+ <h1 style="margin-top:6px">Your cities</h1>
862
+ <p class="prosa" style="margin-top:8px">One person, several autonomous cities: each has
863
+ its own identity, domain, chair, <b>its own houses</b> and its own roads. A house is not
864
+ shared — it stands inside the city that owns it, with its workspace and mounts under that
865
+ city's folder — so two cities can each have a <code class="mono">docs</code> house and
866
+ they are two different workers. They share nothing unless you build a road between
867
+ them.</p></div>
868
+ <div class="lista"><div class="filas">${filas}</div></div>
869
+ <div class="campos" style="max-width:420px">
870
+ <div class="campo"><label>Start another city</label>
871
+ <input type="text" id="nuevaCiudad" placeholder="client-a, research, the book…"></div>
872
+ <button class="bt ppal" id="creaCiudad">Create it</button>
873
+ </div>
874
+ <div class="peligro">
875
+ <h3>Start this city over</h3>
876
+ <p class="prosa">Takes <b>${esc(E.city_name)}</b> back to its first day: no seat, no
877
+ agents, no committee history, no map. Your repositories and document folders are
878
+ <b>never touched</b> — only the city that points at them.</p>
879
+ <button class="bt malo" id="reiniciaCiudad">Start over…</button>
880
+ </div>
881
+ <p class="pista">Archiving <b>moves</b> a city into
882
+ <code class="mono">.backups/</code> — nothing is deleted, and you can bring it back with
883
+ <code class="mono">mv</code>. The city you are standing in, and the last one you own,
884
+ cannot be archived.</p>`;
885
+ enlaza();
886
+ const campo = q<HTMLInputElement>('#nuevaCiudad');
887
+ q<HTMLButtonElement>('#creaCiudad').onclick = async () => {
888
+ const nombre = campo.value.trim();
889
+ if (!nombre) {
890
+ toast('Give it a name', true);
891
+ return;
892
+ }
893
+ const r = await api<Respuesta>('/api/ciudades', {
894
+ method: 'POST',
895
+ body: JSON.stringify({ name: nombre }),
896
+ });
897
+ if (!r.ok) {
898
+ toast(r.error ?? 'Could not create it', true);
899
+ return;
900
+ }
901
+ toast(`${nombre} created`);
902
+ await refresca();
903
+ };
904
+ const reiniciar = document.getElementById('reiniciaCiudad');
905
+ if (reiniciar)
906
+ reiniciar.onclick = async () => {
907
+ // Two round-trips on purpose: the first asks the server what this would
908
+ // actually do, and the person reads it before typing anything. "Are you
909
+ // sure?" is not information; a list of what disappears is.
910
+ const previa = await api<{ preview?: Efectos; error?: string }>('/api/ciudad-reinicia', {
911
+ method: 'POST',
912
+ body: '{}',
913
+ });
914
+ if (!previa.preview) {
915
+ toast(previa.error ?? 'Could not read what a reset would do', true);
916
+ return;
917
+ }
918
+ const p = previa.preview;
919
+ const texto =
920
+ `Start over ${p.city}?\n\nYou lose:\n` +
921
+ p.loses.map((l) => ` · ${l}`).join('\n') +
922
+ `\n\nRight now that is ${p.agents} agent(s), ${p.roads} road(s) and ` +
923
+ `${p.deliberations} committee act(s).\n\nYou keep:\n` +
924
+ p.keeps.map((l) => ` · ${l}`).join('\n') +
925
+ `\n\nType the city's name to confirm:`;
926
+ const escrito = window.prompt(texto, '');
927
+ if (escrito === null) return;
928
+ if (escrito.trim() !== p.city) {
929
+ toast('That is not the name — nothing was changed', true);
930
+ return;
931
+ }
932
+ const r = await api<{ ok?: boolean; backup?: string; error?: string }>(
933
+ '/api/ciudad-reinicia',
934
+ { method: 'POST', body: JSON.stringify({ confirm: p.city }) },
935
+ );
936
+ if (!r.ok) {
937
+ toast(r.error ?? 'Could not reset it', true);
938
+ return;
939
+ }
940
+ toast(`${p.city} starts over — the old one is at ${r.backup ?? 'its backup'}`);
941
+ primeraCarga = true; // land wherever a fresh city should land: the guide
942
+ await refresca();
943
+ };
944
+ todos<HTMLButtonElement>('.archiva').forEach((boton) => {
945
+ boton.onclick = async () => {
946
+ const nombre = boton.dataset.nombre ?? '';
947
+ if (!window.confirm(`Archive ${nombre}? It moves into .backups — nothing is deleted.`))
948
+ return;
949
+ const r = await api<{ ok?: boolean; backup?: string; error?: string }>(
950
+ '/api/ciudad-archiva',
951
+ { method: 'POST', body: JSON.stringify({ city: boton.dataset.ciudad ?? '' }) },
952
+ );
953
+ if (!r.ok) {
954
+ toast(r.error ?? 'Could not archive it', true);
955
+ return;
956
+ }
957
+ toast(`${nombre} archived — it is in ${r.backup ?? '.backups'}`);
958
+ await refresca();
959
+ };
960
+ });
961
+ };
962
+
771
963
  VISTAS.resumen = () => {
772
964
  const mia = E.tarjetas.find((t) => t.user === E.yo);
773
965
  const sinUnidad = E.parcelas.filter((p) => p.unidad === 'none' || p.unidad === 'mine').length;
966
+ const agentes = E.agents ?? [];
967
+ // The order somebody actually does it in: take the chair, say who works
968
+ // here, say what for. The old list asked for "folders" — the vocabulary of
969
+ // the model before agents existed — and never mentioned the roster at all,
970
+ // so a new city could look finished with nobody in it.
774
971
  const tareas = [
775
972
  {
776
973
  ok: !!mia,
777
974
  txt: mia
778
- ? `Your card exists — you are <b>${esc(mia.agent)}</b>`
779
- : 'Take your seat: role, folders, one goal',
975
+ ? `Your seat is taken — you are <b>${esc(mia.agent)}</b> in <b>${esc(E.domain)}</b>`
976
+ : 'Take your seat: the work domain and your role in it',
780
977
  ir: 'puesto',
781
978
  },
979
+ {
980
+ ok: agentes.length > 0,
981
+ txt:
982
+ agentes.length === 0
983
+ ? 'Build the <b>houses</b> of this city — one per worker, each whole: its kind, its role, and everything it works on'
984
+ : `<b>${agentes.length}</b> house${agentes.length === 1 ? '' : 's'} in this city: ${esc(
985
+ agentes.map((a) => a.name).join(', '),
986
+ )}`,
987
+ ir: 'gente',
988
+ },
989
+ {
990
+ ok: agentes.length === 0 || agentes.some((a) => (a.mounts ?? []).length),
991
+ txt: agentes.some((a) => (a.mounts ?? []).length)
992
+ ? 'Every house works on something real'
993
+ : 'Give a house something to work on — a repository, a worktree, a folder of documents',
994
+ ir: 'gente',
995
+ },
782
996
  {
783
997
  ok: !!(mia && mia.goals_defined),
784
998
  txt:
@@ -788,23 +1002,33 @@ VISTAS.resumen = () => {
788
1002
  ir: 'puesto',
789
1003
  },
790
1004
  {
791
- ok: E.parcelas.length > 0 && sinUnidad === 0,
1005
+ ok: E.parcelas.length === 0 || sinUnidad === 0,
792
1006
  txt:
793
1007
  E.parcelas.length === 0
794
- ? 'No houses yet — pick your folders and they appear'
1008
+ ? 'Districts appear on the map once agents have houses'
795
1009
  : sinUnidad
796
1010
  ? `<b>${sinUnidad}</b> of ${E.parcelas.length} houses sit in no real district — assign them`
797
1011
  : 'Every house has its district',
798
1012
  ir: 'barrios',
799
1013
  },
800
1014
  ];
1015
+ const empezando = !mia || agentes.length === 0;
801
1016
  const sesionArriba = E.tmux.includes(E.sesion);
802
1017
  q('#lienzo').innerHTML = `
803
- <div><span class="sub">your city</span>
804
- <h1 style="margin-top:6px">${esc(E.city_name)}</h1>
1018
+ <div><span class="sub">${empezando ? 'welcome' : 'your city'}</span>
1019
+ <h1 style="margin-top:6px">${empezando ? 'Let’s build ' + esc(E.city_name) : esc(E.city_name)}</h1>
805
1020
  <p class="de mono" style="margin-top:5px">${esc(E.address)}</p>
806
- <p class="prosa" style="margin-top:8px">Domain: <b>${esc(E.domain)}</b>. Growth is
807
- counted with <code class="mono">${esc(E.grow || 'nothing yet')}</code>.</p></div>
1021
+ ${
1022
+ empezando
1023
+ ? `<p class="prosa" style="margin-top:8px">A city is a chair and the agents behind
1024
+ it. Two steps and it is alive: take your seat — the work domain and your role in
1025
+ it — then add the agents, each one asked for whole: what kind of work it does,
1026
+ its role, everything it works on, the engine that runs it and the skills it
1027
+ starts with. Everything below is a plain file in
1028
+ <code class="mono">${esc(corto(E.datos))}</code> you can also edit by hand.</p>`
1029
+ : `<p class="prosa" style="margin-top:8px">Domain: <b>${esc(E.domain)}</b>. Growth is
1030
+ counted with <code class="mono">${esc(E.grow || 'nothing yet')}</code>.</p>`
1031
+ }</div>
808
1032
  <div class="cifras">
809
1033
  <div class="cifra"><b>${Object.keys(E.skills).length}</b><span>repo agents</span></div>
810
1034
  <div class="cifra"><b>${E.parcelas.length}</b><span>houses</span></div>
@@ -910,12 +1134,23 @@ VISTAS.mapa = () => {
910
1134
  };
911
1135
  return;
912
1136
  }
1137
+ const cuantos = (E.agents ?? []).length;
913
1138
  q('#lienzo').innerHTML = `<div><span class="sub">the map</span>
914
- <h1 style="margin-top:6px">Not drawn yet</h1>
915
- <p class="prosa" style="margin-top:8px">The map can be served independently.
916
- Locally the hall runs it for you: first time bakes the front end
917
- and seeds the local database, which takes about a minute.</p></div>
918
- <div><button class="bt ppal" id="arrancaMapa">Draw my city</button></div>
1139
+ <h1 style="margin-top:6px">Your city, drawn</h1>
1140
+ <p class="prosa" style="margin-top:8px">${
1141
+ cuantos
1142
+ ? `<b>${cuantos}</b> agent${cuantos === 1 ? '' : 's'} live here, and the map shows
1143
+ them as houses that grow with the work they do — with the town hall in the middle
1144
+ when a committee is sitting, and a gate for every road out.`
1145
+ : `The map draws one house per agent. There are none yet, so it would be an empty
1146
+ plot — build one from <b>Houses</b> first.`
1147
+ }</p>
1148
+ <p class="prosa">The map is its own little server, so it is not running until you say so.
1149
+ The first time takes about a minute: it builds the front end and seeds a local database.
1150
+ After that it opens instantly, and it is yours — nothing is uploaded anywhere.</p></div>
1151
+ <div><button class="bt ppal" id="arrancaMapa">${
1152
+ cuantos ? 'Draw my city' : 'Draw it anyway'
1153
+ }</button></div>
919
1154
  <p class="cargando" id="mapaEspera" style="display:none">baking the map — first time takes a minute</p>`;
920
1155
  q<HTMLButtonElement>('#arrancaMapa').onclick = async () => {
921
1156
  q<HTMLButtonElement>('#arrancaMapa').disabled = true;
@@ -1511,16 +1746,22 @@ VISTAS.gente = () => {
1511
1746
  </div>`;
1512
1747
  };
1513
1748
 
1514
- q('#lienzo').innerHTML = `<div><span class="sub">agents &amp; skills</span>
1515
- <h1 style="margin-top:6px">One seat, the agents behind it</h1>
1516
- <p class="prosa" style="margin-top:8px">A city is its agents, and each one is
1517
- whole here: the kind of work it does, its role, everything it works on — any
1518
- number of repositories and document folders at once — the engine and effort
1519
- that run it, and the skills in its own home. Every number is real: growth
1520
- from what the agent actually produced, skills from live read-only
1521
- recognition. Changes persist to the card and apply the next time the session
1522
- opens.</p>
1523
- <button class="rpgAlta" type="button" id="altaAgente">+ Add an agent</button></div>
1749
+ q('#lienzo').innerHTML = `<div><span class="sub">the houses of ${esc(E.city_name)}</span>
1750
+ <h1 style="margin-top:6px">Who lives in ${esc(E.city_name)}</h1>
1751
+ <p class="prosa" style="margin-top:8px"><b>A house is where an agent lives and works</b>,
1752
+ and many houses are a city — it is the same thing the map draws, growing with what that
1753
+ agent actually does. The card and the CLI call them <code class="mono">agents</code>;
1754
+ here you see their houses.</p>
1755
+ <p class="prosa">They belong to <b>this</b> city and only
1756
+ to it: each one's workspace and its mounts live inside
1757
+ <code class="mono">${esc(corto(E.datos))}/agents/</code>, so another city has its own
1758
+ people even if you give them the same names. Every agent is whole here: the kind of work
1759
+ it does, its role, everything it works on — any number of repositories and document
1760
+ folders at once — the engine and effort that run it, and the skills in its own home.</p>
1761
+ <p class="prosa">Every number is real: growth from what the agent actually produced,
1762
+ skills from live read-only recognition. Changes persist to the card and apply the next
1763
+ time the session opens.</p>
1764
+ <button class="rpgAlta" type="button" id="altaAgente">+ Build a house</button></div>
1524
1765
  <div class="fichasRPG">
1525
1766
  <div class="fichaRPG rpgSeat">
1526
1767
  <div class="rpgCab">${caraRPG(E.avatars?.['seat'])}
@@ -1662,7 +1903,7 @@ VISTAS.gente = () => {
1662
1903
  toast(r.error || 'Could not add the agent', true);
1663
1904
  return;
1664
1905
  }
1665
- toast(`${nombre.trim()} joined this city — give it something to work on`);
1906
+ toast(`${nombre.trim()} has a house now — give it something to work on`);
1666
1907
  void refresca();
1667
1908
  } catch (e) {
1668
1909
  toast(String(e), true);
@@ -1909,7 +2150,9 @@ window.addEventListener('message', (message) => {
1909
2150
  function temaActual(): 'light' | 'dark' {
1910
2151
  const elegido = document.documentElement.getAttribute('data-tema');
1911
2152
  if (elegido) return elegido === 'claro' ? 'light' : 'dark';
1912
- return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
2153
+ // Day is THE default, full stop: a desktop set to dark does not decide this
2154
+ // for the owner. Only the switch does, and it is remembered.
2155
+ return 'light';
1913
2156
  }
1914
2157
 
1915
2158
  function tema(): void {
@@ -1921,14 +2164,16 @@ function tema(): void {
1921
2164
  return ''; // a browser with site data blocked still gets a working page
1922
2165
  }
1923
2166
  })();
1924
- const claroDelSistema = window.matchMedia?.('(prefers-color-scheme: light)').matches ?? false;
2167
+ const oscuroDelSistema = false; // daylight is the default; only the switch changes it
1925
2168
 
1926
2169
  const pon = (elegido: string): void => {
1927
2170
  const raiz = document.documentElement;
1928
2171
  if (elegido) raiz.setAttribute('data-tema', elegido);
1929
2172
  else raiz.removeAttribute('data-tema');
1930
- const claro = elegido ? elegido === 'claro' : claroDelSistema;
1931
- if (boton) boton.textContent = claro ? '☀ Day' : '☾ Night';
2173
+ const claro = elegido ? elegido === 'claro' : !oscuroDelSistema;
2174
+ // The button says where a click TAKES you, not where you are: a control
2175
+ // labelled with the state you can already see is a riddle.
2176
+ if (boton) boton.textContent = claro ? '☾ Night' : '☀ Day';
1932
2177
  const frame = document.querySelector<HTMLIFrameElement>('#cityMapFrame');
1933
2178
  frame?.contentWindow?.postMessage(
1934
2179
  { type: 'agents-city-map-theme/1', theme: claro ? 'light' : 'dark' },
@@ -1941,7 +2186,7 @@ function tema(): void {
1941
2186
  boton.onclick = () => {
1942
2187
  const claroAhora = document.documentElement.getAttribute('data-tema')
1943
2188
  ? document.documentElement.getAttribute('data-tema') === 'claro'
1944
- : claroDelSistema;
2189
+ : !oscuroDelSistema;
1945
2190
  const elegido = claroAhora ? 'oscuro' : 'claro';
1946
2191
  try {
1947
2192
  localStorage.setItem('hall-tema', elegido);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-city",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Run autonomous agent cities: one chair seat, role-aware repo agents, repo-owned skills, and explicit roads.",
5
5
  "bin": {
6
6
  "agents-city": "bin/agents-city.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "city",
3
3
  "displayName": "Agents City",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
6
6
  "author": {
7
7
  "name": "jlcases",
@@ -20100,7 +20100,7 @@ try {
20100
20100
  }
20101
20101
  var chair = Boolean(context && context.actors[actor]?.role === "chair" && actor === "seat");
20102
20102
  var server = new Server(
20103
- { name: "agents-city-bus", version: "0.1.0" },
20103
+ { name: "agents-city-bus", version: "0.2.1" },
20104
20104
  {
20105
20105
  capabilities: {
20106
20106
  ...channelEnabled ? { experimental: { "claude/channel": {} } } : {},
@@ -24,7 +24,7 @@ try {
24
24
 
25
25
  const chair = Boolean(context && context.actors[actor]?.role === 'chair' && actor === 'seat');
26
26
  const server = new Server(
27
- { name: 'agents-city-bus', version: '0.1.0' },
27
+ { name: 'agents-city-bus', version: '0.2.1' },
28
28
  {
29
29
  capabilities: {
30
30
  ...(channelEnabled ? { experimental: { 'claude/channel': {} } } : {}),
@@ -67,7 +67,7 @@ export class CodexConnector implements RuntimeConnector {
67
67
  async (method, params) => this.providerRequest(method, params),
68
68
  );
69
69
  await this.rpc.request('initialize', {
70
- clientInfo: { name: 'agents-city', title: 'Agents City', version: '0.1.0' },
70
+ clientInfo: { name: 'agents-city', title: 'Agents City', version: '0.2.1' },
71
71
  capabilities: { experimentalApi: true },
72
72
  });
73
73
  await this.rpc.notify('initialized');
@@ -4894,7 +4894,7 @@ var CodexConnector = class {
4894
4894
  async (method, params) => this.providerRequest(method, params)
4895
4895
  );
4896
4896
  await this.rpc.request("initialize", {
4897
- clientInfo: { name: "agents-city", title: "Agents City", version: "0.1.0" },
4897
+ clientInfo: { name: "agents-city", title: "Agents City", version: "0.2.1" },
4898
4898
  capabilities: { experimentalApi: true }
4899
4899
  });
4900
4900
  await this.rpc.notify("initialized");
@@ -248,6 +248,40 @@ def crea(usuario, nombre_ciudad, usar=True):
248
248
  return _real(destino)
249
249
 
250
250
 
251
+ def archiva(datos, usuario=''):
252
+ """Take one city out of use, keeping every byte of it.
253
+
254
+ Deliberately NOT a delete. A city is somebody's cards, deliberations and
255
+ map; a product that offers to erase that with one click will eventually
256
+ erase the wrong one. So the folder MOVES into `<user>/.backups/` with a
257
+ timestamp, the registry forgets it, and the selection falls back to another
258
+ city — recoverable with `mv`, which is a sentence a person can act on.
259
+
260
+ Returns the backup path. Refuses a city that is not this owner's managed
261
+ one, and refuses the last one standing: a city list with nothing in it is
262
+ not a state this product knows how to be in.
263
+ """
264
+ usuario = _slug(usuario or usuario_actual(), 'me')
265
+ real = _real(datos)
266
+ if not es_ciudad(real):
267
+ raise ValueError(f'{datos} is not a city')
268
+ if not gestionada(real, usuario):
269
+ raise ValueError('that city lives outside this owner\'s folder; move it by hand')
270
+ restantes = [c for c in lista(usuario) if _real(c['ruta']) != real]
271
+ if not restantes:
272
+ raise ValueError('this is your only city — create another one first')
273
+ base = os.path.join(carpeta_usuario(usuario), '.backups')
274
+ os.makedirs(base, mode=0o700, exist_ok=True)
275
+ destino, _ = _respaldo_libre(base, f'archivada-{nombre(real) or "city"}')
276
+ # The registry only ever held EXTERNAL cities — a managed one is found by
277
+ # walking the owner's folder, so moving it out is the whole removal.
278
+ seleccionada = _real(actual(usuario, crear=False) or '')
279
+ shutil.move(real, destino)
280
+ if seleccionada == real:
281
+ selecciona(usuario, restantes[0]['ruta'])
282
+ return destino
283
+
284
+
251
285
  def _registro_para(usuario):
252
286
  try:
253
287
  lineas = [l.strip() for l in open(REGISTRO, encoding='utf-8') if l.strip()]
@@ -151,28 +151,64 @@ def reinicia(datos, usuario="", dry_run=False):
151
151
  return ctx["backup"]
152
152
 
153
153
 
154
+ def _resuelve_todas(pedidas, usuario):
155
+ """Every requested city as a path, or (None, complaint).
156
+
157
+ `all` means every managed city this owner has. Names resolve one by one and
158
+ a single unknown name aborts the WHOLE run: resetting three of four cities
159
+ and then stopping on a typo is the worst possible outcome of a destructive
160
+ command, so nothing starts until every name is known.
161
+ """
162
+ if len(pedidas) == 1 and pedidas[0].lower() == "all":
163
+ todas = [c["ruta"] for c in cities.lista(usuario)]
164
+ if not todas:
165
+ return None, "there are no cities here to reset"
166
+ return todas, ""
167
+ fuera, desconocidas = [], []
168
+ for nombre in pedidas:
169
+ datos = cities.resuelve(nombre, usuario)
170
+ if datos:
171
+ if datos not in fuera:
172
+ fuera.append(datos)
173
+ else:
174
+ desconocidas.append(nombre)
175
+ if desconocidas:
176
+ conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none"
177
+ return None, (f"no city called {', '.join(repr(d) for d in desconocidas)}. "
178
+ f"Known here: {conocidas}. Nothing was reset.")
179
+ return fuera, ""
180
+
181
+
154
182
  def main():
155
183
  ap = argparse.ArgumentParser(
156
- description="Reset one city to onboarding; repos stay untouched and data is backed up"
184
+ description="Reset cities to onboarding; repos stay untouched and data is backed up"
157
185
  )
158
- ap.add_argument("city", help="city name or path (required)")
186
+ ap.add_argument("city", nargs="+",
187
+ help="one or more city names or paths, space separated — or `all`")
159
188
  ap.add_argument("--dry-run", action="store_true", help="show every effect, change nothing")
160
189
  a = ap.parse_args()
161
190
  usuario = cities.usuario_actual()
162
- datos = cities.resuelve(a.city, usuario)
163
- if not datos:
164
- conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none"
165
- print(f" No city called {a.city!r}. Known here: {conocidas}.", file=sys.stderr)
166
- return 1
167
- try:
168
- backup = reinicia(datos, usuario, a.dry_run)
169
- except ValueError as e:
170
- print(f" {e}", file=sys.stderr)
191
+ objetivo, queja = _resuelve_todas(a.city, usuario)
192
+ if not objetivo:
193
+ print(f" {queja}", file=sys.stderr)
171
194
  return 1
195
+ # More than one city at once is a bigger gesture than the command's name
196
+ # suggests, so it says out loud what it is about to do before doing it.
197
+ if len(objetivo) > 1 and not a.dry_run:
198
+ print(f" Resetting {len(objetivo)} cities: "
199
+ f"{', '.join(cities.slug_ciudad(d) for d in objetivo)}")
200
+ for datos in objetivo:
201
+ try:
202
+ backup = reinicia(datos, usuario, a.dry_run)
203
+ except ValueError as e:
204
+ print(f" {cities.slug_ciudad(datos)}: {e}", file=sys.stderr)
205
+ return 1
206
+ if not a.dry_run:
207
+ corto = backup.replace(os.path.expanduser("~"), "~")
208
+ print(f" City `{cities.slug_ciudad(datos)}` reset. Recovery copy: {corto}")
172
209
  if not a.dry_run:
173
- corto = backup.replace(os.path.expanduser("~"), "~")
174
- print(f" City `{cities.slug_ciudad(datos)}` reset. Recovery copy: {corto}")
175
- print(f" Run `agents-city seat --city {cities.slug_ciudad(datos)}` for onboarding.")
210
+ primera = cities.slug_ciudad(objetivo[0])
211
+ print(f" Run `agents-city seat --city {primera}` for onboarding.")
176
212
  return 0
177
213
 
178
214