@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.
@@ -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
- /** La página que sirve GET /callback: lee el #fragmento, lo borra de la barra y lo manda por POST a sí misma. */
83
- function paginaCallback(nonce) {
84
- const T = {
85
- enviando: 'Conectando Lienzo…',
86
- ok: 'Listo: tu agente ya está conectado a tu cuenta. Puedes cerrar esta pestaña.',
87
- error: 'No se pudo conectar. Vuelve a tu agente y pídele que use lienzo_entrar otra vez.',
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:90vh;margin:0;padding:0 16px;background:Canvas;color:CanvasText}p{max-width:28em;text-align:center}</style>
91
- <body><p id="m">${T.enviando}</p><script nonce="${nonce}">
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 (!h) { m.textContent = T.error; return; }
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
- m.textContent = r.ok ? T.ok : T.error;
100
- } catch { m.textContent = T.error; }
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, el servidor se cierra. La URL lleva el nonce: va a stderr y a `abrir`, nunca al modelo.
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
- // Solo la página que sirvió este mismo servidor (mismo origen). Un sitio ajeno no puede postear aquí.
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 && origen !== `http://127.0.0.1:${servidor.address().port}`) return res.writeHead(403, cabeceras).end();
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
- servidor?.close();
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;
@@ -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.4.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",