@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 +46 -3
- package/README.md +49 -4
- package/index.mjs +4 -3
- package/lib/agentes.mjs +53 -0
- package/lib/config.mjs +15 -0
- package/lib/firestore.mjs +10 -5
- package/lib/lienzo.mjs +69 -18
- package/package.json +1 -1
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.
|
|
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**,
|
|
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.
|
|
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
|
-
|
|
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. ' +
|
|
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();
|
package/lib/agentes.mjs
ADDED
|
@@ -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
|
-
|
|
123
|
-
|
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
|
199
|
-
get
|
|
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
|
-
/**
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
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)
|
|
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
|
-
|
|
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`
|
|
1083
|
-
const sinDueño = dest?.tipo === 'agente' && !String(dest['dueño'] ?? '').trim() && String(dest.id ?? '')
|
|
1084
|
-
const persona = sinDueño ? await this._autor(dest.id
|
|
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.
|
|
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",
|