agents-city 0.1.0 → 0.2.0

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,
@@ -267,10 +268,24 @@ const SECCIONES: Array<[string, string]> = [
267
268
  ['red', 'Roads'],
268
269
  ['committee', 'Committee'],
269
270
  ['gente', 'Agents & skills'],
271
+ ['ciudades', 'Cities'],
270
272
  ];
271
273
 
274
+ /** True until the person navigates: the landing section is decided once, from
275
+ * the state, and never yanked out from under them afterwards. */
276
+ let primeraCarga = true;
277
+
272
278
  async function refresca(): Promise<void> {
273
279
  E = await api<Estado>('/api/estado');
280
+ if (primeraCarga) {
281
+ primeraCarga = false;
282
+ // A city with no seat or no agents has nothing to draw, and "Not drawn
283
+ // yet" is a dead end as a first impression. Start where the next step is.
284
+ const mia = E.tarjetas.find((t) => t.user === E.yo);
285
+ // A city with no seat and no agents has nothing to show and everything to
286
+ // ask, so it opens the guide instead of a map it cannot draw.
287
+ if (!mia || (E.agents ?? []).length === 0) SECCION = 'bienvenida';
288
+ }
274
289
  pinta();
275
290
  pintaAvisoDeVersion();
276
291
  pintaDemoControles();
@@ -722,6 +737,7 @@ function rail(): void {
722
737
  }).join('');
723
738
  todos<HTMLElement>('#rail li').forEach((li) => {
724
739
  li.onclick = () => {
740
+ document.body.classList.remove('enGuia');
725
741
  SECCION = li.dataset.s ?? 'mapa';
726
742
  pinta();
727
743
  };
@@ -768,17 +784,134 @@ function pinta(): void {
768
784
  const VISTAS: Record<string, () => void> = {};
769
785
 
770
786
  // ── overview ─────────────────────────────────────────────────────────────────
787
+ /** The guided first run. Built once and kept, so a half-finished answer
788
+ * survives a repaint. */
789
+ let guia: Bienvenida | null = null;
790
+
791
+ VISTAS.bienvenida = () => {
792
+ document.body.classList.add('enGuia');
793
+ guia ??= new Bienvenida({
794
+ api,
795
+ esc,
796
+ aviso: toast,
797
+ refresca,
798
+ vete: (seccion: string) => {
799
+ document.body.classList.remove('enGuia');
800
+ SECCION = seccion;
801
+ pinta();
802
+ },
803
+ ciudad: E.city_name,
804
+ yo: E.yo,
805
+ datos: corto(E.datos),
806
+ });
807
+ void guia.pinta(q<HTMLElement>('#lienzo'));
808
+ };
809
+
810
+ VISTAS.ciudades = () => {
811
+ const filas = E.ciudades
812
+ .map(
813
+ (c) => `
814
+ <div class="fila ${c.actual ? 'on' : ''}">
815
+ <span class="et">${esc(c.nombre)}${c.actual ? ' <b class="de">· open now</b>' : ''}</span>
816
+ <span class="de mono">${esc(corto(c.ruta))}</span>
817
+ <span style="margin-left:auto;display:flex;gap:6px">
818
+ ${
819
+ c.actual
820
+ ? ''
821
+ : `<a class="bt mini" href="?city=${encodeURIComponent(c.ruta)}">open</a>
822
+ <button class="bt mini archiva" data-ciudad="${esc(c.ruta)}"
823
+ data-nombre="${esc(c.nombre)}">archive</button>`
824
+ }
825
+ </span>
826
+ </div>`,
827
+ )
828
+ .join('');
829
+ q('#lienzo').innerHTML = `<div><span class="sub">cities</span>
830
+ <h1 style="margin-top:6px">Your cities</h1>
831
+ <p class="prosa" style="margin-top:8px">One person, several autonomous cities: each has
832
+ its own identity, domain, chair, agents and roads. They share nothing unless you build a
833
+ road between them. The one you have open is marked.</p></div>
834
+ <div class="lista"><div class="filas">${filas}</div></div>
835
+ <div class="campos" style="max-width:420px">
836
+ <div class="campo"><label>Start another city</label>
837
+ <input type="text" id="nuevaCiudad" placeholder="client-a, research, the book…"></div>
838
+ <button class="bt ppal" id="creaCiudad">Create it</button>
839
+ </div>
840
+ <p class="pista">Archiving <b>moves</b> a city into
841
+ <code class="mono">.backups/</code> — nothing is deleted, and you can bring it back with
842
+ <code class="mono">mv</code>. The city you are standing in, and the last one you own,
843
+ cannot be archived.</p>`;
844
+ enlaza();
845
+ const campo = q<HTMLInputElement>('#nuevaCiudad');
846
+ q<HTMLButtonElement>('#creaCiudad').onclick = async () => {
847
+ const nombre = campo.value.trim();
848
+ if (!nombre) {
849
+ toast('Give it a name', true);
850
+ return;
851
+ }
852
+ const r = await api<Respuesta>('/api/ciudades', {
853
+ method: 'POST',
854
+ body: JSON.stringify({ name: nombre }),
855
+ });
856
+ if (!r.ok) {
857
+ toast(r.error ?? 'Could not create it', true);
858
+ return;
859
+ }
860
+ toast(`${nombre} created`);
861
+ await refresca();
862
+ };
863
+ todos<HTMLButtonElement>('.archiva').forEach((boton) => {
864
+ boton.onclick = async () => {
865
+ const nombre = boton.dataset.nombre ?? '';
866
+ if (!window.confirm(`Archive ${nombre}? It moves into .backups — nothing is deleted.`))
867
+ return;
868
+ const r = await api<{ ok?: boolean; backup?: string; error?: string }>(
869
+ '/api/ciudad-archiva',
870
+ { method: 'POST', body: JSON.stringify({ city: boton.dataset.ciudad ?? '' }) },
871
+ );
872
+ if (!r.ok) {
873
+ toast(r.error ?? 'Could not archive it', true);
874
+ return;
875
+ }
876
+ toast(`${nombre} archived — it is in ${r.backup ?? '.backups'}`);
877
+ await refresca();
878
+ };
879
+ });
880
+ };
881
+
771
882
  VISTAS.resumen = () => {
772
883
  const mia = E.tarjetas.find((t) => t.user === E.yo);
773
884
  const sinUnidad = E.parcelas.filter((p) => p.unidad === 'none' || p.unidad === 'mine').length;
885
+ const agentes = E.agents ?? [];
886
+ // The order somebody actually does it in: take the chair, say who works
887
+ // here, say what for. The old list asked for "folders" — the vocabulary of
888
+ // the model before agents existed — and never mentioned the roster at all,
889
+ // so a new city could look finished with nobody in it.
774
890
  const tareas = [
775
891
  {
776
892
  ok: !!mia,
777
893
  txt: mia
778
- ? `Your card exists — you are <b>${esc(mia.agent)}</b>`
779
- : 'Take your seat: role, folders, one goal',
894
+ ? `Your seat is taken — you are <b>${esc(mia.agent)}</b> in <b>${esc(E.domain)}</b>`
895
+ : 'Take your seat: the work domain and your role in it',
780
896
  ir: 'puesto',
781
897
  },
898
+ {
899
+ ok: agentes.length > 0,
900
+ txt:
901
+ agentes.length === 0
902
+ ? 'Add the <b>agents</b> that work in this city — each one whole: its kind, its role, and everything it works on'
903
+ : `<b>${agentes.length}</b> agent${agentes.length === 1 ? '' : 's'} in this city: ${esc(
904
+ agentes.map((a) => a.name).join(', '),
905
+ )}`,
906
+ ir: 'gente',
907
+ },
908
+ {
909
+ ok: agentes.length === 0 || agentes.some((a) => (a.mounts ?? []).length),
910
+ txt: agentes.some((a) => (a.mounts ?? []).length)
911
+ ? 'Your agents work on real repositories and folders'
912
+ : 'Give an agent something to work on — a repository, a worktree, a folder of documents',
913
+ ir: 'gente',
914
+ },
782
915
  {
783
916
  ok: !!(mia && mia.goals_defined),
784
917
  txt:
@@ -788,23 +921,33 @@ VISTAS.resumen = () => {
788
921
  ir: 'puesto',
789
922
  },
790
923
  {
791
- ok: E.parcelas.length > 0 && sinUnidad === 0,
924
+ ok: E.parcelas.length === 0 || sinUnidad === 0,
792
925
  txt:
793
926
  E.parcelas.length === 0
794
- ? 'No houses yet — pick your folders and they appear'
927
+ ? 'Districts appear on the map once agents have houses'
795
928
  : sinUnidad
796
929
  ? `<b>${sinUnidad}</b> of ${E.parcelas.length} houses sit in no real district — assign them`
797
930
  : 'Every house has its district',
798
931
  ir: 'barrios',
799
932
  },
800
933
  ];
934
+ const empezando = !mia || agentes.length === 0;
801
935
  const sesionArriba = E.tmux.includes(E.sesion);
802
936
  q('#lienzo').innerHTML = `
803
- <div><span class="sub">your city</span>
804
- <h1 style="margin-top:6px">${esc(E.city_name)}</h1>
937
+ <div><span class="sub">${empezando ? 'welcome' : 'your city'}</span>
938
+ <h1 style="margin-top:6px">${empezando ? 'Let’s build ' + esc(E.city_name) : esc(E.city_name)}</h1>
805
939
  <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>
940
+ ${
941
+ empezando
942
+ ? `<p class="prosa" style="margin-top:8px">A city is a chair and the agents behind
943
+ it. Two steps and it is alive: take your seat — the work domain and your role in
944
+ it — then add the agents, each one asked for whole: what kind of work it does,
945
+ its role, everything it works on, the engine that runs it and the skills it
946
+ starts with. Everything below is a plain file in
947
+ <code class="mono">${esc(corto(E.datos))}</code> you can also edit by hand.</p>`
948
+ : `<p class="prosa" style="margin-top:8px">Domain: <b>${esc(E.domain)}</b>. Growth is
949
+ counted with <code class="mono">${esc(E.grow || 'nothing yet')}</code>.</p>`
950
+ }</div>
808
951
  <div class="cifras">
809
952
  <div class="cifra"><b>${Object.keys(E.skills).length}</b><span>repo agents</span></div>
810
953
  <div class="cifra"><b>${E.parcelas.length}</b><span>houses</span></div>
@@ -1909,7 +2052,8 @@ window.addEventListener('message', (message) => {
1909
2052
  function temaActual(): 'light' | 'dark' {
1910
2053
  const elegido = document.documentElement.getAttribute('data-tema');
1911
2054
  if (elegido) return elegido === 'claro' ? 'light' : 'dark';
1912
- return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
2055
+ // Day is the default now, so only a machine that asks for dark gets it.
2056
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1913
2057
  }
1914
2058
 
1915
2059
  function tema(): void {
@@ -1921,14 +2065,16 @@ function tema(): void {
1921
2065
  return ''; // a browser with site data blocked still gets a working page
1922
2066
  }
1923
2067
  })();
1924
- const claroDelSistema = window.matchMedia?.('(prefers-color-scheme: light)').matches ?? false;
2068
+ const oscuroDelSistema = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
1925
2069
 
1926
2070
  const pon = (elegido: string): void => {
1927
2071
  const raiz = document.documentElement;
1928
2072
  if (elegido) raiz.setAttribute('data-tema', elegido);
1929
2073
  else raiz.removeAttribute('data-tema');
1930
- const claro = elegido ? elegido === 'claro' : claroDelSistema;
1931
- if (boton) boton.textContent = claro ? '☀ Day' : '☾ Night';
2074
+ const claro = elegido ? elegido === 'claro' : !oscuroDelSistema;
2075
+ // The button says where a click TAKES you, not where you are: a control
2076
+ // labelled with the state you can already see is a riddle.
2077
+ if (boton) boton.textContent = claro ? '☾ Night' : '☀ Day';
1932
2078
  const frame = document.querySelector<HTMLIFrameElement>('#cityMapFrame');
1933
2079
  frame?.contentWindow?.postMessage(
1934
2080
  { type: 'agents-city-map-theme/1', theme: claro ? 'light' : 'dark' },
@@ -1941,7 +2087,7 @@ function tema(): void {
1941
2087
  boton.onclick = () => {
1942
2088
  const claroAhora = document.documentElement.getAttribute('data-tema')
1943
2089
  ? document.documentElement.getAttribute('data-tema') === 'claro'
1944
- : claroDelSistema;
2090
+ : !oscuroDelSistema;
1945
2091
  const elegido = claroAhora ? 'oscuro' : 'claro';
1946
2092
  try {
1947
2093
  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.0",
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.0",
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.0" },
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.0' },
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.0' },
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.0" },
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()]