@ajuarezso/lienzo-mcp 0.4.0 → 0.5.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 +114 -31
- package/README.md +131 -36
- package/index.mjs +95 -16
- 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 +368 -39
- package/lib/menciones.mjs +200 -0
- package/lib/version.mjs +48 -0
- package/package.json +1 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Id automático por instalación (21-10-2026, pedido del dueño: «quiero instalarlo y listo; si el id es el problema,
|
|
2
|
+
// asígnale uno»). Detalle en docs/spec.md «Varios agentes por cuenta».
|
|
3
|
+
//
|
|
4
|
+
// Sin LIENZO_AGENTE, cada instalación del puente es un agente con nombre propio `{uid}_agente_{slug}`, con un slug corto
|
|
5
|
+
// (6 caracteres base36) que sale de la máquina y de la instalación (hash del id de la máquina + la clave), así que borrar
|
|
6
|
+
// ~/.lienzo (o un LIENZO_HOME que no dura) en la misma máquina devuelve el MISMO agente y no gasta otro lugar del tope.
|
|
7
|
+
// Se guarda en <LIENZO_HOME>/agente.json; si la máquina no da un id, el slug es aleatorio (y lo sostiene agente.json).
|
|
8
|
+
// Dos perfiles de Claude en la misma Mac comparten ~/.lienzo: la instalación se distingue por el cliente (Claude
|
|
9
|
+
// Code, Claude Desktop…) y su carpeta de configuración (CLAUDE_CONFIG_DIR), así que cada perfil tiene su slug.
|
|
10
|
+
//
|
|
11
|
+
// Retrocompatibilidad: quien ya usaba el puente (0.3.x o antes) era `{uid}_agente`. Si cuando se crea agente.json ya
|
|
12
|
+
// había una sesión en ese LIENZO_HOME (`legadoDisponible`), la PRIMERA instalación que se une a una sala hereda el
|
|
13
|
+
// agente de siempre y lo sigue usando en todas las salas: lo que le pidieron a `{uid}_agente` le sigue llegando. Las
|
|
14
|
+
// demás instalaciones de ese home reciben su slug. Un home nuevo (sin sesión al crear agente.json) no hereda nada.
|
|
15
|
+
//
|
|
16
|
+
// agente.json = {
|
|
17
|
+
// version: 1,
|
|
18
|
+
// legadoDisponible: boolean, // ¿había sesion.json cuando se creó este archivo? Se fija UNA vez
|
|
19
|
+
// legado: string | null, // la clave de la instalación que heredó `{uid}_agente`
|
|
20
|
+
// instalaciones: { [clave]: { agente: string | null, desde: ISO } }, // agente null = el de siempre
|
|
21
|
+
// nombres: { [clave]: string[] } // los LIENZO_NOMBRE que ya se aplicaron (ver nombreDeConfigManda)
|
|
22
|
+
// }
|
|
23
|
+
// Escrituras atómicas (temporal + rename) bajo un candado de archivo, como sesion.json.
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync, statSync, rmSync } from 'node:fs';
|
|
25
|
+
import { homedir, platform } from 'node:os';
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import { basename, join, resolve } from 'node:path';
|
|
28
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
29
|
+
import { RE_SLUG } from './agentes.mjs';
|
|
30
|
+
|
|
31
|
+
export const ARCHIVO = 'agente.json';
|
|
32
|
+
// Lo que se decidió sobre el agente de siempre, aparte: si se borra SOLO agente.json (con sesion.json todavía ahí), la
|
|
33
|
+
// herencia no se vuelve a repartir y el heredero la recupera (revisión adversarial, 21-10-2026).
|
|
34
|
+
export const ARCHIVO_LEGADO = 'agente.legado.json';
|
|
35
|
+
const CANDADO_VIEJO_MS = 30 * 1000;
|
|
36
|
+
const CANDADO_ESPERA_MS = 10 * 1000;
|
|
37
|
+
const LARGO_SLUG = 6;
|
|
38
|
+
const esperar = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
39
|
+
|
|
40
|
+
/** 6 caracteres base36 al azar (~31 bits): «k3x9q2». Siempre cumple RE_SLUG. */
|
|
41
|
+
export function nuevoSlug(n = LARGO_SLUG) {
|
|
42
|
+
const b = randomBytes(n);
|
|
43
|
+
let s = '';
|
|
44
|
+
for (let i = 0; i < n; i++) s += (b[i] % 36).toString(36);
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Slug estable derivado de la clave (si agente.json no se puede escribir, la instalación igual tiene su id). */
|
|
49
|
+
export function slugDeRespaldo(home, clave) {
|
|
50
|
+
const h = createHash('sha256').update(`${home}\0${clave}`).digest();
|
|
51
|
+
let s = '';
|
|
52
|
+
for (let i = 0; i < LARGO_SLUG; i++) s += (h[i] % 36).toString(36);
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Slug de una instalación en una máquina: el mismo cada vez que se calcula (borrar ~/.lienzo no lo cambia). */
|
|
57
|
+
export function slugDeMaquina(maquina, clave) {
|
|
58
|
+
const h = createHash('sha256').update(`lienzo\0${maquina}\0${clave}`).digest();
|
|
59
|
+
let s = '';
|
|
60
|
+
for (let i = 0; i < LARGO_SLUG; i++) s += (h[i] % 36).toString(36);
|
|
61
|
+
return s;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let maquinaCache;
|
|
65
|
+
/**
|
|
66
|
+
* Un id estable de esta máquina, o null: IOPlatformUUID en macOS, /etc/machine-id en Linux, MachineGuid en Windows.
|
|
67
|
+
* No es el hostname (macOS lo cambia solo). Nunca sale de la máquina: solo entra al hash del slug. Se calcula una vez.
|
|
68
|
+
*/
|
|
69
|
+
export function idDeMaquina() {
|
|
70
|
+
if (maquinaCache !== undefined) return maquinaCache;
|
|
71
|
+
maquinaCache = null;
|
|
72
|
+
try {
|
|
73
|
+
const so = platform();
|
|
74
|
+
if (so === 'darwin') {
|
|
75
|
+
const out = execFileSync('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
76
|
+
maquinaCache = /"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]{16,})"/.exec(out)?.[1] ?? null;
|
|
77
|
+
} else if (so === 'win32') {
|
|
78
|
+
const out = execFileSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
79
|
+
maquinaCache = /MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]{16,})/.exec(out)?.[1] ?? null;
|
|
80
|
+
} else {
|
|
81
|
+
for (const ruta of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
82
|
+
try {
|
|
83
|
+
const v = readFileSync(ruta, 'utf8').trim();
|
|
84
|
+
if (/^[0-9a-f]{16,}$/i.test(v)) { maquinaCache = v; break; }
|
|
85
|
+
} catch { /* la siguiente */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
maquinaCache = null;
|
|
90
|
+
}
|
|
91
|
+
return maquinaCache;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** La carpeta de configuración del cliente: CLAUDE_CONFIG_DIR (con ~ expandido) o «predeterminada». */
|
|
95
|
+
export function carpetaDeConfig(env = process.env) {
|
|
96
|
+
const v = String(env.CLAUDE_CONFIG_DIR ?? '').trim();
|
|
97
|
+
if (!v) return 'predeterminada';
|
|
98
|
+
return resolve(v === '~' ? homedir() : v.startsWith('~/') ? join(homedir(), v.slice(2)) : v);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* El perfil de Claude Code para el nombre visible: «ontop» de CLAUDE_CONFIG_DIR=~/.claude-ontop; null si no hay
|
|
103
|
+
* CLAUDE_CONFIG_DIR o es la carpeta de siempre (~/.claude). Así dos perfiles de la misma persona no se llaman igual
|
|
104
|
+
* en la web («Claude Code (ontop) de Ana» y «Claude Code de Ana»).
|
|
105
|
+
*/
|
|
106
|
+
export function perfilDeConfig(env = process.env) {
|
|
107
|
+
const v = String(env.CLAUDE_CONFIG_DIR ?? '').trim();
|
|
108
|
+
if (!v) return null;
|
|
109
|
+
const p = basename(carpetaDeConfig(env)).replace(/^\.+/, '').replace(/^claude[-_.]?/i, '');
|
|
110
|
+
const limpio = p.replace(/[^\p{L}\p{N}._-]/gu, '').slice(0, 20);
|
|
111
|
+
return limpio && limpio.toLowerCase() !== 'claude' ? limpio : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** La clave de una instalación: `<cliente>@<carpeta de config>`. */
|
|
115
|
+
export const claveDeInstalacion = (cliente, env = process.env) => `${cliente || 'claude-code'}@${carpetaDeConfig(env)}`;
|
|
116
|
+
|
|
117
|
+
function leer(ruta) {
|
|
118
|
+
try {
|
|
119
|
+
const d = JSON.parse(readFileSync(ruta, 'utf8'));
|
|
120
|
+
return d && typeof d === 'object' ? d : null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function escribirAtomico(ruta, datos) {
|
|
127
|
+
const temporal = `${ruta}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(temporal, JSON.stringify(datos, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
130
|
+
renameSync(temporal, ruta);
|
|
131
|
+
} finally {
|
|
132
|
+
rmSync(temporal, { force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Guarda la decisión del legado aparte de agente.json (no lanza: sin ella solo se pierde ese recuerdo). */
|
|
137
|
+
function anotarLegado(home, legadoDisponible, legado) {
|
|
138
|
+
try {
|
|
139
|
+
escribirAtomico(join(home, ARCHIVO_LEGADO), { legadoDisponible, legado: legado ?? null });
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error(`[lienzo] no pude guardar ${ARCHIVO_LEGADO} (${e.message})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Al arrancar el puente (constructor de Lienzo): si agente.json no existe, lo crea con `legadoDisponible` = ¿hay
|
|
147
|
+
* sesion.json en este momento? Se fija una sola vez: si el archivo ya existe no se toca. Si agente.json se borró pero
|
|
148
|
+
* queda agente.legado.json, se usa esa decisión (y el heredero vuelve a ser el agente de siempre). Devuelve el estado
|
|
149
|
+
* leído. No lanza: un home que no se puede escribir deja la decisión para `resolverAgente` (que usa el slug de respaldo).
|
|
150
|
+
*/
|
|
151
|
+
export function prepararInstalacion(home) {
|
|
152
|
+
if (!home) return null;
|
|
153
|
+
const ruta = join(home, ARCHIVO);
|
|
154
|
+
try {
|
|
155
|
+
if (!existsSync(ruta)) {
|
|
156
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
157
|
+
const previa = leer(join(home, ARCHIVO_LEGADO));
|
|
158
|
+
const recordada = previa && typeof previa.legadoDisponible === 'boolean';
|
|
159
|
+
const legadoDisponible = recordada ? previa.legadoDisponible : existsSync(join(home, 'sesion.json'));
|
|
160
|
+
const legado = recordada && typeof previa.legado === 'string' ? previa.legado : null;
|
|
161
|
+
const inicial = {
|
|
162
|
+
version: 1, legadoDisponible, legado,
|
|
163
|
+
instalaciones: legado ? { [legado]: { agente: null, desde: new Date().toISOString() } } : {},
|
|
164
|
+
};
|
|
165
|
+
if (!recordada) anotarLegado(home, legadoDisponible, null);
|
|
166
|
+
try {
|
|
167
|
+
// 'wx': si otro proceso lo creó entre medio, gana el suyo (se relee abajo)
|
|
168
|
+
writeFileSync(ruta, JSON.stringify(inicial, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (e.code !== 'EEXIST') throw e;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return leer(ruta);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error(`[lienzo] no pude preparar ${ruta} (${e.message}); el id de este agente sale de la clave de la instalación`);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function conCandado(home, fn, { esperaMs = CANDADO_ESPERA_MS, viejoMs = CANDADO_VIEJO_MS } = {}) {
|
|
181
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
182
|
+
const candado = join(home, `${ARCHIVO}.lock`);
|
|
183
|
+
const hasta = Date.now() + esperaMs;
|
|
184
|
+
let fd;
|
|
185
|
+
for (;;) {
|
|
186
|
+
try {
|
|
187
|
+
fd = openSync(candado, 'wx', 0o600);
|
|
188
|
+
break;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if (e.code !== 'EEXIST') throw e;
|
|
191
|
+
try {
|
|
192
|
+
if (Date.now() - statSync(candado).mtimeMs > viejoMs) { unlinkSync(candado); continue; }
|
|
193
|
+
} catch { continue; }
|
|
194
|
+
if (Date.now() > hasta) throw new Error(`otro proceso tiene tomado ${candado}`);
|
|
195
|
+
await esperar(25 + Math.random() * 50);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
return await fn();
|
|
200
|
+
} finally {
|
|
201
|
+
closeSync(fd);
|
|
202
|
+
rmSync(candado, { force: true });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** La entrada guardada de una clave, si es válida (null si no hay o está rota). */
|
|
207
|
+
function guardada(d, clave) {
|
|
208
|
+
const previa = d?.instalaciones && typeof d.instalaciones === 'object' ? d.instalaciones[clave] : null;
|
|
209
|
+
return previa && (previa.agente === null || RE_SLUG.test(String(previa.agente))) ? previa : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Qué agente es esta instalación. Devuelve { agente: slug | null, origen }:
|
|
214
|
+
* - 'guardado': ya estaba en agente.json (estable entre arranques)
|
|
215
|
+
* - 'legado': la primera instalación de un home que ya tenía sesión: hereda `{uid}_agente` (agente null)
|
|
216
|
+
* - 'nuevo': slug recién guardado (el de la máquina; aleatorio si la máquina no da un id)
|
|
217
|
+
* - 'respaldo': no se pudo leer/escribir agente.json: el slug de la máquina (o uno derivado de la clave)
|
|
218
|
+
* Lo ya guardado se lee SIN candado: un candado tomado por otro proceso no cambia el id de una instalación que ya lo
|
|
219
|
+
* tenía (revisión adversarial, 21-10-2026: antes, con el candado tomado más de 10 s, salía un slug de respaldo
|
|
220
|
+
* distinto). El candado solo se toma para escribir una instalación nueva: dos que se unen a la vez no heredan las
|
|
221
|
+
* dos el agente de siempre.
|
|
222
|
+
* `opciones` (para tests): maquina = () => id | null; esperaMs / viejoMs del candado.
|
|
223
|
+
*/
|
|
224
|
+
export async function resolverAgente(home, clave, { maquina = idDeMaquina, esperaMs, viejoMs } = {}) {
|
|
225
|
+
const ruta = join(home, ARCHIVO);
|
|
226
|
+
const ya = guardada(leer(ruta), clave);
|
|
227
|
+
if (ya) return { agente: ya.agente, origen: 'guardado' };
|
|
228
|
+
const id = maquina();
|
|
229
|
+
const deMaquina = id ? slugDeMaquina(id, clave) : null;
|
|
230
|
+
try {
|
|
231
|
+
return await conCandado(home, async () => {
|
|
232
|
+
const d = leer(ruta) ?? { version: 1, legadoDisponible: false, legado: null, instalaciones: {} };
|
|
233
|
+
d.instalaciones = d.instalaciones && typeof d.instalaciones === 'object' ? d.instalaciones : {};
|
|
234
|
+
const previa = guardada(d, clave);
|
|
235
|
+
if (previa) return { agente: previa.agente, origen: 'guardado' };
|
|
236
|
+
let agente;
|
|
237
|
+
let origen;
|
|
238
|
+
if (d.legadoDisponible === true && !d.legado) {
|
|
239
|
+
d.legado = clave;
|
|
240
|
+
agente = null;
|
|
241
|
+
origen = 'legado';
|
|
242
|
+
anotarLegado(home, true, clave);
|
|
243
|
+
} else {
|
|
244
|
+
const usados = new Set(Object.values(d.instalaciones).map((i) => i?.agente).filter(Boolean));
|
|
245
|
+
agente = deMaquina && !usados.has(deMaquina) ? deMaquina : null;
|
|
246
|
+
while (!agente || usados.has(agente)) agente = nuevoSlug();
|
|
247
|
+
origen = 'nuevo';
|
|
248
|
+
}
|
|
249
|
+
d.instalaciones[clave] = { agente, desde: new Date().toISOString() };
|
|
250
|
+
escribirAtomico(ruta, d);
|
|
251
|
+
return { agente, origen };
|
|
252
|
+
}, { esperaMs, viejoMs });
|
|
253
|
+
} catch (e) {
|
|
254
|
+
// otro proceso pudo guardarla mientras esperábamos el candado: lo guardado manda
|
|
255
|
+
const otra = guardada(leer(ruta), clave);
|
|
256
|
+
if (otra) return { agente: otra.agente, origen: 'guardado' };
|
|
257
|
+
console.error(`[lienzo] no pude guardar el id de esta instalación en ${ruta} (${e.message}); uso uno derivado de la ${deMaquina ? 'máquina' : 'clave'}`);
|
|
258
|
+
return { agente: deMaquina ?? slugDeRespaldo(home, clave), origen: 'respaldo' };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── LIENZO_NOMBRE frente al nombre que se puso desde la web ──────────────────
|
|
263
|
+
// LIENZO_NOMBRE (o el `nombre` de config.json, del plugin o del .mcpb) se aplica cuando el agente entra por primera vez
|
|
264
|
+
// a una sala, o cuando el nombre que tiene ahí lo puso una versión anterior de esa misma variable. Si el nombre de la
|
|
265
|
+
// sala no es ninguno de los que puso la configuración, lo cambió su persona desde la web y se respeta (revisión
|
|
266
|
+
// adversarial, 21-10-2026: antes la variable lo pisaba en cada sesión nueva). Se recuerdan los últimos 10.
|
|
267
|
+
const MAX_NOMBRES = 10;
|
|
268
|
+
|
|
269
|
+
/** Los nombres que la configuración ya aplicó en esta instalación. */
|
|
270
|
+
export function nombresAplicados(home, clave) {
|
|
271
|
+
const d = leer(join(home, ARCHIVO));
|
|
272
|
+
const l = d?.nombres && typeof d.nombres === 'object' ? d.nombres[clave] : null;
|
|
273
|
+
return Array.isArray(l) ? l.filter((x) => typeof x === 'string') : [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** ¿Manda LIENZO_NOMBRE sobre el nombre que el agente ya tiene en la sala? */
|
|
277
|
+
export function nombreDeConfigManda(deConfig, enSala, aplicados) {
|
|
278
|
+
if (!deConfig) return false;
|
|
279
|
+
if (!enSala) return true; // entra por primera vez
|
|
280
|
+
return enSala !== deConfig && aplicados.includes(enSala);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Anota que la configuración puso este nombre (no lanza: sin agente.json solo se pierde el recuerdo). */
|
|
284
|
+
export async function anotarNombreAplicado(home, clave, nombre, opciones = {}) {
|
|
285
|
+
if (!home || !nombre) return;
|
|
286
|
+
const ruta = join(home, ARCHIVO);
|
|
287
|
+
if (nombresAplicados(home, clave).at(-1) === nombre) return;
|
|
288
|
+
try {
|
|
289
|
+
await conCandado(home, async () => {
|
|
290
|
+
const d = leer(ruta) ?? { version: 1, legadoDisponible: false, legado: null, instalaciones: {} };
|
|
291
|
+
d.nombres = d.nombres && typeof d.nombres === 'object' ? d.nombres : {};
|
|
292
|
+
const l = Array.isArray(d.nombres[clave]) ? d.nombres[clave].filter((x) => typeof x === 'string' && x !== nombre) : [];
|
|
293
|
+
d.nombres[clave] = [...l, nombre].slice(-MAX_NOMBRES);
|
|
294
|
+
escribirAtomico(ruta, d);
|
|
295
|
+
}, opciones);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
console.error(`[lienzo] no pude anotar el nombre de la configuración en ${ruta} (${e.message})`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── el cliente MCP y su nombre visible ──────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
/** El cliente según el `clientInfo.name` del handshake MCP (null si no se reconoce). */
|
|
304
|
+
export function clienteDeMcp(nombre) {
|
|
305
|
+
const n = String(nombre ?? '').toLowerCase();
|
|
306
|
+
if (!n) return null;
|
|
307
|
+
if (/claude[-\s]?code/.test(n)) return 'claude-code';
|
|
308
|
+
if (/claude[-\s]?(ai|desktop)/.test(n)) return 'claude-desktop';
|
|
309
|
+
if (/cursor/.test(n)) return 'cursor';
|
|
310
|
+
if (/codex/.test(n)) return 'codex';
|
|
311
|
+
if (/windsurf/.test(n)) return 'windsurf';
|
|
312
|
+
if (/visual studio code|vscode|copilot/.test(n)) return 'vscode';
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const NOMBRES = {
|
|
317
|
+
'claude-code': 'Claude Code', 'claude-desktop': 'Claude Desktop', cursor: 'Cursor', codex: 'Codex',
|
|
318
|
+
windsurf: 'Windsurf', vscode: 'VS Code',
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/** «Claude Code», «Claude Desktop»…; un cliente desconocido es «Agente» (el nombre de siempre: «Agente de Ana»). */
|
|
322
|
+
export const nombreDeCliente = (cliente) => NOMBRES[cliente] ?? 'Agente';
|