agents-city 0.3.0 → 0.4.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.
@@ -10,12 +10,24 @@
10
10
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=Newsreader:opsz,wght@6..72,400;6..72,500&family=IBM+Plex+Mono:wght@400;500&display=swap">
11
11
  <style>
12
12
  :root{
13
+ /* Day is the base, exactly as in the Hall: the map is framed by it, and a
14
+ light Hall around a night map read as two products in one window. The
15
+ tokens are the Hall's, so the two frames cannot drift apart. Night is
16
+ stamped by the Hall's own switch, never guessed from the OS. */
17
+ --suelo:#f3f0e8; --suelo2:#fbfaf6; --suelo3:#ffffff; --linea:#e2ddd0; --linea2:#cdc6b6;
18
+ --tinta:#1d2229; --tinta2:#515861; --tinta3:#7b8187;
19
+ --nucleo:#8a7247; /* los colores de cada unidad llegan de la API */
20
+ --lampara:#7048e8; --carta:#dd5d3e;
21
+ --insignia-borde:#cdc6b6; --insignia-fondo:#ffffff;
22
+ --hud:"Chakra Petch",system-ui,sans-serif; --texto:"Newsreader",Georgia,serif;
23
+ --mono:"IBM Plex Mono",ui-monospace,monospace;
24
+ }
25
+ :root[data-tema="dark"]{
13
26
  --suelo:#131a24; --suelo2:#1b2431; --suelo3:#222d3c; --linea:#26313f; --linea2:#38495f;
14
27
  --tinta:#e8e2d4; --tinta2:#9aa7b8; --tinta3:#6b7889;
15
- --nucleo:#c8b48a; /* los colores de cada unidad llegan de la API */
28
+ --nucleo:#c8b48a;
16
29
  --lampara:#f5c451; --carta:#e2604f;
17
- --hud:"Chakra Petch",system-ui,sans-serif; --texto:"Newsreader",Georgia,serif;
18
- --mono:"IBM Plex Mono",ui-monospace,monospace;
30
+ --insignia-borde:#4a4127; --insignia-fondo:#241f14;
19
31
  }
20
32
  *{box-sizing:border-box}
21
33
  html,body{height:100%}
@@ -42,7 +54,8 @@ aside{background:var(--suelo2);border-right:1px solid var(--linea);padding:20px
42
54
  display:flex;flex-direction:column;gap:18px;overflow-y:auto}
43
55
  .badge{display:inline-flex;align-items:center;gap:7px;align-self:flex-start;font-family:var(--hud);
44
56
  font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--lampara);
45
- border:1px solid #4a4127;background:#241f14;padding:4px 9px;border-radius:2px}
57
+ border:1px solid var(--insignia-borde);background:var(--insignia-fondo);
58
+ padding:4px 9px;border-radius:2px}
46
59
  .badge::before{content:"";width:5px;height:5px;border-radius:50%;background:var(--lampara)}
47
60
  h1{font-size:23px;line-height:1.05;margin:8px 0 5px}
48
61
  p.sub{font-size:13px;line-height:1.5;color:var(--tinta2);margin:0}
@@ -16,28 +16,16 @@
16
16
  */
17
17
 
18
18
  import { plural, t as _ } from './idioma';
19
- import { ESFUERZOS, MODELOS, RUNTIMES, opciones } from './motores';
19
+ import { CLASES, FormularioDeCasa } from './casa';
20
+ import type { DatosDeCasa, Rol } from './casa';
21
+
22
+ export type { Rol } from './casa';
20
23
 
21
24
  export interface Dominio {
22
25
  id: string;
23
26
  name: string;
24
27
  summary: string;
25
28
  }
26
- export interface Rol {
27
- id: string;
28
- name: string;
29
- summary: string;
30
- trade: string;
31
- }
32
- export interface RepoDelDisco {
33
- nombre: string;
34
- ruta: string;
35
- /** repo · worktree · docs — a house can be given any of the three. */
36
- clase: string;
37
- cuando: string;
38
- /** Touched recently, so almost certainly what this person came to pick. */
39
- mio: boolean;
40
- }
41
29
 
42
30
  /** What the onboarding needs from the Hall. Passed in rather than imported, so
43
31
  * this file cannot quietly grow its own way of talking to the server. */
@@ -53,38 +41,6 @@ export interface Puerta {
53
41
  datos: string;
54
42
  }
55
43
 
56
- interface AgenteNuevo {
57
- nombre: string;
58
- clase: string;
59
- rol: string;
60
- montajes: string[];
61
- /** Which CLI runs it, and — on Claude — the engine and how hard it thinks.
62
- * Empty means the owner's default, which is a real answer and the one most
63
- * people should give. Asked here because it is a decision somebody has
64
- * already made in their head while naming the house; making them find three
65
- * dropdowns afterwards is making them say it twice. */
66
- runtime: string;
67
- modelo: string;
68
- esfuerzo: string;
69
- }
70
-
71
- const CLASES: Array<[string, string, string]> = [
72
- [
73
- 'code',
74
- 'It writes code',
75
- 'Its house grows with the pull requests it merges. Mounts repositories and worktrees.',
76
- ],
77
- [
78
- 'knowledge',
79
- 'It keeps knowledge',
80
- 'Its house grows with the documents it writes. Needs no git at all.',
81
- ],
82
- ['coordinator', 'It coordinates', 'Its house grows with the decisions it records.'],
83
- ];
84
-
85
- /** One glyph per kind of place, so a chip says what it is without a legend. */
86
- const CLASE_CORTA: Record<string, string> = { repo: 'git', worktree: 'wt', docs: 'doc' };
87
-
88
44
  const PASOS = ['Welcome', 'The work', 'Your chair', 'The houses', 'Ready'];
89
45
 
90
46
  /** A house, drawn rather than shipped: the same isometric shape the map and the
@@ -102,16 +58,11 @@ export class Bienvenida {
102
58
  private dominios: Dominio[] = [];
103
59
  private roles: Rol[] = [];
104
60
  private rolesDeAgente: Rol[] = [];
105
- private repos: RepoDelDisco[] = [];
106
61
  private dominio = '';
107
62
  private rol = '';
108
- private roster: AgenteNuevo[] = [];
109
- private enCurso: AgenteNuevo | null = null;
63
+ private roster: DatosDeCasa[] = [];
64
+ private enCurso: FormularioDeCasa | null = null;
110
65
  private guardando = false;
111
- private buscandoRepos = false;
112
- private reposListos = false;
113
- /** What is typed in the mount search box, kept across repaints. */
114
- private filtro = '';
115
66
  private lienzo: HTMLElement | null = null;
116
67
 
117
68
  constructor(private readonly p: Puerta) {}
@@ -239,37 +190,22 @@ export class Bienvenida {
239
190
 
240
191
  // ── 3. the agents ─────────────────────────────────────────────────────────
241
192
  private pantallaAgentes(): string {
242
- // The repository index is a disk scan — a minute on a big home the first
243
- // time. Awaiting it here froze the guide on the PREVIOUS screen with no
244
- // explanation: the person clicks Next and nothing happens. So the step
245
- // draws immediately and the list arrives when it arrives.
246
- this.buscaReposUnaVez();
247
193
  if (!this.enCurso) return this.listaDeAgentes();
248
- return this.formularioDeAgente(this.enCurso);
249
- }
250
-
251
- private buscaReposUnaVez(otraVez = false): void {
252
- if (this.buscandoRepos || (this.reposListos && !otraVez)) return;
253
- this.buscandoRepos = true;
254
- void this.p
255
- .api<{ repos: RepoDelDisco[] }>(
256
- '/api/misrepos?user=' + encodeURIComponent(this.p.yo) + (otraVez ? '&refresh=1' : ''),
257
- )
258
- .then((r) => {
259
- // The whole index is kept, not a slice of it: the search box below is
260
- // what makes a thousand places usable, and a list cut at two hundred
261
- // would quietly fail to find the one somebody typed the name of.
262
- this.repos = r.repos ?? [];
263
- })
264
- .catch(() => {
265
- this.repos = []; // no index is not a reason to block the flow
266
- })
267
- .finally(() => {
268
- this.buscandoRepos = false;
269
- this.reposListos = true;
270
- // Only repaint if this step is still on screen.
271
- if (this.lienzo && this.paso === 3) void this.pinta(this.lienzo);
272
- });
194
+ // The form mounts itself into this hole after the step is on screen: it
195
+ // repaints on its own as chips are ticked and the disk index arrives, and
196
+ // that must never take the step's frame down with it.
197
+ return `
198
+ <div class="bvPaso">
199
+ <span class="sub">${_('a new house')}</span>
200
+ <h1>${_('Who lives in it?')}</h1>
201
+ <div id="bvCasa"></div>
202
+ <div class="bvBotones">
203
+ <button class="bt" data-bv="cancela">${_('Cancel')}</button>
204
+ <button class="bt ppal" data-bv="guarda" ${this.guardando ? 'disabled' : ''}>${_(
205
+ this.guardando ? 'building…' : 'Build it',
206
+ )}</button>
207
+ </div>
208
+ </div>`;
273
209
  }
274
210
 
275
211
  private listaDeAgentes(): string {
@@ -317,134 +253,6 @@ export class Bienvenida {
317
253
  * that keeps knowledge sees document folders first, because that is the one
318
254
  * kind of agent the old picker could not help at all.
319
255
  */
320
- private buscaEnElDisco(a: AgenteNuevo): RepoDelDisco[] {
321
- const q = this.filtro.trim().toLowerCase();
322
- const quiereDocs = a.clase === 'knowledge';
323
- const puntua = (r: RepoDelDisco): number =>
324
- (r.clase === 'docs' ? (quiereDocs ? -2 : 1) : 0) + (r.mio ? -1 : 0);
325
- const encaja = q
326
- ? this.repos.filter(
327
- (r) => r.nombre.toLowerCase().includes(q) || r.ruta.toLowerCase().includes(q),
328
- )
329
- : this.repos.filter((r) => r.mio);
330
- // A mount already chosen stays visible even when the search moves past it,
331
- // so unticking never means hunting for it again.
332
- const elegidas = this.repos.filter((r) => a.montajes.includes(r.ruta) && !encaja.includes(r));
333
- return elegidas
334
- .concat(encaja)
335
- .sort((x, y) => puntua(x) - puntua(y))
336
- .slice(0, 40);
337
- }
338
-
339
- private formularioDeAgente(a: AgenteNuevo): string {
340
- const clases = CLASES.map(
341
- ([id, titulo, porque]) => `
342
- <button class="bvOpcion ${a.clase === id ? 'elegida' : ''}" data-bv="clase" data-id="${id}">
343
- <b>${_(titulo)}</b><span>${_(porque)}</span></button>`,
344
- ).join('');
345
- const roles = this.rolesDeAgente
346
- .map(
347
- (r) =>
348
- `<option value="${this.p.esc(r.id)}"${r.id === a.rol ? ' selected' : ''}>${this.p.esc(
349
- r.name,
350
- )}</option>`,
351
- )
352
- .join('');
353
- const esClaude = (a.runtime || 'claude') === 'claude';
354
- const encontrados = this.buscaEnElDisco(a);
355
- const sugeridos = encontrados
356
- .map(
357
- (r) => `<button class="bvChip cl-${this.p.esc(r.clase)} ${
358
- a.montajes.includes(r.ruta) ? 'elegida' : ''
359
- }"
360
- data-bv="monta" data-ruta="${this.p.esc(r.ruta)}"
361
- title="${this.p.esc(r.ruta)}${r.cuando ? ` · ${this.p.esc(r.cuando)}` : ''}"
362
- ><i class="bvClase">${this.p.esc(CLASE_CORTA[r.clase] ?? r.clase)}</i>${this.p.esc(
363
- r.nombre,
364
- )}</button>`,
365
- )
366
- .join('');
367
- return `
368
- <div class="bvPaso">
369
- <span class="sub">${_('a new house')}</span>
370
- <h1>${_('Who lives in it?')}</h1>
371
- <div class="campo"><label>${_('What do you call it?')}</label>
372
- <input type="text" id="bvNombre" value="${this.p.esc(a.nombre)}"
373
- placeholder="${_('urgencias, api, the handbook — whatever you would say out loud')}"></div>
374
- <label class="bvEtiqueta">${_('What kind of work does it do?')}</label>
375
- <div class="bvRejilla bvTres">${clases}</div>
376
- <div class="campo"><label>${_('Its role — its speciality, never authority')}</label>
377
- <select id="bvRol">${roles}</select></div>
378
- <label class="bvEtiqueta">${_('What runs it?')}</label>
379
- <p class="pista">${_(
380
- 'Leave all three on default and it runs the way you do. Model and effort are Claude’s to read; another CLI carries its own flags.',
381
- )}</p>
382
- <div class="bvMotor">
383
- <div class="campo"><label>${_('provider')}</label>
384
- <select id="bvRuntime">${opciones(RUNTIMES, a.runtime, this.p.esc, 'claude')}</select></div>
385
- <div class="campo"><label>${_('engine')}</label>
386
- <select id="bvModelo" ${esClaude ? '' : 'disabled'}>${opciones(
387
- [''].concat(MODELOS),
388
- a.modelo,
389
- this.p.esc,
390
- _('default'),
391
- )}</select></div>
392
- <div class="campo"><label>${_('effort')}</label>
393
- <select id="bvEsfuerzo" ${esClaude ? '' : 'disabled'}>${opciones(
394
- [''].concat(ESFUERZOS),
395
- a.esfuerzo,
396
- this.p.esc,
397
- _('default'),
398
- )}</select></div>
399
- </div>
400
- <label class="bvEtiqueta">${_('What does it work on?')}</label>
401
- <p class="pista">${_(`Everything found on this disk: <b>repositories</b>, the
402
- <b>worktrees</b> an isolated agent works in, and <b>folders of documents</b> with no git
403
- at all. Pick as many as you like — this is the part that makes it an agent and not a
404
- repo.`)}</p>
405
- <div class="bvBuscador">
406
- <input type="search" id="bvBusca" value="${this.p.esc(this.filtro)}"
407
- placeholder="${_('search your disk — a name, or part of a path')}">
408
- <button class="bt bvMini" data-bv="rebusca" ${this.buscandoRepos ? 'disabled' : ''}
409
- title="${_('Look through the disk again — for something cloned a minute ago')}">${_(
410
- this.buscandoRepos ? 'searching…' : 'Search again',
411
- )}</button>
412
- </div>
413
- ${
414
- sugeridos
415
- ? `<div class="bvChips">${sugeridos}</div>
416
- <p class="pista">${
417
- this.filtro
418
- ? _('{shown} of {total} places match', {
419
- shown: String(encontrados.length),
420
- total: String(this.repos.length),
421
- })
422
- : _('{total} places found — type to narrow it down', {
423
- total: String(this.repos.length),
424
- })
425
- }</p>`
426
- : this.buscandoRepos
427
- ? `<p class="cargando">${_('looking through your disk…')}</p>`
428
- : this.filtro
429
- ? `<p class="pista">${_('Nothing matches that. Add it by path below.')}</p>`
430
- : `<p class="pista">${_('Nothing found on this disk — add folders by path below.')}</p>`
431
- }
432
- <div class="campo"><label>${_('…or a path, and Enter')}</label>
433
- <input type="text" id="bvRuta" placeholder="~/documents/handbook"></div>
434
- ${
435
- a.montajes.length
436
- ? `<p class="pista">${_('Working on: {que}', { que: `<b>${this.p.esc(a.montajes.join(', '))}</b>` })}</p>`
437
- : ''
438
- }
439
- <div class="bvBotones">
440
- <button class="bt" data-bv="cancela">${_('Cancel')}</button>
441
- <button class="bt ppal" data-bv="guarda" ${this.guardando ? 'disabled' : ''}>${_(
442
- this.guardando ? 'building…' : 'Build it',
443
- )}</button>
444
- </div>
445
- </div>`;
446
- }
447
-
448
256
  // ── 4. done ───────────────────────────────────────────────────────────────
449
257
  private pantallaFinal(): string {
450
258
  return `
@@ -498,33 +306,10 @@ export class Bienvenida {
498
306
  return repinta();
499
307
  }
500
308
  if (que === 'nuevo') {
501
- this.enCurso = {
502
- nombre: '',
503
- clase: 'code',
504
- rol: 'blank',
505
- montajes: [],
506
- runtime: '',
507
- modelo: '',
508
- esfuerzo: '',
509
- };
510
- return repinta();
511
- }
512
- if (que === 'clase' && this.enCurso) {
513
- this.enCurso.clase = el.dataset.id ?? 'code';
514
- this.recogeFormulario(lienzo);
515
- return repinta();
516
- }
517
- if (que === 'monta' && this.enCurso) {
518
- const ruta = el.dataset.ruta ?? '';
519
- const ya = this.enCurso.montajes.indexOf(ruta);
520
- if (ya >= 0) this.enCurso.montajes.splice(ya, 1);
521
- else this.enCurso.montajes.push(ruta);
522
- this.recogeFormulario(lienzo);
523
- return repinta();
524
- }
525
- if (que === 'rebusca') {
526
- this.recogeFormulario(lienzo);
527
- this.buscaReposUnaVez(true);
309
+ this.enCurso = new FormularioDeCasa(
310
+ { api: this.p.api, esc: this.p.esc, aviso: this.p.aviso, yo: this.p.yo },
311
+ this.rolesDeAgente,
312
+ );
528
313
  return repinta();
529
314
  }
530
315
  if (que === 'cancela') {
@@ -539,107 +324,28 @@ export class Bienvenida {
539
324
  if (que === 'ir-puesto') return this.p.vete('puesto');
540
325
  };
541
326
  });
542
- // The search filters what is already in memory, so it repaints as you type
543
- // without asking the disk anything. Focus and caret are restored because a
544
- // box that loses the cursor on the third letter is not a search box.
545
- const busca = lienzo.querySelector<HTMLInputElement>('#bvBusca');
546
- if (busca) {
547
- busca.oninput = () => {
548
- this.filtro = busca.value;
549
- const donde = busca.selectionStart ?? busca.value.length;
550
- void this.pinta(lienzo).then(() => {
551
- const otra = lienzo.querySelector<HTMLInputElement>('#bvBusca');
552
- if (!otra) return;
553
- otra.focus();
554
- otra.setSelectionRange(donde, donde);
555
- });
556
- };
327
+ const hueco = lienzo.querySelector<HTMLElement>('#bvCasa');
328
+ if (hueco && this.enCurso) {
329
+ this.enCurso.monta(hueco);
330
+ hueco.querySelector<HTMLInputElement>('#bvNombre')?.focus();
557
331
  }
558
- const ruta = lienzo.querySelector<HTMLInputElement>('#bvRuta');
559
- if (ruta)
560
- ruta.onkeydown = (e) => {
561
- if (e.key !== 'Enter' || !this.enCurso) return;
562
- e.preventDefault();
563
- const valor = ruta.value.trim();
564
- if (valor) this.enCurso.montajes.push(valor);
565
- this.recogeFormulario(lienzo);
566
- void this.pinta(lienzo);
567
- };
568
- lienzo.querySelector<HTMLSelectElement>('#bvRuntime')?.addEventListener('change', () => {
569
- this.recogeFormulario(lienzo);
570
- void this.pinta(lienzo);
571
- });
572
- if (!busca?.value) lienzo.querySelector<HTMLInputElement>('#bvNombre')?.focus();
573
- }
574
-
575
- /** Keep what is typed when a click forces a repaint. */
576
- private recogeFormulario(lienzo: HTMLElement): void {
577
- if (!this.enCurso) return;
578
- const nombre = lienzo.querySelector<HTMLInputElement>('#bvNombre');
579
- const rol = lienzo.querySelector<HTMLSelectElement>('#bvRol');
580
- if (nombre) this.enCurso.nombre = nombre.value;
581
- if (rol) this.enCurso.rol = rol.value;
582
- for (const [id, campo] of [
583
- ['#bvRuntime', 'runtime'],
584
- ['#bvModelo', 'modelo'],
585
- ['#bvEsfuerzo', 'esfuerzo'],
586
- ] as const) {
587
- const sel = lienzo.querySelector<HTMLSelectElement>(id);
588
- if (sel) this.enCurso[campo] = sel.value;
589
- }
590
- const busca = lienzo.querySelector<HTMLInputElement>('#bvBusca');
591
- if (busca) this.filtro = busca.value;
592
332
  }
593
333
 
594
334
  private async guardaAgente(lienzo: HTMLElement): Promise<void> {
595
- this.recogeFormulario(lienzo);
596
- const a = this.enCurso;
597
- if (!a) return;
598
- if (!a.nombre.trim()) {
599
- this.p.aviso('Give it a name — it is how you will call it in its window', true);
600
- return;
601
- }
335
+ const forma = this.enCurso;
336
+ if (!forma) return;
602
337
  this.guardando = true;
603
338
  await this.pinta(lienzo);
604
339
  try {
605
340
  // The seat has to exist before an agent can join it: agents live on the
606
341
  // owner's card. Writing it here is why this step can come before the goal.
607
342
  await this.aseguraFicha();
608
- const r = await this.p.api<{ ok?: boolean; agent?: string; error?: string }>('/api/agentes', {
609
- method: 'POST',
610
- body: JSON.stringify({ name: a.nombre.trim(), kind: a.clase, role: a.rol }),
611
- });
612
- if (!r.ok || !r.agent) {
613
- this.p.aviso(r.error || 'Could not add that agent', true);
614
- return;
615
- }
616
- for (const ruta of a.montajes) {
617
- const m = await this.p.api<{ ok?: boolean; error?: string }>('/api/montaje', {
618
- method: 'POST',
619
- body: JSON.stringify({ agent: r.agent, add: ruta }),
620
- });
621
- if (!m.ok) this.p.aviso(`${ruta}: ${m.error ?? 'could not mount'}`, true);
622
- }
623
- // The engine is a second write by necessity: `/api/agentes` owns who is
624
- // on the roster and `/api/agente` owns how one is run, and neither is
625
- // allowed to learn the other's job just to save a round trip.
626
- const motor: Record<string, string> = {};
627
- if (a.runtime) motor.runtime = a.runtime;
628
- if (a.modelo) motor.model = a.modelo;
629
- if (a.esfuerzo) motor.effort = a.esfuerzo;
630
- if (Object.keys(motor).length) {
631
- const m = await this.p.api<{ ok?: boolean; error?: string }>('/api/agente', {
632
- method: 'POST',
633
- body: JSON.stringify({ agent: r.agent, ...motor }),
634
- });
635
- if (!m.ok) this.p.aviso(m.error ?? 'Could not set its engine', true);
636
- }
637
- this.roster.push({ ...a, nombre: a.nombre.trim() });
343
+ const hecho = await forma.guarda();
344
+ if (!hecho) return;
345
+ this.roster.push({ ...forma.datos, nombre: hecho.nombre });
638
346
  this.enCurso = null;
639
- this.p.aviso(`${a.nombre.trim()} joined the city`);
347
+ this.p.aviso(_('{name} joined the city', { name: hecho.nombre }));
640
348
  await this.p.refresca();
641
- } catch (e) {
642
- this.p.aviso(String(e), true);
643
349
  } finally {
644
350
  this.guardando = false;
645
351
  await this.pinta(lienzo);