@ajuarezso/lienzo-mcp 0.4.0 → 0.6.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 +139 -35
- package/README.md +156 -40
- package/index.mjs +133 -22
- package/lib/canal.mjs +13 -8
- package/lib/config.mjs +14 -5
- package/lib/envoltorio.mjs +23 -0
- package/lib/instalacion.mjs +322 -0
- package/lib/lienzo.mjs +425 -49
- package/lib/menciones.mjs +200 -0
- package/lib/salir.mjs +83 -0
- package/lib/secretos.mjs +5 -0
- package/lib/sesion.mjs +77 -22
- package/lib/version.mjs +48 -0
- package/package.json +1 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Menciones y tope de turnos entre agentes (F2, docs/rediseno-chat-lienzo.md §6).
|
|
2
|
+
//
|
|
3
|
+
// · `menciones` es un campo opcional de la tarjeta: lista de ids de participantes (hasta MAX_MENCIONES). El puente
|
|
4
|
+
// acepta ids o nombres y los resuelve contra la sala (un nombre repetido no se adivina). «@todos» = sin menciones.
|
|
5
|
+
// · Convención: un agente contesta en la sala solo si lo mencionan o si la tarjeta es para él (paraId).
|
|
6
|
+
// · Tope: un TURNO es una tarjeta de un agente DIRIGIDA a otro agente (paraId, menciones o respondeA a una tarjeta de
|
|
7
|
+
// otro agente) que sigue a la de OTRO agente; la respuesta a una tarea por lienzo_tarea (tomadaPor = otro agente)
|
|
8
|
+
// cuenta como turno de quien responde aunque no cree tarjeta. Después de TOPE_TURNOS turnos sin una persona en
|
|
9
|
+
// medio, el puente no publica nada dirigido a otro agente, y cada agente tiene UNA tarjeta más (a toda la sala o a
|
|
10
|
+
// una persona) para avisar que esperan a alguien. Lo que no va a otro agente no suma: varios agentes que publican
|
|
11
|
+
// avances para sus personas no se frenan entre sí. Una lista de puntos cuenta como una sola tarjeta.
|
|
12
|
+
// Las reglas de Firestore no pueden contarlo (no hay consultas entre documentos): es convención del puente.
|
|
13
|
+
import { cuentaDe } from './agentes.mjs';
|
|
14
|
+
|
|
15
|
+
export const MAX_MENCIONES = 10;
|
|
16
|
+
export const TOPE_TURNOS = 6;
|
|
17
|
+
/**
|
|
18
|
+
* El puente lee las tarjetas recientes de a PAGINA_TURNOS, de la más nueva a la más vieja, hasta dar con una de una
|
|
19
|
+
* persona (lo anterior no cuenta) o hasta PAGINAS_TURNOS páginas. Si en VENTANA_TURNOS tarjetas no escribió ninguna
|
|
20
|
+
* persona y hay más viejas, se trata como tope (`sinPersona`): así llenar la sala de tarjetas (una lista de 30 puntos)
|
|
21
|
+
* no reinicia la cuenta. Lo normal es una sola lectura de PAGINA_TURNOS.
|
|
22
|
+
*/
|
|
23
|
+
export const PAGINA_TURNOS = 16;
|
|
24
|
+
export const PAGINAS_TURNOS = 3;
|
|
25
|
+
export const VENTANA_TURNOS = PAGINA_TURNOS * PAGINAS_TURNOS;
|
|
26
|
+
|
|
27
|
+
/** Palabras que significan «toda la sala» (no son una mención). */
|
|
28
|
+
const TODOS = new Set(['todos', 'todas', 'all', 'everyone', 'sala']);
|
|
29
|
+
|
|
30
|
+
/** ¿El participante es una persona? `{uid}` sí; `{uid}_agente` y `{uid}_agente_{slug}` no. */
|
|
31
|
+
export const esPersona = (id) => Boolean(id) && cuentaDe(id) === String(id);
|
|
32
|
+
|
|
33
|
+
/** ¿Es un agente? (cualquier participante que no es una persona) */
|
|
34
|
+
export const esAgente = (id) => Boolean(id) && !esPersona(id);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Las menciones tal como las pasó el agente → referencias limpias (sin «@», sin repetidos, sin «todos»).
|
|
38
|
+
* Lanza Error con el texto para el agente si no es una lista o trae más de MAX_MENCIONES.
|
|
39
|
+
*/
|
|
40
|
+
export function referenciasDeMenciones(menciones) {
|
|
41
|
+
if (menciones == null) return [];
|
|
42
|
+
if (!Array.isArray(menciones)) throw new Error('menciones es una lista de ids o nombres de participantes.');
|
|
43
|
+
const refs = [];
|
|
44
|
+
const vistas = new Set();
|
|
45
|
+
for (const m of menciones) {
|
|
46
|
+
const r = String(m ?? '').trim().replace(/^@+/, '').trim();
|
|
47
|
+
if (!r) continue;
|
|
48
|
+
if (TODOS.has(r.toLowerCase())) continue;
|
|
49
|
+
const clave = r.toLowerCase();
|
|
50
|
+
if (vistas.has(clave)) continue;
|
|
51
|
+
vistas.add(clave);
|
|
52
|
+
refs.push(r);
|
|
53
|
+
}
|
|
54
|
+
if (refs.length > MAX_MENCIONES) {
|
|
55
|
+
throw new Error(`Una tarjeta menciona hasta ${MAX_MENCIONES} participantes (trae ${refs.length}). Para toda la sala, publica sin menciones.`);
|
|
56
|
+
}
|
|
57
|
+
return refs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Las menciones guardadas en una tarjeta (lo que venga: solo strings, sin repetidos, a lo sumo MAX_MENCIONES). */
|
|
61
|
+
export function mencionesDe(t) {
|
|
62
|
+
const m = t?.menciones;
|
|
63
|
+
if (!Array.isArray(m)) return [];
|
|
64
|
+
return [...new Set(m.filter((x) => typeof x === 'string' && x))].slice(0, MAX_MENCIONES);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** ¿La tarjeta menciona a `id`? */
|
|
68
|
+
export const menciona = (t, id) => Boolean(id) && mencionesDe(t).includes(id);
|
|
69
|
+
|
|
70
|
+
// ── turnos ─────────────────────────────────────────────────────────────────
|
|
71
|
+
// La misma lógica vive en la web (web/src/app/sala/chat/turnos.ts) para la fila «los agentes van N turnos»: un
|
|
72
|
+
// cambio acá va también allá, con sus tests.
|
|
73
|
+
|
|
74
|
+
const RE_PUNTO = /^(.+)-p\d{2}$/;
|
|
75
|
+
|
|
76
|
+
/** ¿Es un punto de una lista (id «<lista>-pNN» que responde a la lista)? Cuenta junto con su lista, no aparte. */
|
|
77
|
+
export const esPuntoDeLista = (t) => Boolean(t?.respondeA) && RE_PUNTO.exec(String(t.id ?? ''))?.[1] === t.respondeA;
|
|
78
|
+
|
|
79
|
+
/** A quiénes va una tarjeta: paraId, menciones y el autor de la tarjeta a la que responde (si se conoce). */
|
|
80
|
+
export function destinosDe(t, autorDe = () => null) {
|
|
81
|
+
const d = [t?.paraId, ...mencionesDe(t)];
|
|
82
|
+
if (t?.respondeA) d.push(autorDe(t.respondeA));
|
|
83
|
+
return [...new Set(d.filter((x) => typeof x === 'string' && x))];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** ¿Algún destino es un agente que no es quien publica? */
|
|
87
|
+
export const vaAOtroAgente = (autorId, destinos) => (destinos ?? []).some((d) => d && d !== autorId && esAgente(d));
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Lo que una tarjeta aporta a la racha, en orden: { autor, persona } o { autor, dirigido }. Un punto de lista no
|
|
91
|
+
* aporta nada (va con su lista). Una tarea de un agente que tomó o respondió OTRO agente suma después la respuesta
|
|
92
|
+
* de ese agente (lienzo_tarea no crea tarjeta, pero es contestarle al que pidió).
|
|
93
|
+
*/
|
|
94
|
+
export function eventosDe(t, autorDe = () => null) {
|
|
95
|
+
if (!t?.autorId || esPuntoDeLista(t)) return [];
|
|
96
|
+
if (esPersona(t.autorId)) return [{ autor: t.autorId, persona: true }];
|
|
97
|
+
const eventos = [{ autor: t.autorId, dirigido: vaAOtroAgente(t.autorId, destinosDe(t, autorDe)) }];
|
|
98
|
+
if (t.tipo === 'tarea' && esAgente(t.tomadaPor) && t.tomadaPor !== t.autorId) eventos.push({ autor: t.tomadaPor, dirigido: true });
|
|
99
|
+
return eventos;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Racha: turnos agente↔agente desde la última persona, el autor del último paso dirigido a un agente, los agentes
|
|
104
|
+
* que ya usaron su tarjeta de salida después del tope y si la cuenta es por no haber visto a ninguna persona.
|
|
105
|
+
*/
|
|
106
|
+
export const RACHA_VACIA = Object.freeze({ turnos: 0, ultimo: null, salidas: Object.freeze([]), sinPersona: false });
|
|
107
|
+
|
|
108
|
+
/** Suma un evento (eventosDe) a la racha. */
|
|
109
|
+
export function sumarEvento(r, e) {
|
|
110
|
+
if (!e?.autor) return r;
|
|
111
|
+
if (e.persona) return { ...RACHA_VACIA, ultimo: e.autor };
|
|
112
|
+
if (!e.dirigido) {
|
|
113
|
+
// antes del tope no cuenta; después, es la única tarjeta que le queda a ese agente hasta que escriba una persona
|
|
114
|
+
return r.turnos >= TOPE_TURNOS && !r.salidas.includes(e.autor) ? { ...r, salidas: [...r.salidas, e.autor] } : r;
|
|
115
|
+
}
|
|
116
|
+
if (e.autor === r.ultimo) return r; // el mismo agente sigue: no es un turno nuevo
|
|
117
|
+
return { ...r, turnos: r.ultimo && esAgente(r.ultimo) ? r.turnos + 1 : r.turnos, ultimo: e.autor };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Suma una tarjeta a la racha (en orden de creación). */
|
|
121
|
+
export const sumarTarjeta = (r, t, autorDe) => eventosDe(t, autorDe).reduce(sumarEvento, r);
|
|
122
|
+
|
|
123
|
+
/** La racha de una lista de tarjetas en orden de creación (de la más vieja a la más nueva). */
|
|
124
|
+
export function rachaDe(tarjetas, inicio = RACHA_VACIA) {
|
|
125
|
+
const autorDe = autoresDe(tarjetas);
|
|
126
|
+
return (tarjetas ?? []).reduce((r, t) => sumarTarjeta(r, t, autorDe), inicio);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const autoresDe = (tarjetas) => {
|
|
130
|
+
const porId = new Map((tarjetas ?? []).filter((t) => t?.id).map((t) => [t.id, t.autorId]));
|
|
131
|
+
return (id) => porId.get(id) ?? null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* La racha de las tarjetas recientes tal como las devuelve la consulta (de la más NUEVA a la más vieja). Cuenta solo
|
|
136
|
+
* desde la última tarjeta de una persona. `agotada` = no hay tarjetas más viejas que estas. Sin persona a la vista y
|
|
137
|
+
* con más tarjetas detrás, la racha arranca en el tope (`sinPersona`): nadie mira desde hace VENTANA_TURNOS tarjetas.
|
|
138
|
+
*/
|
|
139
|
+
export function rachaDeRecientes(nuevasPrimero, { agotada = true } = {}) {
|
|
140
|
+
const lista = nuevasPrimero ?? [];
|
|
141
|
+
const autorDe = autoresDe(lista);
|
|
142
|
+
const tramo = [];
|
|
143
|
+
let persona = null;
|
|
144
|
+
for (const t of lista) {
|
|
145
|
+
if (t?.autorId && esPersona(t.autorId)) { persona = t.autorId; break; }
|
|
146
|
+
tramo.push(t);
|
|
147
|
+
}
|
|
148
|
+
let inicio = RACHA_VACIA;
|
|
149
|
+
if (persona) inicio = { ...RACHA_VACIA, ultimo: persona };
|
|
150
|
+
else if (!agotada) inicio = { ...RACHA_VACIA, turnos: TOPE_TURNOS, sinPersona: true };
|
|
151
|
+
return tramo.reverse().reduce((r, t) => sumarTarjeta(r, t, autorDe), inicio);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* ¿Publicar ahora pasaría el tope? Con TOPE_TURNOS turnos (o sin persona a la vista): nada dirigido a otro agente;
|
|
156
|
+
* lo demás (a toda la sala o a personas) pasa una vez por agente, para llamar a alguien. Sin persona a la vista la
|
|
157
|
+
* salida no se acaba: la sala solo tiene agentes y lo que no va a otro agente no gasta a nadie.
|
|
158
|
+
* @param {{ turnos: number, salidas: string[], sinPersona?: boolean }} racha
|
|
159
|
+
* @param {string} yo el participante que publica
|
|
160
|
+
* @param {boolean} dirigido ¿lo que publica va a otro agente? (vaAOtroAgente)
|
|
161
|
+
*/
|
|
162
|
+
export function pasaElTope(racha, yo, dirigido) {
|
|
163
|
+
if (!racha || racha.turnos < TOPE_TURNOS) return false;
|
|
164
|
+
if (dirigido) return true;
|
|
165
|
+
if (racha.sinPersona) return false;
|
|
166
|
+
return (racha.salidas ?? []).includes(yo);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const porQue = (racha) => (racha.sinPersona
|
|
170
|
+
? `en las últimas ${VENTANA_TURNOS} tarjetas de la sala no escribió ninguna persona`
|
|
171
|
+
: `los agentes ya llevan ${racha.turnos} turnos seguidos contestándose sin que escriba una persona (el tope de Lienzo es ${TOPE_TURNOS})`);
|
|
172
|
+
|
|
173
|
+
/** El mensaje con el que el puente rechaza la publicación que pasaría el tope. */
|
|
174
|
+
export function textoTope(racha, dirigido = true) {
|
|
175
|
+
if (dirigido) {
|
|
176
|
+
return `No publiqué: ${porQue(racha)}, y esto va a otro agente (paraId, menciones o respondeA). Avísale a tu ` +
|
|
177
|
+
'usuario qué quedó pendiente y espera a que una persona escriba en la sala (lienzo_esperar). Si necesitas que ' +
|
|
178
|
+
'alguien decida, publica UNA tarjeta para una persona (paraId o menciones con el id de una persona) o para toda la sala.';
|
|
179
|
+
}
|
|
180
|
+
return `No publiqué: ${porQue(racha)}, y ya usaste tu tarjeta para avisar. Hasta que escriba una persona no ` +
|
|
181
|
+
'publicas más en esta sala: cuéntale a tu usuario qué quedó pendiente y espera (lienzo_esperar).';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** La línea que el puente agrega a lo que entrega cuando la sala llegó al tope (null si no). */
|
|
185
|
+
export function lineaTope(racha) {
|
|
186
|
+
if (!racha || racha.turnos < TOPE_TURNOS) return null;
|
|
187
|
+
return `Turnos entre agentes: ${racha.sinPersona ? `ninguna persona escribió en las últimas ${VENTANA_TURNOS} tarjetas` : `${racha.turnos} seguidos sin una persona en medio`} ` +
|
|
188
|
+
`(tope ${TOPE_TURNOS}). No le escribas a otro agente hasta que escriba una persona: el puente no lo publica. Avísale ` +
|
|
189
|
+
'a tu usuario; si hace falta que alguien decida, tienes UNA tarjeta para una persona o para la sala.';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Texto de la convención, para las instructions del servidor, la skill y los prompts. */
|
|
193
|
+
export const CONVENCION =
|
|
194
|
+
'CUÁNDO CONTESTAR EN LA SALA: contesta solo si te mencionan (te lo dice «te mencionan») o si la tarjeta es para ti ' +
|
|
195
|
+
'(para: ti). Lo que va a toda la sala o a otros, léelo y cuéntaselo a tu usuario si le sirve, sin contestar. Para ' +
|
|
196
|
+
'nombrar a alguien usa menciones en lienzo_publicar (ids o nombres; sin menciones = toda la sala). Tope: después de ' +
|
|
197
|
+
`${TOPE_TURNOS} turnos seguidos entre agentes sin que escriba una persona (pedir una tarea a otro agente y que te ` +
|
|
198
|
+
'la responda también son turnos), el puente no publica nada dirigido a otro agente y te deja UNA tarjeta más, para ' +
|
|
199
|
+
'una persona o para la sala, para avisar que esperan a alguien. Así dos agentes no gastan las cuentas de sus ' +
|
|
200
|
+
'personas sin que nadie mire.';
|
package/lib/salir.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// lienzo_salir: cierra la sesión de la cuenta EN ESTE EQUIPO (21-10-2026, squad conectar; como «Salir» en Pixel Perfect).
|
|
2
|
+
//
|
|
3
|
+
// Borra la entrada de este proyecto en <LIENZO_HOME>/sesion.json (las de otros proyectos, como el emulador, se quedan)
|
|
4
|
+
// bajo el mismo candado que usan las escrituras de la sesión, y olvida la cuenta en memoria. No toca agente.json: la
|
|
5
|
+
// identidad de la instalación (su slug) no es la cuenta, y al volver a entrar con la misma cuenta sigues siendo el
|
|
6
|
+
// mismo agente en tus salas. No te saca de ninguna sala (eso se hace desde la web, en «Tus agentes»).
|
|
7
|
+
//
|
|
8
|
+
// Lo que NO puede hacer: revocar el refresh token en el servidor. Firebase no lo permite por REST sin una cuenta de
|
|
9
|
+
// servicio (haría falta una función con revokeRefreshTokens de Admin), y cerrar la sesión de Google NO lo invalida:
|
|
10
|
+
// por eso el texto no lo sugiere. El token borrado deja de existir en este equipo, que es lo que importa en una
|
|
11
|
+
// máquina compartida. Las
|
|
12
|
+
// otras ventanas que usan el mismo LIENZO_HOME lo notan al renovar su token (Sesion.token: la entrada ya no está).
|
|
13
|
+
import { existsSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { cerrarTimbre } from './escucha.mjs';
|
|
16
|
+
|
|
17
|
+
/** El texto que ve el agente (y le pasa a su usuario) después de salir. */
|
|
18
|
+
export function textoSalida(cuenta) {
|
|
19
|
+
const quien = cuenta?.email || cuenta?.nombre;
|
|
20
|
+
return `${quien ? `Saliste de tu cuenta (${quien}) en este equipo` : 'Saliste de tu cuenta en este equipo'}: ` +
|
|
21
|
+
'se borró la sesión guardada en tu carpeta de Lienzo. Tu agente ya no puede leer ni publicar hasta que vuelvas a ' +
|
|
22
|
+
'usar lienzo_entrar. No te sacó de ninguna sala: para eso, la web (tu cuenta › Tus agentes). Lienzo todavía no ' +
|
|
23
|
+
'revoca sesiones en el servidor: una copia de ese archivo seguiría sirviendo en otra máquina, y cerrar tu sesión ' +
|
|
24
|
+
'de Google no la corta.';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const TEXTO_SIN_SESION = 'No había ninguna cuenta conectada en este equipo: no hay nada que cerrar.';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Cierra la sesión de `sesion` (lib/sesion.mjs) en este equipo. Devuelve la cuenta que estaba conectada, o null si
|
|
31
|
+
* no había ninguna. Con el candado tomado: otra ventana no puede refrescar y volver a escribir la entrada a la vez.
|
|
32
|
+
*/
|
|
33
|
+
export async function salirDeLaCuenta(sesion) {
|
|
34
|
+
return sesion._conCandado(async () => {
|
|
35
|
+
sesion._cargar(); // otra ventana pudo conectar la cuenta después de que arrancó este proceso
|
|
36
|
+
const cuenta = sesion.cuenta;
|
|
37
|
+
const todo = sesion._leerTodo();
|
|
38
|
+
const clave = sesion._clave();
|
|
39
|
+
if (clave in todo) {
|
|
40
|
+
delete todo[clave];
|
|
41
|
+
if (Object.keys(todo).length) escribirAtomico(sesion.ruta, todo);
|
|
42
|
+
else rmSync(sesion.ruta, { force: true });
|
|
43
|
+
} else if (existsSync(sesion.ruta) && !Object.keys(todo).length) {
|
|
44
|
+
rmSync(sesion.ruta, { force: true });
|
|
45
|
+
}
|
|
46
|
+
olvidar(sesion);
|
|
47
|
+
return cuenta;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* lienzo_salir completo: cierra la sesión en este equipo y deja el puente como recién arrancado sin cuenta (sin sala,
|
|
53
|
+
* sin el timbre de la sala, sin las marcas de salas perdidas). Devuelve el texto para el agente.
|
|
54
|
+
*/
|
|
55
|
+
export async function salirDeLienzo(lienzo) {
|
|
56
|
+
const cuenta = await salirDeLaCuenta(lienzo.sesion);
|
|
57
|
+
lienzo._olvidarSala();
|
|
58
|
+
await cerrarTimbre(lienzo).catch(() => undefined);
|
|
59
|
+
lienzo.salasPerdidas.clear();
|
|
60
|
+
lienzo.ultimaPerdida = null;
|
|
61
|
+
return cuenta ? textoSalida(cuenta) : TEXTO_SIN_SESION;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** La sesión en memoria vuelve a «sin cuenta». */
|
|
65
|
+
export function olvidar(sesion) {
|
|
66
|
+
sesion.uid = null;
|
|
67
|
+
sesion.refreshToken = null;
|
|
68
|
+
sesion.idToken = null;
|
|
69
|
+
sesion.venceEn = 0;
|
|
70
|
+
sesion.nombre = null;
|
|
71
|
+
sesion.email = null;
|
|
72
|
+
sesion.proveedor = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function escribirAtomico(ruta, datos) {
|
|
76
|
+
const temporal = `${ruta}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(temporal, JSON.stringify(datos, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
79
|
+
renameSync(temporal, ruta);
|
|
80
|
+
} finally {
|
|
81
|
+
rmSync(temporal, { force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
package/lib/secretos.mjs
CHANGED
|
@@ -15,6 +15,11 @@ const PATRONES = [
|
|
|
15
15
|
{ tipo: 'token de Google OAuth', re: /\bya29\.[0-9A-Za-z_-]{20,}/g },
|
|
16
16
|
{ tipo: 'JWT (token de sesión)', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
17
17
|
{ tipo: 'URL con usuario y contraseña', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{6,}@[^\s]+/gi },
|
|
18
|
+
// El link de entrada de lienzo_entrar (puerto + nonce): si el navegador no se abre, el puente se lo da a su agente
|
|
19
|
+
// para que se lo pase a SU persona. Publicado en una sala, otro podría usarlo para dejar a este agente con su cuenta
|
|
20
|
+
// (revisión de squad/conectar): nunca sale a una sala, aunque una tarjeta lo pida.
|
|
21
|
+
{ tipo: 'link de entrada de Lienzo (lienzo_entrar)', re: /\/conectar\?(?=\S*\bpuerto=\d)(?=\S*\bestado=)\S*/g },
|
|
22
|
+
{ tipo: 'vuelta del login de Lienzo (callback local)', re: /\b(?:127\.0\.0\.1|localhost):\d+\/callback#[^\s]*/gi },
|
|
18
23
|
];
|
|
19
24
|
|
|
20
25
|
// «password = algo», «api_key: "…"», «TOKEN=…» con un valor que no es un marcador de posición.
|
package/lib/sesion.mjs
CHANGED
|
@@ -22,6 +22,11 @@ export const TIMEOUT_MS = 20 * 1000;
|
|
|
22
22
|
const CANDADO_VIEJO_MS = 30 * 1000; // un candado más viejo que esto es de un proceso que murió
|
|
23
23
|
const CANDADO_ESPERA_MS = 10 * 1000;
|
|
24
24
|
export const ENTRAR_TIMEOUT_MS = 180 * 1000;
|
|
25
|
+
// Después de usarse, vencer o cancelarse, el servidor local sigue escuchando un rato solo para limpiar la barra: si la
|
|
26
|
+
// web vuelve tarde (el link se abrió de nuevo o después de los 3 min), el refresh token llega en el #fragmento y, sin
|
|
27
|
+
// nadie que lo borre, quedaría en la barra y en el historial. En ese modo GET /callback sirve la página que borra el
|
|
28
|
+
// fragmento y dice que el enlace ya no sirve, y POST da 410 (revisión de integra-06).
|
|
29
|
+
export const GRACIA_MS = 10 * 60 * 1000;
|
|
25
30
|
const MAX_CUERPO = 64 * 1024;
|
|
26
31
|
|
|
27
32
|
/** Errores de sesión que el agente tiene que ver tal cual (no son fallas internas). */
|
|
@@ -79,25 +84,43 @@ export function mensajeDeError(codigo = '') {
|
|
|
79
84
|
return `El navegador no pudo conectar tu cuenta (${c.replace(/[^\w/.-]/g, '').slice(0, 60) || 'error'}). Vuelve a usar lienzo_entrar.`;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
87
|
+
/** El ícono de Lienzo (web/public/icono.svg), en línea: la CSP de la página no deja cargar nada de afuera. */
|
|
88
|
+
const ICONO = '<svg class="i" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" aria-hidden="true"><rect width="1024" height="1024" rx="225" fill="#fa7319"/><path transform="translate(102.40 91.14) scale(34.1333)" fill="#fff" d="M8.58579 17H3V15H21V17H15.4142L18.6569 20.2426L17.2426 21.6569L13 17.4142V20H11V17.4142L6.75736 21.6569L5.34315 20.2426L8.58579 17ZM5 3H19C19.5523 3 20 3.44772 20 4V14H4V4C4 3.44772 4.44772 3 5 3ZM6 5V12H18V5H6Z"/></svg>';
|
|
89
|
+
|
|
90
|
+
/** Textos de la página local de /callback (squad conectar: como el cierre de /entrar de Pixel Perfect). */
|
|
91
|
+
export const TEXTOS_CALLBACK = {
|
|
92
|
+
enviando: { titulo: 'Conectando tu agente…', texto: 'Un segundo: le estamos pasando tu sesión.' },
|
|
93
|
+
ok: { titulo: 'Listo. Ya puedes volver a tu terminal', texto: 'Tu agente quedó conectado a tu cuenta de Lienzo. Puedes cerrar esta pestaña.' },
|
|
94
|
+
cancelado: { titulo: 'No se conectó', texto: 'Le avisamos a tu agente. Ya puedes volver a tu terminal y cerrar esta pestaña.' },
|
|
95
|
+
error: { titulo: 'No se pudo conectar', texto: 'Vuelve a tu agente y pídele que use lienzo_entrar otra vez. Si tardaste más de 3 minutos, el enlace ya venció.' },
|
|
96
|
+
usado: { titulo: 'Este enlace ya no sirve', texto: 'Ya se usó, venció o se canceló. Si tu agente no quedó conectado, vuelve a tu terminal y pídele que use lienzo_entrar otra vez.' },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* La página que sirve GET /callback: lee el #fragmento, lo borra de la barra y lo manda por POST a sí misma. Con
|
|
101
|
+
* `cerrado` (el enlace ya se usó, venció o se canceló) solo borra el fragmento y lo dice: no manda nada.
|
|
102
|
+
*/
|
|
103
|
+
function paginaCallback(nonce, cerrado = false) {
|
|
104
|
+
const T = TEXTOS_CALLBACK;
|
|
89
105
|
return `<!doctype html><html lang="es"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Lienzo</title>
|
|
90
|
-
<style>:root{color-scheme:light dark}body{font:16px/1.5 -apple-system,system-ui,sans-serif;display:grid;place-items:center;min-height:
|
|
91
|
-
|
|
106
|
+
<style>:root{color-scheme:light dark}*{box-sizing:border-box}body{font:16px/1.5 -apple-system,system-ui,"Segoe UI",sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;padding:24px 16px;background:Canvas;color:CanvasText}
|
|
107
|
+
.c{width:100%;max-width:420px;border:1px solid color-mix(in srgb,CanvasText 14%,transparent);border-radius:20px;padding:32px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px}
|
|
108
|
+
.i{width:48px;height:48px;margin-bottom:8px}h1{font-size:20px;line-height:28px;margin:0;font-weight:600}p{margin:0;color:color-mix(in srgb,CanvasText 68%,transparent)}
|
|
109
|
+
.e{display:none;width:32px;height:32px;line-height:32px;border-radius:50%;margin-top:8px;color:#fff;font-weight:600}.ok .e,.cancelado .e,.usado .e,.error .e{display:block}.ok .e{background:#1fc16b}.ok .e::after{content:"✓"}.cancelado .e,.usado .e{background:#a3a3a3}.cancelado .e::after,.usado .e::after,.error .e::after{content:"✕"}.error .e{background:#fb3748}
|
|
110
|
+
.s{display:flex;flex-direction:column;align-items:center;gap:8px}.pie{margin-top:16px;font-size:13px;line-height:20px}</style>
|
|
111
|
+
<body><main class="c" id="c">${ICONO}<div class="s" role="status" aria-live="polite"><h1 id="t">${T.enviando.titulo}</h1><p id="m">${T.enviando.texto}</p></div><span class="e" aria-hidden="true"></span>
|
|
112
|
+
<p class="pie">Lienzo · esta página la sirve tu agente en tu propio equipo (127.0.0.1)</p></main><script nonce="${nonce}">
|
|
92
113
|
(async () => {
|
|
93
|
-
const m = document.getElementById('m'), T = ${JSON.stringify(T)};
|
|
114
|
+
const c = document.getElementById('c'), t = document.getElementById('t'), m = document.getElementById('m'), T = ${JSON.stringify(T)};
|
|
115
|
+
const poner = (k) => { c.className = 'c ' + k; t.textContent = T[k].titulo; m.textContent = T[k].texto; };
|
|
94
116
|
const h = location.hash.slice(1);
|
|
95
117
|
history.replaceState(null, '', location.pathname);
|
|
96
|
-
if (
|
|
118
|
+
if (${cerrado ? 'true' : 'false'}) { poner('usado'); return; }
|
|
119
|
+
if (!h) { poner('error'); return; }
|
|
97
120
|
try {
|
|
98
121
|
const r = await fetch('/callback', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: h });
|
|
99
|
-
|
|
100
|
-
} catch {
|
|
122
|
+
poner(r.ok ? 'ok' : /(^|&)error=cancel/.test(h) ? 'cancelado' : 'error');
|
|
123
|
+
} catch { poner('error'); }
|
|
101
124
|
})();
|
|
102
125
|
</script></body></html>`;
|
|
103
126
|
}
|
|
@@ -127,7 +150,16 @@ export function abrirNavegador(url, plataforma = process.platform) {
|
|
|
127
150
|
/**
|
|
128
151
|
* Levanta el servidor de un solo uso en 127.0.0.1, abre `{webUrl}/conectar?puerto=<n>&estado=<nonce>` y espera
|
|
129
152
|
* a que la web vuelva a /callback. Resuelve con { refreshToken }. Al vencer (ErrorSesion con codigo 'vencido'),
|
|
130
|
-
* cancelarse o resolverse,
|
|
153
|
+
* cancelarse o resolverse, deja de aceptar sesiones: sigue `graciaMs` solo para borrar el #fragmento si la web vuelve
|
|
154
|
+
* tarde (GRACIA_MS) y después se cierra. La URL lleva el nonce: va a stderr y a `abrir`; `onNoAbrio` avisa cuando el
|
|
155
|
+
* navegador no se abrió (o `abrir: null`). Quien decide si la URL llega al modelo es lib/lienzo.mjs: solo con
|
|
156
|
+
* `sinNavegador: true` explícito.
|
|
157
|
+
*
|
|
158
|
+
* Qué protege qué: lo que de verdad cierra el callback es `estado` (nonce de 32 bytes, comparado en tiempo
|
|
159
|
+
* constante). El Origin exigido en el POST solo frena a una página ajena abierta en el navegador (el navegador no la
|
|
160
|
+
* deja mentir su Origin); un proceso local lo pone como quiera (curl -H 'Origin: …'), así que contra eso no protege
|
|
161
|
+
* nada que no proteja ya el nonce. Un proceso local que conoce el nonce puede entregar la sesión que quiera: por eso
|
|
162
|
+
* el nonce no tiene que llegar a nadie más que a la persona (revisión de integra-06).
|
|
131
163
|
*
|
|
132
164
|
* @param {object} o
|
|
133
165
|
* @param {string} o.webUrl
|
|
@@ -135,13 +167,16 @@ export function abrirNavegador(url, plataforma = process.platform) {
|
|
|
135
167
|
* @param {number} [o.timeoutMs] 3 min por defecto
|
|
136
168
|
* @param {AbortSignal} [o.signal]
|
|
137
169
|
* @param {(info: { puerto: number, estado: string, url: string }) => void} [o.onListening]
|
|
170
|
+
* @param {(url: string) => void} [o.onNoAbrio] el navegador no se abrió (o `abrir` es null): hay que pasar la URL a mano
|
|
171
|
+
* @param {number} [o.graciaMs] cuánto sigue limpiando la barra después de terminar (0: se cierra ya)
|
|
138
172
|
*/
|
|
139
|
-
export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening } = {}) {
|
|
173
|
+
export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening, onNoAbrio, graciaMs = GRACIA_MS } = {}) {
|
|
140
174
|
if (!webUrl) throw new ErrorSesion('Falta webUrl en la configuración de Lienzo.');
|
|
141
175
|
if (signal?.aborted) throw new ErrorSesion('Se canceló la conexión con tu cuenta.');
|
|
142
176
|
const estado = randomBytes(32).toString('base64url');
|
|
143
177
|
let servidor;
|
|
144
178
|
let timer;
|
|
179
|
+
let cerrado = false; // ya se usó, venció o se canceló: solo limpia la barra
|
|
145
180
|
try {
|
|
146
181
|
return await new Promise((resolve, reject) => {
|
|
147
182
|
let listo = false;
|
|
@@ -161,12 +196,14 @@ export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs
|
|
|
161
196
|
...cabeceras,
|
|
162
197
|
'content-type': 'text/html; charset=utf-8',
|
|
163
198
|
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
|
164
|
-
}).end(paginaCallback(nonce));
|
|
199
|
+
}).end(paginaCallback(nonce, cerrado));
|
|
165
200
|
}
|
|
166
201
|
if (req.method !== 'POST') return res.writeHead(405, cabeceras).end();
|
|
167
|
-
|
|
202
|
+
if (cerrado) return res.writeHead(410, cabeceras).end();
|
|
203
|
+
// Solo la página que sirvió este mismo servidor (mismo origen): frena a un sitio ajeno abierto en el navegador.
|
|
204
|
+
// NO frena a un proceso local (el Origin se falsifica con curl): eso lo cierra el nonce `estado`, más abajo.
|
|
168
205
|
const origen = req.headers.origin;
|
|
169
|
-
if (origen
|
|
206
|
+
if (origen !== `http://127.0.0.1:${servidor.address().port}`) return res.writeHead(403, cabeceras).end();
|
|
170
207
|
const trozos = [];
|
|
171
208
|
let bytes = 0;
|
|
172
209
|
for await (const c of req) {
|
|
@@ -189,8 +226,13 @@ export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs
|
|
|
189
226
|
const url = `${webUrl.replace(/\/+$/, '')}/conectar?${new URLSearchParams({ puerto: String(puerto), estado })}`;
|
|
190
227
|
console.error(`[lienzo] para conectar tu cuenta abre: ${url}`);
|
|
191
228
|
onListening?.({ puerto, estado, url });
|
|
229
|
+
if (!abrir) {
|
|
230
|
+
onNoAbrio?.(url);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
192
233
|
Promise.resolve().then(() => abrir(url)).catch((e) => {
|
|
193
234
|
console.error(`[lienzo] no pude abrir el navegador (${e?.message ?? e}); ábrelo a mano: ${url}`);
|
|
235
|
+
onNoAbrio?.(url);
|
|
194
236
|
});
|
|
195
237
|
});
|
|
196
238
|
timer = setTimeout(
|
|
@@ -202,8 +244,13 @@ export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs
|
|
|
202
244
|
});
|
|
203
245
|
} finally {
|
|
204
246
|
clearTimeout(timer);
|
|
205
|
-
|
|
206
|
-
servidor?.closeAllConnections?.();
|
|
247
|
+
cerrado = true;
|
|
248
|
+
const cerrar = () => { servidor?.close(); servidor?.closeAllConnections?.(); };
|
|
249
|
+
if (servidor?.listening && graciaMs > 0) {
|
|
250
|
+
servidor.unref(); // no retiene al proceso: si el puente termina, la gracia termina con él
|
|
251
|
+
servidor.closeIdleConnections?.();
|
|
252
|
+
setTimeout(cerrar, graciaMs).unref();
|
|
253
|
+
} else cerrar();
|
|
207
254
|
}
|
|
208
255
|
}
|
|
209
256
|
|
|
@@ -406,8 +453,8 @@ export class Sesion {
|
|
|
406
453
|
}
|
|
407
454
|
|
|
408
455
|
/** Login en el navegador + adoptar. Devuelve la cuenta. Opciones: las de recibirSesion. */
|
|
409
|
-
async entrar({ abrir, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening } = {}) {
|
|
410
|
-
const { refreshToken } = await recibirSesion({ webUrl: this.config.webUrl, abrir, timeoutMs, signal, onListening });
|
|
456
|
+
async entrar({ abrir, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening, onNoAbrio } = {}) {
|
|
457
|
+
const { refreshToken } = await recibirSesion({ webUrl: this.config.webUrl, abrir, timeoutMs, signal, onListening, onNoAbrio });
|
|
411
458
|
return this.adoptar(refreshToken);
|
|
412
459
|
}
|
|
413
460
|
|
|
@@ -423,6 +470,14 @@ export class Sesion {
|
|
|
423
470
|
}
|
|
424
471
|
return this._conCandado(async () => {
|
|
425
472
|
this._cargar(); // otro proceso con el mismo LIENZO_HOME pudo crear o refrescar la sesión
|
|
473
|
+
// lienzo_salir en otra ventana (o la carpeta borrada a mano): la entrada ya no está. No se refresca ni se vuelve
|
|
474
|
+
// a escribir con lo que quedó en memoria: se cierra aquí también (lib/salir.mjs).
|
|
475
|
+
if (this.refreshToken && !(this._clave() in this._leerTodo())) {
|
|
476
|
+
console.error('[lienzo] la sesión se cerró fuera de este proceso (lienzo_salir o la carpeta borrada)');
|
|
477
|
+
for (const k of ['uid', 'refreshToken', 'idToken', 'nombre', 'email', 'proveedor']) this[k] = null;
|
|
478
|
+
this.venceEn = 0;
|
|
479
|
+
if (!this.config.pruebasAnonimas) throw new ErrorSesion(SIN_SESION);
|
|
480
|
+
}
|
|
426
481
|
if (!this.refreshToken) {
|
|
427
482
|
await this._registrarAnonima(); // solo en pruebas; si no, lanza SIN_SESION
|
|
428
483
|
return this.idToken;
|
package/lib/version.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ¿Hay una versión nueva del puente en npm? (21-10-2026, «el MCP de Figma se actualiza solo, el nuestro también debería»)
|
|
2
|
+
//
|
|
3
|
+
// Instalado con `npx -y @ajuarezso/lienzo-mcp@latest`, el puente ya se actualiza solo cada vez que el cliente lo
|
|
4
|
+
// arranca. Esto cubre lo demás (una versión fija, el plugin del repo, la extensión .mcpb de Claude Desktop, o un
|
|
5
|
+
// cliente que lleva días abierto): al arrancar se pregunta UNA vez al registro de npm, con un timeout corto y sin
|
|
6
|
+
// bloquear el arranque; si hay una más nueva, la primera respuesta de herramienta lo dice en una línea.
|
|
7
|
+
export const PAQUETE = '@ajuarezso/lienzo-mcp';
|
|
8
|
+
export const COMANDO = `claude mcp add lienzo --scope user -- npx -y ${PAQUETE}@latest`;
|
|
9
|
+
const TIMEOUT_MS = 1500;
|
|
10
|
+
|
|
11
|
+
/** «0.10.0» > «0.9.3». Solo x.y.z (una prerelease nunca se ofrece como nueva). */
|
|
12
|
+
export function esMasNueva(candidata, actual) {
|
|
13
|
+
const p = (v) => (/^\d+\.\d+\.\d+$/.test(String(v ?? '')) ? String(v).split('.').map(Number) : null);
|
|
14
|
+
const a = p(candidata);
|
|
15
|
+
const b = p(actual);
|
|
16
|
+
if (!a || !b) return false;
|
|
17
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* La versión `latest` en npm si es más nueva que `actual`; null si no, si no hay red o si tarda más de 1,5 s.
|
|
23
|
+
* Nunca lanza.
|
|
24
|
+
*/
|
|
25
|
+
export async function versionNueva(actual, { fetch: f = globalThis.fetch, timeoutMs = TIMEOUT_MS, registro = 'https://registry.npmjs.org' } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const r = await f(`${registro}/${PAQUETE.replace('/', '%2F')}/latest`, {
|
|
28
|
+
headers: { accept: 'application/json' },
|
|
29
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
30
|
+
});
|
|
31
|
+
if (!r.ok) return null;
|
|
32
|
+
const { version } = await r.json();
|
|
33
|
+
return esMasNueva(version, actual) ? version : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Para pasar de una versión fija (o del comando viejo) a la que se actualiza sola: quitar y volver a agregar. */
|
|
40
|
+
export const COMANDO_REINSTALAR = `claude mcp remove lienzo --scope user && ${COMANDO}`;
|
|
41
|
+
|
|
42
|
+
/** La línea que va al agente (una vez por proceso). */
|
|
43
|
+
export const avisoDeVersion = (nueva, actual) =>
|
|
44
|
+
`(Lienzo: hay una versión nueva del puente, ${nueva}; esta es ${actual}. Díselo a tu usuario una vez: si lo instaló con ` +
|
|
45
|
+
`@latest (o sin versión), se actualiza solo al reiniciar su cliente (en Claude Code, /mcp → lienzo → reconectar); si ` +
|
|
46
|
+
`tiene una versión fija, que lo reinstale con «${COMANDO_REINSTALAR}» y, si lo tenía con -e (LIENZO_AGENTE, ` +
|
|
47
|
+
`LIENZO_NOMBRE…), que los repita en el add, antes del --. La extensión de Claude Desktop (.mcpb) no se actualiza sola: ` +
|
|
48
|
+
`hay que bajar la nueva.)`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ajuarezso/lienzo-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|