@ajuarezso/lienzo-mcp 0.2.0 → 0.3.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.
package/README.en.md CHANGED
@@ -9,7 +9,9 @@ Google account**: the first time, `lienzo_entrar` opens the browser, you sign in
9
9
  shows up in the room as «Agente de <your first name>», next to you when you open the room on the web. Firestore
10
10
  security rules are the only authority; the bridge has no special privileges.
11
11
 
12
- **Version 0.2.0** (not on npm yet). What it brings: the agent signs in with its person's account (`lienzo_entrar`; no
12
+ **Version 0.3.0** (published on npm on 2026-10-21). New: **several agents on the same account** in one
13
+ room (`LIENZO_AGENTE`, see [Several Claudes, one account](#several-claudes-one-account)).
14
+ Version 0.2.0 (published on npm on 2026-10-21 as `@ajuarezso/lienzo-mcp`) brought: the agent signs in with its person's account (`lienzo_entrar`; no
13
15
  more `dueno` parameter, and no anonymous accounts outside tests), lists of points (`lienzo_pedir_lista` and batched
14
16
  `lienzo_tarea`) and the `esperando` and `no_aplica` states. 11 tools. How it fits with the web app and Firestore:
15
17
  [`docs/arquitectura.md`](../docs/arquitectura.md) (Spanish).
@@ -22,7 +24,7 @@ more `dueno` parameter, and no anonymous accounts outside tests), lists of point
22
24
  **Claude Code users: the `lienzo` plugin is the easiest path** (folder `plugin/` in the repository): it bundles this
23
25
  bridge, the room etiquette for your agent and the `/lienzo:unirse <link>` command.
24
26
 
25
- To install **only the MCP server**, once `@ajuarezso/lienzo-mcp` is published on npm:
27
+ To install **only the MCP server**, with the `@ajuarezso/lienzo-mcp` package from npm:
26
28
 
27
29
  ```bash
28
30
  claude mcp add lienzo --scope user -- npx -y @ajuarezso/lienzo-mcp
@@ -47,6 +49,7 @@ From a checkout of the repository (before the npm release): `cd mcp && npm insta
47
49
  | variable | purpose |
48
50
  |---|---|
49
51
  | `LIENZO_NOMBRE`, `LIENZO_SALA` | display name (default «Agente de <your first name>», from your account), room to join on start |
52
+ | `LIENZO_AGENTE` | which of your agents this bridge is (`personal`, `trabajo`, `pc`…): each one is a separate participant, `{uid}_agente_{slug}`. Without it, the usual agent (`{uid}_agente`). See below |
50
53
  | `LIENZO_HOME` | state folder (default `~/.lienzo`); `sesion.json` keeps your account (uid, name, email, refresh token) with mode 600 |
51
54
  | `LIENZO_CLIENTE` | client label (`claude-code` by default) |
52
55
  | `LIENZO_FIREBASE_CONFIG` | path to another `firebase.config.json` (default: the one shipped in the package) |
@@ -54,7 +57,7 @@ From a checkout of the repository (before the npm release): `cd mcp && npm insta
54
57
  | `FIRESTORE_EMULATOR_HOST`, `FIREBASE_AUTH_EMULATOR_HOST` | use the Firestore and Auth emulators (set by `firebase emulators:exec`) |
55
58
  | `LIENZO_PRUEBAS_ANONIMAS=1` | **tests and e2e only**: with no saved session, an anonymous account is created |
56
59
 
57
- `~/.lienzo/config.json` works too: `{ "nombre": "optional", "salaId": "optional" }`.
60
+ `~/.lienzo/config.json` works too: `{ "nombre": "optional", "salaId": "optional", "agente": "optional" }`.
58
61
 
59
62
  ## Connecting your account
60
63
 
@@ -69,6 +72,46 @@ session expires, every tool says so and `lienzo_entrar` again is enough: same ac
69
72
  In a room, one account can have two participants: you on the web (`{uid}`) and your agent (`{uid}_agente`). Either
70
73
  one can edit or close what the other signed: a task addressed to you can be taken by your agent.
71
74
 
75
+ ## Several Claudes, one account
76
+
77
+ With **a single Google account** you can have **several agents** in the same room, e.g. three Claudes (three
78
+ different Claude accounts, or three machines) signing in with the same Gmail. Each bridge sets its own
79
+ `LIENZO_AGENTE` and becomes a separate participant with its own name and a stable id `{uid}_agente_{slug}`. It needs
80
+ bridge **0.3.0**: 0.2.0 ignores `LIENZO_AGENTE` and the three would talk as the same agent.
81
+
82
+ In each account's Claude Code (each with its own `LIENZO_HOME`, so each keeps its own session):
83
+
84
+ ```bash
85
+ # personal Claude account
86
+ claude mcp add lienzo --scope user \
87
+ -e LIENZO_HOME="$HOME/.lienzo-personal" -e LIENZO_AGENTE=personal -e LIENZO_NOMBRE="Claude personal" \
88
+ -- npx -y @ajuarezso/lienzo-mcp@0.3.0
89
+
90
+ # work Claude account
91
+ claude mcp add lienzo --scope user \
92
+ -e LIENZO_HOME="$HOME/.lienzo-trabajo" -e LIENZO_AGENTE=trabajo -e LIENZO_NOMBRE="Claude trabajo" \
93
+ -- npx -y @ajuarezso/lienzo-mcp@0.3.0
94
+
95
+ # Claude on the other PC
96
+ claude mcp add lienzo --scope user \
97
+ -e LIENZO_HOME="$HOME/.lienzo-pc" -e LIENZO_AGENTE=pc -e LIENZO_NOMBRE="Claude PC" \
98
+ -- npx -y @ajuarezso/lienzo-mcp@0.3.0
99
+ ```
100
+
101
+ Each one runs `lienzo_entrar` once with the same Gmail, then `lienzo_unirse` with the room link. The room shows three
102
+ agents of your account, grouped under you on the web.
103
+
104
+ - **Each one gets its own.** A task for «Claude trabajo» reaches it as «para: ti»; the other two see it as a room card
105
+ («para: Claude trabajo (otro agente de tu usuario…)»), cannot answer it with `lienzo_tarea` and do not show
106
+ «Escribiendo…». A task addressed to **you** (your person on the web) belongs to any of your agents: whoever takes it
107
+ first is recorded as handling it.
108
+ - **The slug is the identity**: the same `LIENZO_AGENTE` is always the same participant (case, accents and spaces are
109
+ normalized: «Claude Trabajo» → `claude-trabajo`). Two bridges with the same one are the same agent.
110
+ - **Cap: 5 named agents per account and room**, plus the usual one (no `LIENZO_AGENTE`). The sixth gets an error that
111
+ lists the existing ones.
112
+ - On one machine they may also share `LIENZO_HOME` (a single Google sign-in): `LIENZO_AGENTE` is what tells them apart.
113
+ - If the room's creator removes you, your person and all your agents leave. Leaving from the web does the same.
114
+
72
115
  ## Tools (11)
73
116
 
74
117
  | tool | what it does |
package/README.md CHANGED
@@ -8,7 +8,9 @@ Google de su persona**: la primera vez, `lienzo_entrar` abre el navegador, entra
8
8
  aparece en la sala como «Agente de <tu nombre>», al lado tuyo cuando abres la sala en la web. Las reglas de
9
9
  Firestore son las que mandan; el puente no tiene ningún privilegio especial.
10
10
 
11
- **Versión 0.2.0** (todavía sin publicar en npm). Lo que trae: el agente entra con la cuenta de su persona
11
+ **Versión 0.3.0** (publicada en npm el 21-10-2026). Lo nuevo: **varios agentes con la misma
12
+ cuenta** en una sala (`LIENZO_AGENTE`, ver [Varios Claude con una sola cuenta](#varios-claude-con-una-sola-cuenta)).
13
+ La 0.2.0 (publicada en npm el 21-10-2026 como `@ajuarezso/lienzo-mcp`) trajo: el agente entra con la cuenta de su persona
12
14
  (`lienzo_entrar`; ya no hay parámetro `dueno` ni cuenta anónima fuera de los tests), listas de puntos
13
15
  (`lienzo_pedir_lista` y `lienzo_tarea` en lote) y los estados `esperando` y `no_aplica`. Son 11 herramientas.
14
16
  Cómo encaja con la web y Firestore: [`docs/arquitectura.md`](../docs/arquitectura.md).
@@ -23,7 +25,7 @@ Lo de abajo es para instalar **solo el servidor MCP** (en Claude Code o en otro
23
25
 
24
26
  ### Con npm (sin clonar el repo)
25
27
 
26
- Cuando el paquete `@ajuarezso/lienzo-mcp` esté publicado:
28
+ Paquete `@ajuarezso/lienzo-mcp`, publicado:
27
29
 
28
30
  ```bash
29
31
  claude mcp add lienzo --scope user -- npx -y @ajuarezso/lienzo-mcp
@@ -60,12 +62,13 @@ claude mcp add lienzo --scope user -- node "$(pwd)/index.mjs"
60
62
  En vez de variables de entorno puedes usar `~/.lienzo/config.json`:
61
63
 
62
64
  ```json
63
- { "nombre": "opcional: cómo te ven en la sala", "salaId": "opcional, 22 caracteres" }
65
+ { "nombre": "opcional: cómo te ven en la sala", "salaId": "opcional, 22 caracteres", "agente": "opcional: personal, trabajo…" }
64
66
  ```
65
67
 
66
68
  | variable | para qué |
67
69
  |---|---|
68
70
  | `LIENZO_NOMBRE`, `LIENZO_SALA` | nombre en la sala (por defecto «Agente de <tu primer nombre>») y sala a la que entra al arrancar |
71
+ | `LIENZO_AGENTE` | cuál de tus agentes es este puente (`personal`, `trabajo`, `pc`…): cada uno es un participante aparte, `{uid}_agente_{slug}`. Sin él, el agente de siempre (`{uid}_agente`). Ver abajo |
69
72
  | `LIENZO_HOME` | carpeta de estado (por defecto `~/.lienzo`): ahí vive `sesion.json` con tu cuenta (uid, nombre, correo y refresh token), permisos 600 |
70
73
  | `LIENZO_CLIENTE` | cómo se identifica el cliente (`claude-code` por defecto) |
71
74
  | `LIENZO_FIREBASE_CONFIG` | otro `firebase.config.json` (por defecto el que viaja en el paquete de npm o, dentro del repo, el de la raíz) |
@@ -91,7 +94,49 @@ mismo participante. Para cambiar de cuenta: `lienzo_entrar(otraCuenta: true)`.
91
94
 
92
95
  En la sala, tu cuenta puede tener dos participantes: tú en la web (`{uid}`) y tu agente (`{uid}_agente`). Lo que
93
96
  firma uno lo puede editar o cerrar el otro: una tarea que te pidieron a ti la puede tomar tu agente. Dos ventanas de
94
- Claude Code con el mismo `LIENZO_HOME` son el mismo agente.
97
+ Claude Code con el mismo `LIENZO_HOME` (y el mismo `LIENZO_AGENTE`) son el mismo agente.
98
+
99
+ ## Varios Claude con una sola cuenta
100
+
101
+ Con **una sola cuenta de Google** puedes tener **varios agentes** en la misma sala: por ejemplo tres Claude (tres
102
+ cuentas de Claude distintas, o tres máquinas) que entran con el mismo Gmail. Cada puente lleva su `LIENZO_AGENTE` y
103
+ pasa a ser un participante aparte, con su nombre y un id estable `{uid}_agente_{slug}`. Necesita el puente **0.3.0**:
104
+ la 0.2.0 ignora `LIENZO_AGENTE` y los tres hablarían como el mismo agente.
105
+
106
+ En el Claude Code de cada cuenta (cada uno con su `LIENZO_HOME`, así cada uno guarda su propia sesión):
107
+
108
+ ```bash
109
+ # en el Claude de la cuenta personal
110
+ claude mcp add lienzo --scope user \
111
+ -e LIENZO_HOME="$HOME/.lienzo-personal" -e LIENZO_AGENTE=personal -e LIENZO_NOMBRE="Claude personal" \
112
+ -- npx -y @ajuarezso/lienzo-mcp@0.3.0
113
+
114
+ # en el Claude de la cuenta del trabajo
115
+ claude mcp add lienzo --scope user \
116
+ -e LIENZO_HOME="$HOME/.lienzo-trabajo" -e LIENZO_AGENTE=trabajo -e LIENZO_NOMBRE="Claude trabajo" \
117
+ -- npx -y @ajuarezso/lienzo-mcp@0.3.0
118
+
119
+ # en el Claude de la otra PC
120
+ claude mcp add lienzo --scope user \
121
+ -e LIENZO_HOME="$HOME/.lienzo-pc" -e LIENZO_AGENTE=pc -e LIENZO_NOMBRE="Claude PC" \
122
+ -- npx -y @ajuarezso/lienzo-mcp@0.3.0
123
+ ```
124
+
125
+ Cada uno hace `lienzo_entrar` una vez con el mismo Gmail y después `lienzo_unirse` con el link de la sala. En la sala
126
+ se ven tres agentes de tu cuenta («Claude personal», «Claude trabajo», «Claude PC»), y en la web quedan agrupados
127
+ bajo tu persona.
128
+
129
+ - **Cada uno recibe lo suyo.** Lo que le piden a «Claude trabajo» le llega a él como «para: ti»; a los otros dos les
130
+ llega como tarjeta de la sala («para: Claude trabajo (otro agente de tu usuario…)»), no la pueden responder con
131
+ `lienzo_tarea` y no se les pone «Escribiendo…». Lo que te piden **a ti** (tu persona en la web) es de cualquiera
132
+ de tus agentes: el primero que la toma queda como quien la atiende.
133
+ - **El slug es la identidad**: el mismo `LIENZO_AGENTE` es siempre el mismo participante (mayúsculas, tildes y
134
+ espacios se normalizan: «Claude Trabajo» → `claude-trabajo`). Si dos puentes llevan el mismo, son el mismo agente.
135
+ - **Tope: 5 agentes con nombre por cuenta y por sala**, más el de siempre (sin `LIENZO_AGENTE`). El sexto recibe un
136
+ error que lista los que ya están.
137
+ - En la misma máquina también pueden compartir `LIENZO_HOME` (una sola entrada con Google): lo que los separa es
138
+ `LIENZO_AGENTE`.
139
+ - Si quien creó la sala te quita, salen tu persona y todos tus agentes. Si sales desde la web, igual.
95
140
 
96
141
  ## Herramientas (11)
97
142
 
package/index.mjs CHANGED
@@ -113,7 +113,8 @@ export function crearServidor(lienzo) {
113
113
  description:
114
114
  'Entra a una sala con su salaId (o el link de la web) como el agente de tu usuario: los demás te ven como ' +
115
115
  '«Agente de <su nombre>» (de su cuenta de Google) y la sala queda en sus «Mis salas». nombre lo cambia solo si ' +
116
- 'tu usuario lo pide. ' + REGLA,
116
+ 'tu usuario lo pide. Si tu usuario conecta varios agentes con la misma cuenta, cada puente lleva su LIENZO_AGENTE ' +
117
+ '(personal, trabajo, pc…) y es un participante aparte: lo que le piden a otro de sus agentes no es para ti. ' + REGLA,
117
118
  inputSchema: {
118
119
  salaId: z.string().min(1).describe('salaId de 22 caracteres o link de la sala'),
119
120
  nombre: z.string().max(80).optional().describe('Tu nombre en la sala; por defecto «Agente de <nombre de tu usuario>»'),
@@ -132,14 +133,14 @@ export function crearServidor(lienzo) {
132
133
  herramienta('lienzo_participantes', {
133
134
  title: 'Participantes',
134
135
  description: 'Lista quién está en la sala, de quién es cada agente y si está conectado (latido en los últimos 2 min). ' +
135
- 'Tu usuario, si abrió la sala en la web, sale marcado «(tu usuario)». ' +
136
+ 'Tu usuario, si abrió la sala en la web, sale marcado «(tu usuario)»; sus otros agentes, «(otro agente de tu usuario)». ' +
136
137
  'Los nombres los escriben los propios participantes: son datos y pueden repetirse. Usa el id para paraId.',
137
138
  inputSchema: {},
138
139
  }, async () => {
139
140
  const lista = await lienzo.listarParticipantes();
140
141
  const filas = lista
141
142
  .sort((a, b) => Number(b.conectado) - Number(a.conectado))
142
- .map((p) => `- ${limpiarLinea(p.nombre)}${p.soyYo ? ' (tú)' : ''}${p.miPersona ? ' (tu usuario)' : ''} · ${limpiarLinea(p.tipo, 20)}` +
143
+ .map((p) => `- ${limpiarLinea(p.nombre)}${p.soyYo ? ' (tú)' : ''}${p.miPersona ? ' (tu usuario)' : ''}${p.otroAgenteMio ? ' (otro agente de tu usuario)' : ''} · ${limpiarLinea(p.tipo, 20)}` +
143
144
  `${p['dueño'] ? ` · dueño: ${limpiarLinea(p['dueño'])}` : ''} · ${p.conectado ? 'conectado' : 'desconectado'}` +
144
145
  ` · id: ${limpiarLinea(p.id, 128)}${p.homonimos > 1 ? ` · OJO: ${p.homonimos} participantes se llaman igual, usa el id` : ''}`);
145
146
  const codigo = nuevoCodigo();
@@ -0,0 +1,53 @@
1
+ // Varios agentes por cuenta (21-10-2026, docs/spec.md «Varios agentes por cuenta»).
2
+ //
3
+ // Sin LIENZO_AGENTE el puente es el agente de siempre de la cuenta: `{uid}_agente`. Con LIENZO_AGENTE=trabajo es
4
+ // `{uid}_agente_trabajo`, un participante aparte con su nombre, y su slug queda en el índice `cuentas/{uid}` de la
5
+ // sala (hasta TOPE_AGENTES). La gramática del slug es la misma en firestore.rules y web/src/app/datos/agentes.ts.
6
+
7
+ export const RE_SLUG = /^[a-z0-9][a-z0-9-]{0,23}$/;
8
+ export const MAX_SLUG = 24;
9
+ export const TOPE_AGENTES = 5;
10
+ const SUFIJO = '_agente';
11
+
12
+ export class ErrorAgente extends Error {}
13
+
14
+ /**
15
+ * LIENZO_AGENTE tal como lo escribió la persona → slug («Claude trabajo» → «claude-trabajo», «PC» → «pc»).
16
+ * null si no hay. Lanza ErrorAgente si no queda nada usable.
17
+ */
18
+ export function slugDeAgente(entrada) {
19
+ if (entrada == null) return null;
20
+ const crudo = String(entrada).trim();
21
+ if (!crudo) return null;
22
+ const slug = crudo
23
+ .normalize('NFD').replace(/\p{M}/gu, '')
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+/, '')
27
+ .slice(0, MAX_SLUG)
28
+ .replace(/-+$/, '');
29
+ if (!RE_SLUG.test(slug)) {
30
+ throw new ErrorAgente(`LIENZO_AGENTE («${crudo.slice(0, 40)}») necesita letras o números: p. ej. personal, trabajo o pc.`);
31
+ }
32
+ return slug;
33
+ }
34
+
35
+ /** El participante del agente: `{uid}_agente` o `{uid}_agente_{slug}`. */
36
+ export const idDeAgente = (uid, slug = null) => (slug ? `${uid}${SUFIJO}_${slug}` : `${uid}${SUFIJO}`);
37
+
38
+ /** La cuenta (uid) de un participante: `{uid}`, `{uid}_agente` o `{uid}_agente_{slug}` → `{uid}`. */
39
+ export function cuentaDe(id) {
40
+ const s = String(id ?? '');
41
+ const partes = s.split(`${SUFIJO}_`);
42
+ if (partes.length === 2 && RE_SLUG.test(partes[1])) return partes[0];
43
+ return s.endsWith(SUFIJO) ? s.slice(0, -SUFIJO.length) : s;
44
+ }
45
+
46
+ /** El slug de un agente con nombre (null si es la persona o el agente de siempre). */
47
+ export function slugDe(id) {
48
+ const partes = String(id ?? '').split(`${SUFIJO}_`);
49
+ return partes.length === 2 && RE_SLUG.test(partes[1]) ? partes[1] : null;
50
+ }
51
+
52
+ /** ¿`id` es un agente de la cuenta `uid` (el de siempre o uno con nombre)? */
53
+ export const esAgenteDe = (id, uid) => Boolean(uid) && String(id ?? '') !== uid && cuentaDe(id) === uid;
package/lib/config.mjs CHANGED
@@ -3,6 +3,9 @@
3
3
  // LIENZO_HOME carpeta de estado (por defecto ~/.lienzo)
4
4
  // LIENZO_SALA sala a la que se une solo al primer uso
5
5
  // LIENZO_NOMBRE nombre visible del agente (por defecto «Agente de <tu primer nombre>», de tu cuenta)
6
+ // LIENZO_AGENTE cuál de tus agentes es este puente (personal, trabajo, pc…): con él, la misma cuenta puede
7
+ // tener varios agentes en una sala, cada uno con su id `{uid}_agente_{slug}`. Sin él, el
8
+ // agente de siempre (`{uid}_agente`)
6
9
  // LIENZO_CLIENTE 'claude-code' por defecto
7
10
  // LIENZO_FIREBASE_CONFIG ruta a otro firebase.config.json (por defecto: el que viaja en el paquete de npm,
8
11
  // y si no está, el de la raíz del repo)
@@ -14,6 +17,17 @@ import { readFileSync, existsSync } from 'node:fs';
14
17
  import { homedir } from 'node:os';
15
18
  import { join, dirname } from 'node:path';
16
19
  import { fileURLToPath } from 'node:url';
20
+ import { slugDeAgente, ErrorAgente } from './agentes.mjs';
21
+
22
+ /** El slug de LIENZO_AGENTE, o el error para decirlo en la primera herramienta (el puente arranca igual). */
23
+ function agenteDe(entrada) {
24
+ try {
25
+ return { agente: slugDeAgente(entrada), errorAgente: null };
26
+ } catch (e) {
27
+ if (e instanceof ErrorAgente) return { agente: null, errorAgente: e.message };
28
+ throw e;
29
+ }
30
+ }
17
31
 
18
32
  const AQUI = dirname(fileURLToPath(import.meta.url));
19
33
 
@@ -46,6 +60,7 @@ export function cargarConfig(env = process.env) {
46
60
  home,
47
61
  salaId: env.LIENZO_SALA || archivo.salaId || null,
48
62
  nombre: env.LIENZO_NOMBRE || archivo.nombre || null,
63
+ ...agenteDe(env.LIENZO_AGENTE || archivo.agente || null),
49
64
  cliente: env.LIENZO_CLIENTE || archivo.cliente || 'claude-code',
50
65
  apiKey: firebase.apiKey,
51
66
  projectId,
package/lib/firestore.mjs CHANGED
@@ -114,14 +114,19 @@ export class Firestore {
114
114
  /**
115
115
  * Arma una escritura para :commit (para poder usar la hora del servidor).
116
116
  * opciones: { crear: true } exige que no exista; { existe: true } exige que exista;
117
- * mascara: [campos] actualiza solo esos; horaServidor: [campos] = REQUEST_TIME.
117
+ * mascara: [campos] actualiza solo esos; horaServidor: [campos] = REQUEST_TIME;
118
+ * agregar / quitar: { campo: [valores] } = appendMissingElements / removeAllFromArray (atómico en el servidor:
119
+ * dos puentes que agregan a la vez no se pisan).
118
120
  */
119
- escritura(ruta, datos, { crear = false, existe = false, mascara = null, horaServidor = [] } = {}) {
121
+ escritura(ruta, datos, { crear = false, existe = false, mascara = null, horaServidor = [], agregar = null, quitar = null } = {}) {
120
122
  const write = { update: { name: this.nombre(ruta), fields: aCampos(datos) } };
121
123
  if (mascara) write.updateMask = { fieldPaths: mascara.map(campoRuta) };
122
- if (horaServidor.length) {
123
- write.updateTransforms = horaServidor.map((c) => ({ fieldPath: campoRuta(c), setToServerValue: 'REQUEST_TIME' }));
124
- }
124
+ const transformaciones = [
125
+ ...horaServidor.map((c) => ({ fieldPath: campoRuta(c), setToServerValue: 'REQUEST_TIME' })),
126
+ ...Object.entries(agregar ?? {}).map(([c, v]) => ({ fieldPath: campoRuta(c), appendMissingElements: { values: v.map(aValor) } })),
127
+ ...Object.entries(quitar ?? {}).map(([c, v]) => ({ fieldPath: campoRuta(c), removeAllFromArray: { values: v.map(aValor) } })),
128
+ ];
129
+ if (transformaciones.length) write.updateTransforms = transformaciones;
125
130
  if (crear) write.currentDocument = { exists: false };
126
131
  else if (existe) write.currentDocument = { exists: true };
127
132
  return write;
package/lib/lienzo.mjs CHANGED
@@ -9,6 +9,7 @@ import { envolver, limpiarLinea, neutralizar, abreviarId, aviso, nuevoCodigo } f
9
9
  import { rutaCompartible, revisarNombre, ErrorArchivo } from './archivos.mjs';
10
10
  import { INTERVALO_MS } from './esperar.mjs';
11
11
  import { cerrarTimbre } from './escucha.mjs';
12
+ import { idDeAgente, cuentaDe, esAgenteDe, TOPE_AGENTES } from './agentes.mjs';
12
13
 
13
14
  export const MAX_TEXTO = 20000;
14
15
  export const MAX_ARCHIVO_BYTES = 200 * 1024;
@@ -29,10 +30,13 @@ export function nombreDeCuenta(cuenta) {
29
30
  return local || null;
30
31
  }
31
32
 
32
- /** «Agente de <primer nombre>» (o null si la cuenta no tiene nombre ni correo). */
33
- export function nombreAgentePorDefecto(cuenta) {
33
+ /**
34
+ * «Agente de <primer nombre>» (o null si la cuenta no tiene nombre ni correo). Con un agente con nombre
35
+ * (LIENZO_AGENTE), «Agente de <primer nombre> · <slug>»: así tres agentes de la misma cuenta no se llaman igual.
36
+ */
37
+ export function nombreAgentePorDefecto(cuenta, slug = null) {
34
38
  const n = nombreDeCuenta(cuenta);
35
- return n ? limpiarLinea(`Agente de ${n.split(/\s+/)[0]}`, 80) : null;
39
+ return n ? limpiarLinea(`Agente de ${n.split(/\s+/)[0]}${slug ? ` · ${slug}` : ''}`, 80) : null;
36
40
  }
37
41
 
38
42
  // ── listas de puntos (docs/producto.md §4) ──────────────────────────────────
@@ -195,14 +199,26 @@ export class Lienzo {
195
199
  /** La cuenta (la persona). */
196
200
  get uid() { return this.sesion.uid; }
197
201
 
198
- /** El participante de este agente en la sala: `{uid}_agente`. */
199
- get participanteId() { return this.uid ? `${this.uid}_agente` : null; }
202
+ /** El slug de este puente (LIENZO_AGENTE), o null: el agente de siempre. */
203
+ get slug() { return this.config.agente ?? null; }
204
+
205
+ /** El participante de este agente en la sala: `{uid}_agente` o, con LIENZO_AGENTE, `{uid}_agente_{slug}`. */
206
+ get participanteId() { return this.uid ? idDeAgente(this.uid, this.slug) : null; }
200
207
 
201
- /** ¿Este participante es de mi cuenta (mi persona o yo, su agente)? Lo mismo que esMio() en las reglas. */
208
+ /**
209
+ * ¿Esto es para mí? Mi persona o yo, ESTE agente. Más estrecho que esMio() de las reglas (que mira la cuenta
210
+ * entera): una tarea para otro agente de mi misma cuenta («Claude trabajo») no es de «Claude personal», aunque las
211
+ * reglas lo dejarían tocarla.
212
+ */
202
213
  esMio(id) {
203
214
  return Boolean(id) && (id === this.uid || id === this.participanteId);
204
215
  }
205
216
 
217
+ /** ¿Es otro agente de mi misma cuenta (no yo)? */
218
+ esOtroAgenteMio(id) {
219
+ return Boolean(id) && id !== this.participanteId && esAgenteDe(id, this.uid);
220
+ }
221
+
206
222
  link(salaId = this.salaId) {
207
223
  return `${this.config.webUrl}/sala/${salaId}`;
208
224
  }
@@ -231,7 +247,13 @@ export class Lienzo {
231
247
  return e;
232
248
  }
233
249
 
250
+ /** LIENZO_AGENTE que no deja un slug: el puente arranca igual y cada herramienta lo dice (no «MCP failed»). */
251
+ _exigirAgenteValido() {
252
+ if (this.config.errorAgente) throw new ErrorLienzo(this.config.errorAgente);
253
+ }
254
+
234
255
  async _asegurarSala() {
256
+ this._exigirAgenteValido();
235
257
  if (this.salaId && this.yo) {
236
258
  // otra ventana con el mismo LIENZO_HOME pudo conectar otra cuenta: token() recarga sesion.json
237
259
  await this.sesion.token().catch((e) => { throw this._traducir(e, 'iniciar sesión'); });
@@ -392,6 +414,7 @@ export class Lienzo {
392
414
  * nombre: el que se pase, LIENZO_NOMBRE o «Agente de <primer nombre de la cuenta>».
393
415
  */
394
416
  async unirse({ salaId, nombre }) {
417
+ this._exigirAgenteValido();
395
418
  salaId = String(salaId || '').trim();
396
419
  // aceptar también el link completo de la web
397
420
  const m = /\/sala\/([A-Za-z0-9_-]{22})/.exec(salaId);
@@ -401,7 +424,7 @@ export class Lienzo {
401
424
  await this.sesion.token().catch((e) => { throw this._traducir(e, 'iniciar sesión'); });
402
425
  const cuenta = this.sesion.cuenta;
403
426
  const dueño = nombreDeCuenta(cuenta);
404
- nombre = String(nombre || this.config.nombre || nombreAgentePorDefecto(cuenta) || '').trim();
427
+ nombre = String(nombre || this.config.nombre || nombreAgentePorDefecto(cuenta, this.slug) || '').trim();
405
428
  if (!nombre) throw new ErrorLienzo('Falta tu nombre en la sala (p. ej. «Agente de Ana»): pásalo en nombre.');
406
429
  if (nombre.length > 80) throw new ErrorLienzo('El nombre admite hasta 80 caracteres.');
407
430
  if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(nombre)) throw new ErrorLienzo('El nombre va en una sola línea, sin caracteres de control ni invisibles.');
@@ -423,14 +446,23 @@ export class Lienzo {
423
446
  };
424
447
  let commit;
425
448
  try {
426
- if (previo) {
427
- datos.conectadoEn = previo.conectadoEn ? marcaDeTiempo(previo.conectadoEn) : null;
428
- commit = await this.fs.escribir(ruta, datos, { horaServidor: previo.conectadoEn ? ['vistoEn'] : ['vistoEn', 'conectadoEn'] });
429
- } else {
430
- commit = await this.fs.escribir(ruta, datos, { horaServidor: ['conectadoEn', 'vistoEn'] });
449
+ if (previo) datos.conectadoEn = previo.conectadoEn ? marcaDeTiempo(previo.conectadoEn) : null;
450
+ const escrituras = [{
451
+ ruta, datos, opciones: { horaServidor: previo?.conectadoEn ? ['vistoEn'] : ['vistoEn', 'conectadoEn'] },
452
+ }];
453
+ // Un agente con nombre entra en el MISMO :commit que su slug en el índice de la cuenta (las reglas lo exigen:
454
+ // así se cumple el tope y el índice nunca lista a alguien que no está). appendMissingElements: tres puentes
455
+ // de la misma cuenta que entran a la vez no se pisan el índice.
456
+ if (this.slug) {
457
+ escrituras.push({
458
+ ruta: `salas/${salaId}/cuentas/${this.uid}`, datos: {}, opciones: { mascara: [], agregar: { agentes: [this.slug] } },
459
+ });
431
460
  }
461
+ commit = await this.fs.escribirVarios(escrituras);
432
462
  } catch (e) {
433
- const texto = negadoDe(e) ? await this.explicarNegado(salaId, { alUnirse: true }).catch(() => null) : null;
463
+ const texto = negadoDe(e)
464
+ ? (await this.explicarNegado(salaId, { alUnirse: true }).catch(() => null)) ?? (await this._explicarTope(salaId).catch(() => null))
465
+ : null;
434
466
  throw texto ? new ErrorLienzo(texto, { cause: e }) : this._traducir(e, 'unirte a la sala');
435
467
  }
436
468
 
@@ -456,6 +488,16 @@ export class Lienzo {
456
488
  return { salaId, sala, link: this.link(salaId), yo: this.yo };
457
489
  }
458
490
 
491
+ /** Un agente con nombre que no entra: ¿es por el tope? (solo se mira cuando ya falló) */
492
+ async _explicarTope(salaId) {
493
+ if (!this.slug) return null;
494
+ const idx = await this.fs.leer(`salas/${salaId}/cuentas/${this.uid}`);
495
+ const agentes = Array.isArray(idx?.agentes) ? idx.agentes : [];
496
+ if (agentes.length < TOPE_AGENTES || agentes.includes(this.slug)) return null;
497
+ return `Tu cuenta ya tiene ${agentes.length} agentes con nombre en esta sala (${agentes.map((a) => limpiarLinea(a, 24)).join(', ')}), ` +
498
+ `que es el tope. Usa uno de esos en LIENZO_AGENTE, o sal de la sala desde la web y vuelve a entrar con los que necesites.`;
499
+ }
500
+
459
501
  // ── cuenta ─────────────────────────────────────────────────────────────────
460
502
  /** Olvida la sala (la sesión pasó a otra cuenta: lo de la sala era de la anterior). */
461
503
  _olvidarSala() {
@@ -498,7 +540,7 @@ export class Lienzo {
498
540
  * mensaje original). Solo corre cuando algo ya falló: el camino feliz no hace ninguna lectura de más.
499
541
  * 1. estás en `expulsados` → te quitaron (borra tu índice, como la web)
500
542
  * 2. la sala tiene `borradaEn` → se borró
501
- * 3. tu participante `{uid}_agente` no existe o no se deja leer → saliste (no aplica al unirse: ahí no eres miembro todavía)
543
+ * 3. tu participante (`{uid}_agente` o `{uid}_agente_{slug}`) no existe o no se deja leer → saliste (no aplica al unirse: ahí no eres miembro todavía)
502
544
  * 4. sigues siendo miembro → null
503
545
  * Con 1 a 3 se olvida la sala.
504
546
  */
@@ -578,6 +620,7 @@ export class Lienzo {
578
620
  conectado: p.vistoEn ? ahora - Date.parse(p.vistoEn) < CONECTADO_MS : false,
579
621
  soyYo: p.id === this.participanteId,
580
622
  miPersona: p.id === this.uid,
623
+ otroAgenteMio: this.esOtroAgenteMio(p.id),
581
624
  homonimos: this._homonimos(p.id),
582
625
  }));
583
626
  }
@@ -729,6 +772,12 @@ export class Lienzo {
729
772
  if (!soyDestinatario && !soyAutor) {
730
773
  throw new ErrorLienzo(`${que}: solo su destinatario o quien la pidió pueden cambiar su estado.`);
731
774
  }
775
+ // Una tarea para mi persona es de cualquiera de sus agentes, pero la atiende el primero que la toma: otro agente
776
+ // de la misma cuenta no la retoma ni responde por él (su respuesta saldría firmada por el que la tomó).
777
+ if (soyDestinatario && t.tomadaPor && this.esOtroAgenteMio(t.tomadaPor) && !CERRADOS.has(t.estado)) {
778
+ const quien = limpiarLinea(this.participantes.get(t.tomadaPor)?.nombre ?? abreviarId(t.tomadaPor));
779
+ throw new ErrorLienzo(`${que}: ya la tomó ${quien}, otro agente de tu usuario. Déjasela a él (o que la reabra).`);
780
+ }
732
781
  const cambios = { estado };
733
782
  const mascara = ['estado'];
734
783
  const tomadaPor = (v) => { cambios.tomadaPor = v; mascara.push('tomadaPor'); };
@@ -973,7 +1022,9 @@ export class Lienzo {
973
1022
  async _describir(id) {
974
1023
  if (id === this.participanteId) return 'ti';
975
1024
  const p = await this._autor(id);
976
- return `${limpiarLinea(p.nombre)} (id: ${abreviarId(id)}${p.homonimos > 1 ? `; OJO: ${p.homonimos} se llaman igual` : ''})`;
1025
+ // otro agente de tu misma cuenta: no eres tú, aunque las reglas te dejarían tocar lo suyo
1026
+ const mio = this.esOtroAgenteMio(id) ? 'otro agente de tu usuario, ' : '';
1027
+ return `${limpiarLinea(p.nombre)} (${mio}id: ${abreviarId(id)}${p.homonimos > 1 ? `; OJO: ${p.homonimos} se llaman igual` : ''})`;
977
1028
  }
978
1029
 
979
1030
  /**
@@ -1079,9 +1130,9 @@ export class Lienzo {
1079
1130
  const recortar = (t, n, nota = '') => { const u = String(t ?? ''); return u.length > n ? `${u.slice(0, Math.max(0, n - 1))}…${nota}` : u; };
1080
1131
  const contexto = recortar(String(padre.texto ?? '').split('\n').slice(1).join('\n').trim(), MAX_CONTEXTO_LISTA, ' (recortado)');
1081
1132
  const dest = padre.paraId ? await this._autor(padre.paraId) : null;
1082
- // Sin `dueño`, la persona de su cuenta: las reglas atan `{uid}_agente` a `uid` (solo entonces se lee).
1083
- const sinDueño = dest?.tipo === 'agente' && !String(dest['dueño'] ?? '').trim() && String(dest.id ?? '').endsWith('_agente');
1084
- const persona = sinDueño ? await this._autor(dest.id.slice(0, -'_agente'.length)) : null;
1133
+ // Sin `dueño`, la persona de su cuenta: las reglas atan `{uid}_agente` (y `{uid}_agente_{slug}`) a `uid`.
1134
+ const sinDueño = dest?.tipo === 'agente' && !String(dest['dueño'] ?? '').trim() && cuentaDe(dest.id) !== String(dest.id ?? '');
1135
+ const persona = sinDueño ? await this._autor(cuentaDe(dest.id)) : null;
1085
1136
  const quienEspera = dest ? quienEsperaLista(dest, persona) : null;
1086
1137
  const resumen = `lista: ${resumenLista(puntos.map((p) => p.estado), this.esMio(padre.paraId) ? 'tu usuario' : quienEspera)}`;
1087
1138
  const numero = (p) => numeroPunto(p.id) ?? '?';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ajuarezso/lienzo-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Servidor MCP de Lienzo: une tu agente (Claude Code u otro cliente MCP) a una sala compartida con los agentes de otras personas. / MCP server that joins your agent to a room shared with other people's agents.",
5
5
  "keywords": [
6
6
  "mcp",