ratacode 0.2.5

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.
Files changed (58) hide show
  1. package/CREDITS.md +80 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/apreton/README.md +54 -0
  5. package/apreton/handshake.md +33 -0
  6. package/apreton/headless.md +58 -0
  7. package/apreton/mcp.md +221 -0
  8. package/apreton/navegador.md +58 -0
  9. package/bin/instalacion.js +148 -0
  10. package/bin/ratacode.js +1251 -0
  11. package/fabrica/settings.yaml +247 -0
  12. package/mcp/README.md +253 -0
  13. package/mcp/bin/ratacode-mcp.js +215 -0
  14. package/mcp/lib/actividad.js +49 -0
  15. package/mcp/lib/casa.js +126 -0
  16. package/mcp/lib/claves.js +148 -0
  17. package/mcp/lib/espacios.js +117 -0
  18. package/mcp/lib/http.js +184 -0
  19. package/mcp/lib/lectura.js +210 -0
  20. package/mcp/lib/modelos.js +235 -0
  21. package/mcp/lib/nucleo.js +332 -0
  22. package/mcp/lib/registro.js +21 -0
  23. package/mcp/lib/seguridad.js +283 -0
  24. package/mcp/lib/servidor.js +397 -0
  25. package/mcp/lib/tareas.js +406 -0
  26. package/mcp/package.json +22 -0
  27. package/mcp/tunel.mjs +209 -0
  28. package/modos/arquitecto/agent.cordis.yml +120 -0
  29. package/modos/arquitecto/preset.yml +3 -0
  30. package/modos/capataz/agent.cordis.yml +120 -0
  31. package/modos/capataz/preset.yml +3 -0
  32. package/modos/faro/agent.cordis.yml +120 -0
  33. package/modos/faro/preset.yml +3 -0
  34. package/modos/gepeto/agent.cordis.yml +191 -0
  35. package/modos/gepeto/preset.yml +3 -0
  36. package/modos/hero/agent.cordis.yml +198 -0
  37. package/modos/hero/preset.yml +3 -0
  38. package/modos/modo-rata/agent.cordis.yml +198 -0
  39. package/modos/modo-rata/preset.yml +3 -0
  40. package/modos/nex/agent.cordis.yml +213 -0
  41. package/modos/nex/preset.yml +3 -0
  42. package/modos/nex/skills/cordis-plugin-development/SKILL.md +420 -0
  43. package/modos/nex/skills/editing-cordis-compositions/SKILL.md +165 -0
  44. package/modos/pix/agent.cordis.yml +207 -0
  45. package/modos/pix/preset.yml +3 -0
  46. package/modos/tirita/agent.cordis.yml +130 -0
  47. package/modos/tirita/preset.yml +3 -0
  48. package/package.json +49 -0
  49. package/piel/activos/ratacode-emblema.svg +14 -0
  50. package/piel/activos/ratacode-es.js +1331 -0
  51. package/piel/activos/ratacode-identidad.css +50 -0
  52. package/piel/activos/ratacode-piel.css +217 -0
  53. package/piel/activos/ratacode-piel.js +487 -0
  54. package/piel/activos/ratacode-vida.js +424 -0
  55. package/piel/cordis.patch.yml +6 -0
  56. package/piel/lib/cliente.js +1020 -0
  57. package/piel/lib/index.js +1230 -0
  58. package/piel/package.json +36 -0
@@ -0,0 +1,1251 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ratacode — LA TERMINAL DE TRABAJO BRUTO.
4
+ *
5
+ * Arranca `dsh` (el motor de DSH, MIT) con una casa PROPIA
6
+ * (`%USERPROFILE%\.ratacode`, nunca `~/.dsh`) y con la cara de RATACODE puesta
7
+ * por el plugin `ratacode-piel`. Puerto fijo y conocido: 3777.
8
+ *
9
+ * Lo que hace, en orden:
10
+ * 0 · PUERTA: si Node es menor que 24, PARA con un mensaje claro (no arranca).
11
+ * 1 · Prepara la casa: `profiles/web` (bundles `dsh-base`, `dsh-web-app` y
12
+ * `ratacode-piel`, copiado dentro del perfil, que es como el cargador de
13
+ * DSH resuelve un plugin fuera del árbol) y `profiles/headless`
14
+ * (`dsh-base` + `dsh-headless`, el modo sin pantalla que DSH ya trae).
15
+ * 2 · ESTRENA la casa (sólo la primera vez, si no hay `settings.yaml`):
16
+ * copia `fabrica\settings.yaml` — B.AI y OpenRouter de fábrica, el aviso
17
+ * «Internal Testing» ya aceptado y `permission.defaultPreset:
18
+ * danger-full-access` (sin «Permitir» en cada paso) — copia los nueve
19
+ * modos de `modos\` a `<casa>/.agent-presets`, y pone el modelo por
20
+ * defecto del PRIMER PROVEEDOR CON CLAVE en el entorno (orden: B.AI,
21
+ * OpenRouter, DeepSeek). Si la casa YA existe, no se pisa nada del
22
+ * usuario.
23
+ * 3 · ESPACIO DE TRABAJO: la carpeta desde la que se lanza `ratacode` (o la
24
+ * de `--carpeta <ruta>`) queda REGISTRADA en `<casa>\storages\workspace.json`
25
+ * — el formato del registro de espacios de DSH — y es el `cwd` del motor.
26
+ * Además se fija el selector de carpeta EN LA WEB: se apaga la fila
27
+ * adaptativa de DSH (`dsh-host-directory-picker-auto`, que en Windows
28
+ * resuelve «native» y abre un diálogo del sistema que nadie que maneje
29
+ * RATACODE desde fuera puede ver) y se compone el par «browse».
30
+ * 4 · Arranca el motor, coge la URL CON TOKEN que imprime y la guarda en
31
+ * `<casa>/url.txt` SÓLO cuando el puerto ya escucha de verdad (al
32
+ * empezar borra la de la vez anterior, para que nadie se lleve una URL
33
+ * muerta). Y deja en `<casa>\ratacode.log` por qué se cierra el motor
34
+ * (código de salida y señal): una instancia que se cae sola tiene que
35
+ * dejar rastro.
36
+ *
37
+ * Y `ratacode headless "encargo" [--modelo X]`: el MISMO motor, la MISMA casa,
38
+ * el perfil `headless` de DSH, sin pantalla ni puerto. `--modelo` vale sólo
39
+ * para ese encargo: se le pasa al motor un documento de ajustes copia con el
40
+ * modelo cambiado, así NO se toca el que el usuario tenga guardado.
41
+ *
42
+ * Ninguna clave viaja dentro: las pone el usuario (web Ajustes > Models →
43
+ * `<casa>/.credentials.yaml`, o exportando la variable de entorno de cada
44
+ * proveedor). Los proveedores de fábrica son los 9 declarados en
45
+ * `fabrica\settings.yaml` (B.AI, OpenRouter, Groq, Google Gemini, NVIDIA NIM,
46
+ * SambaNova, Cloudflare Workers AI y DOS LOCALES sin clave: Ollama y LM Studio)
47
+ * más DeepSeek, que lo sirve el adaptador nativo de DSH (ruta
48
+ * `deepseek-official`, `https://api.deepseek.com`, `DEEPSEEK_API_KEY`): 10 en
49
+ * Ajustes > Models.
50
+ * De las claves SÓLO se mira si la variable existe: nunca su valor.
51
+ *
52
+ * El texto de la conexión y el MCP para chats web viven en Ajustes > Conexiones
53
+ * (la piel los sirve en `/ratacode/handshake` y `/ratacode/mcp`); para eso el
54
+ * plugin se lleva copiado `apreton\` y la ruta de ESTA instalación en
55
+ * `instalacion.txt` (para que los comandos del MCP lleven la ruta de verdad).
56
+ *
57
+ * Nada de esto toca `~/.dsh` ni el DSH de nadie más.
58
+ */
59
+ import { spawn } from 'node:child_process';
60
+ import { randomUUID } from 'node:crypto';
61
+ import { createRequire } from 'node:module';
62
+ import {
63
+ appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync,
64
+ } from 'node:fs';
65
+ import { connect } from 'node:net';
66
+ import { homedir } from 'node:os';
67
+ import { basename, dirname, join, resolve } from 'node:path';
68
+ import { fileURLToPath } from 'node:url';
69
+ import yaml from 'js-yaml';
70
+
71
+ const AQUI = dirname(fileURLToPath(import.meta.url));
72
+ const PAQUETE = resolve(AQUI, '..');
73
+ const PIEL_ORIGEN = join(PAQUETE, 'piel');
74
+ const NOMBRE_PLUGIN = 'ratacode-piel';
75
+ /** Los ajustes que se copian a una casa nueva (proveedores, aviso, permiso). */
76
+ const FABRICA_ORIGEN = join(PAQUETE, 'fabrica', 'settings.yaml');
77
+ /** Los nueve modos de fábrica, que van a `<casa>/.agent-presets`. */
78
+ const MODOS_ORIGEN = join(PAQUETE, 'modos');
79
+ /** El texto de la conexión y su guía, que viajan copiados junto al plugin de la piel. */
80
+ const APRETON_ORIGEN = join(PAQUETE, 'apreton');
81
+
82
+ /** Puerto fijo y conocido de RATACODE. `--port` lo cambia. */
83
+ const PUERTO_POR_DEFECTO = 3777;
84
+ /** La versión del aviso «Internal Testing» que el cliente da por aceptada. */
85
+ const AVISO_ACEPTADO = '2026-08-13.1';
86
+ /** Node mínimo: por debajo, los módulos nativos del motor no son los suyos. */
87
+ const NODE_MINIMO = 24;
88
+ /**
89
+ * La capa que deja SÓLO los 9 modos de RATACODE en el selector: apaga la raíz
90
+ * de fábrica del motor (sus 4 modos de serie no salen) y pone `modo-rata` por
91
+ * defecto. Va en la COMPOSICIÓN porque el namespace de ajustes del motor sólo
92
+ * admite `default` (`dsh-agent-presets/lib/index.js:1151`); `includeShippedRoot`
93
+ * no cabe en `settings.yaml` y su única puerta es esta capa (`:1246`).
94
+ * Va DESPUÉS de los bundles: la fila `agent-presets` la inserta `dsh-web-app`,
95
+ * así que un `config` por id sólo la alcanza si se aplica por encima.
96
+ */
97
+ const CAPA_PRESETS = `# RATACODE · los 9 modos de la casa, y NINGUNO de los que trae el motor de serie.
98
+ - id: agent-presets
99
+ config:
100
+ default: modo-rata
101
+ includeShippedRoot: false
102
+ `;
103
+ /** El preset de RATACODE que una casa usa si no dice otra cosa. */
104
+ const PRESET_POR_DEFECTO = 'modo-rata';
105
+ /**
106
+ * El idioma de RATACODE (R21): español. Los otros dos que trae el motor
107
+ * (inglés y chino) siguen donde estaban: se eligen en Ajustes › General ›
108
+ * Language. Aquí sólo se apunta el de la casa cuando no hay ninguno apuntado.
109
+ */
110
+ const IDIOMA_POR_DEFECTO = 'es';
111
+ /**
112
+ * Los TRES temas de RATACODE (R21), en su orden, y el de fábrica: el primero.
113
+ * El id tiene que ser el mismo que registra `piel\lib\cliente.js` por la vía
114
+ * oficial de temas de DSH.
115
+ */
116
+ const TEMAS_DE_LA_CASA = ['ratacode-pink', 'ratacode-yellow', 'minimal'];
117
+ const TEMA_POR_DEFECTO = TEMAS_DE_LA_CASA[0];
118
+ /** Los presets que salieron en versiones anteriores de RATACODE y ya no existen. */
119
+ const PRESETS_VIEJOS = ['enlazador', 'promptista', 'escritor'];
120
+ /**
121
+ * Los valores de `agent-presets.default` que hay que MIGRAR a modo-rata, y sólo
122
+ * éstos: los que traía RATACODE de fábrica (`standard`), los que ya no existen
123
+ * (los tres de arriba) y los de serie del motor (`minimal`, `ptc`, `cordis`),
124
+ * que con `includeShippedRoot: false` no resuelven y dejan la sesión sin
125
+ * componer (`preset "standard" not found`, medido en R14). Cualquier otro valor
126
+ * —uno de los 9 modos, o un preset del usuario— NO se toca: es su elección.
127
+ */
128
+ const PRESETS_A_MIGRAR = ['standard', 'minimal', 'ptc', 'cordis', ...PRESETS_VIEJOS];
129
+ const PLANTILLA_PARCHE = `# Tu capa de parches de este perfil, aplicada después de cada capa de bundle:
130
+ # una lista YAML de filas del cargador (config por id, desactivaciones, inserts).
131
+ ${CAPA_PRESETS}`;
132
+ /** Los ajustes que DSH espera en un perfil para resolver plugins de fuera del árbol. */
133
+ const PLANTILLA_PNPM = `packages:
134
+ - .
135
+
136
+ nodeLinker: hoisted
137
+ autoInstallPeers: false
138
+ `;
139
+
140
+ /**
141
+ * El primer proveedor con clave en el entorno, en este orden, y el modelo con
142
+ * el que se estrena la casa. Si el proveedor declara modelos en el
143
+ * `settings.yaml` de la casa, manda el PRIMERO que declare; este de aquí es
144
+ * sólo el respaldo cuando no declara ninguno (DeepSeek no se declara: lo sirve
145
+ * el adaptador nativo de DSH, cuyo modelo por defecto es `deepseek-flash`).
146
+ */
147
+ const PROVEEDORES_POR_ORDEN = [
148
+ { variable: 'B_AI_API_KEY', proveedor: 'b-ai', modelo: 'deepseek-v4.1-flash' },
149
+ { variable: 'OPENROUTER_API_KEY', proveedor: 'openrouter', modelo: 'qwen/qwen3.8-flash' },
150
+ { variable: 'DEEPSEEK_API_KEY', proveedor: 'deepseek-official', modelo: 'deepseek-flash' },
151
+ ];
152
+
153
+ // ── 0 · la puerta: Node 24 ──────────────────────────────────────────────────
154
+ /**
155
+ * Para ANTES de tocar nada si Node es más viejo que {@link NODE_MINIMO}. El
156
+ * motor y sus módulos nativos (`node-pty`, `koffi`, `dsh-subprocess-local`)
157
+ * están probados y precompilados para Node 24; con menos, el fallo saldría más
158
+ * tarde y peor (una terminal que no arranca, un módulo que no carga).
159
+ */
160
+ function exigirNode() {
161
+ const version = process.versions.node;
162
+ const mayor = Number.parseInt(String(version).split('.')[0], 10);
163
+ if (Number.isFinite(mayor) && mayor >= NODE_MINIMO) return;
164
+ process.stderr.write([
165
+ '',
166
+ 'RATACODE · PARA.',
167
+ '',
168
+ ' Hace falta Node ' + NODE_MINIMO + ' o más nuevo, y aquí hay Node ' + version + '.',
169
+ ' El motor de RATACODE y sus módulos nativos (node-pty, koffi,',
170
+ ' subprocess-local) están compilados y probados para Node ' + NODE_MINIMO + ': con menos,',
171
+ ' el panel puede arrancar pero la terminal y los subprocesos fallan.',
172
+ '',
173
+ ' Qué hacer:',
174
+ ' 1. Instala Node ' + NODE_MINIMO + ': https://nodejs.org/es/download',
175
+ ' (con nvm-windows: nvm install ' + NODE_MINIMO + ' && nvm use ' + NODE_MINIMO + ')',
176
+ ' 2. Vuelve a poner RATACODE: npm i -g ratacode',
177
+ ' 3. Arranca: ratacode',
178
+ '',
179
+ ' Comprueba tu versión con: node -v',
180
+ ' (este RATACODE se está ejecutando con: ' + process.execPath + ')',
181
+ '',
182
+ ].join('\n'));
183
+ process.exit(1);
184
+ }
185
+
186
+ // ── la línea de órdenes ─────────────────────────────────────────────────────
187
+ function uso() {
188
+ return [
189
+ 'RATACODE · la terminal de trabajo bruto',
190
+ '',
191
+ ' ratacode [opciones]',
192
+ ' ratacode headless "encargo" [opciones]',
193
+ '',
194
+ ' --port <n> puerto del panel (por defecto ' + PUERTO_POR_DEFECTO + '; 0 = el que dé el sistema)',
195
+ ' --home <ruta> dónde vive la casa de RATACODE (por defecto %USERPROFILE%\\.ratacode)',
196
+ ' --carpeta <ruta> espacio de trabajo (por defecto, la carpeta desde la que lanzas ratacode)',
197
+ ' --modelo <x> SÓLO headless: el modelo de ese encargo (p. ej. deepseek-v4.1-flash)',
198
+ ' --open abre además la URL en el navegador de siempre',
199
+ ' -h, --help esto',
200
+ '',
201
+ ' ratacode mcp [opciones]',
202
+ '',
203
+ ' El servidor MCP de RATACODE, como subcomando del mismo binario:',
204
+ ' habla MCP por stdio (lo que espera cualquier cliente) y, con --http,',
205
+ ' también por Streamable HTTP. Todo lo que va detrás de «mcp» es suyo.',
206
+ '',
207
+ ' ratacode mcp MCP por stdio',
208
+ ' ratacode mcp --http MCP por stdio Y por HTTP (puerto 3778; ver mcp/README.md)',
209
+ ' ratacode mcp --status enseña el estado de la casa y sale',
210
+ ' ratacode mcp --help la ayuda del MCP',
211
+ '',
212
+ ' headless no abre panel ni puerto: hace el encargo, imprime la respuesta y termina.',
213
+ '',
214
+ ].join('\n');
215
+ }
216
+
217
+ /**
218
+ * Las órdenes de RATACODE. `mcp` es un subcomando PASO A PASO: todo lo que va
219
+ * detrás es del servidor MCP (incluido su `--help` y su `--home`), porque por
220
+ * stdio stdout es del protocolo y no se puede tocar.
221
+ */
222
+ function leerOrdenes(argv) {
223
+ if (argv[0] === 'mcp') {
224
+ const resto = argv.slice(1);
225
+ let casa = null;
226
+ for (let i = 0; i < resto.length; i += 1) {
227
+ const a = resto[i];
228
+ if (a === '--home') casa = resolve(resto[i + 1] ?? '');
229
+ else if (a.startsWith('--home=')) casa = resolve(a.slice(a.indexOf('=') + 1));
230
+ }
231
+ return { modo: 'mcp', mcpArgv: resto, casa, ayuda: false, puerto: PUERTO_POR_DEFECTO, abrir: false, carpeta: null, encargo: null, modelo: null };
232
+ }
233
+ const ordenes = {
234
+ puerto: PUERTO_POR_DEFECTO, casa: null, abrir: false, ayuda: false,
235
+ carpeta: null, modo: 'web', encargo: null, modelo: null, mcpArgv: null,
236
+ };
237
+ const sueltos = [];
238
+ for (let i = 0; i < argv.length; i += 1) {
239
+ const a = argv[i];
240
+ if (a === '-h' || a === '--help') ordenes.ayuda = true;
241
+ else if (a === '--open') ordenes.abrir = true;
242
+ else if (a === 'headless') ordenes.modo = 'headless';
243
+ else if (a === '--port' || a.startsWith('--port=')) {
244
+ const valor = a.includes('=') ? a.slice(a.indexOf('=') + 1) : argv[++i];
245
+ if (!/^\d+$/.test(String(valor))) throw new Error('--port tiene que ser un número, y me has dado ' + JSON.stringify(valor));
246
+ ordenes.puerto = Number(valor);
247
+ } else if (a === '--home' || a.startsWith('--home=')) {
248
+ const valor = a.includes('=') ? a.slice(a.indexOf('=') + 1) : argv[++i];
249
+ if (!valor) throw new Error('--home necesita una ruta');
250
+ ordenes.casa = resolve(valor);
251
+ } else if (a === '--carpeta' || a.startsWith('--carpeta=')) {
252
+ const valor = a.includes('=') ? a.slice(a.indexOf('=') + 1) : argv[++i];
253
+ if (!valor) throw new Error('--carpeta necesita una ruta');
254
+ ordenes.carpeta = resolve(valor);
255
+ } else if (a === '--modelo' || a.startsWith('--modelo=')) {
256
+ const valor = a.includes('=') ? a.slice(a.indexOf('=') + 1) : argv[++i];
257
+ if (!valor) throw new Error('--modelo necesita un nombre de modelo');
258
+ ordenes.modelo = valor;
259
+ } else if (a.startsWith('-')) throw new Error('no entiendo «' + a + '» (mira: ratacode --help)');
260
+ else sueltos.push(a);
261
+ }
262
+ if (ordenes.modo === 'headless') {
263
+ if (sueltos.length === 0) {
264
+ throw new Error('headless necesita el encargo entre comillas, por ejemplo: ratacode headless "lista los ficheros"');
265
+ }
266
+ ordenes.encargo = sueltos.join(' ');
267
+ if (ordenes.abrir) throw new Error('--open es del panel; headless no abre nada');
268
+ } else {
269
+ if (sueltos.length > 0) throw new Error('no entiendo «' + sueltos[0] + '» (mira: ratacode --help)');
270
+ if (ordenes.modelo !== null) throw new Error('--modelo es de headless: ratacode headless "encargo" --modelo ' + ordenes.modelo);
271
+ }
272
+ return ordenes;
273
+ }
274
+
275
+ // ── 1 · la casa y los perfiles ──────────────────────────────────────────────
276
+ function escribirSiFalta(ruta, texto) {
277
+ if (existsSync(ruta)) return false;
278
+ mkdirSync(dirname(ruta), { recursive: true });
279
+ writeFileSync(ruta, texto);
280
+ return true;
281
+ }
282
+
283
+ /** Una lista YAML vacía en formato FLUJO, sola en su línea: `[]` (o `[ ]`). */
284
+ const LINEA_VACIA_FLUJO = /^[ \t]*\[[ \t]*\][ \t]*$/;
285
+
286
+ /**
287
+ * Las filas de un `cordis.patch.yml`, o `null` si ese texto NO es una lista
288
+ * YAML válida (que es el caso roto de la 0.2.0). Un fichero vacío es la lista
289
+ * vacía; un documento que no sea una lista tampoco vale.
290
+ * @param texto - el contenido del fichero.
291
+ * @returns las filas, o null.
292
+ */
293
+ function filasDelParche(texto) {
294
+ let cargado;
295
+ try {
296
+ cargado = yaml.load(texto);
297
+ } catch {
298
+ return null;
299
+ }
300
+ if (cargado === null || cargado === undefined) return [];
301
+ return Array.isArray(cargado) ? cargado : null;
302
+ }
303
+
304
+ /** ¿Estas filas ya traen la capa de presets? */
305
+ function tieneLaCapa(filas) {
306
+ return filas.some((fila) => fila !== null && typeof fila === 'object' && !Array.isArray(fila) && fila.id === 'agent-presets');
307
+ }
308
+
309
+ /**
310
+ * Deja la capa de presets en el `cordis.patch.yml` de un perfil, SIEMPRE como
311
+ * lista YAML válida. La plantilla sólo se escribe cuando el fichero falta
312
+ * (`escribirSiFalta`), así que sin esto una casa estrenada con una versión
313
+ * anterior seguiría enseñando los modos de serie del motor.
314
+ *
315
+ * Tres casos, los tres medidos:
316
+ * · el fichero trae `[]` o va vacío → se escribe la capa sola;
317
+ * · el fichero trae filas del usuario → se respetan y la capa se añade al
318
+ * final (que es donde manda);
319
+ * · el fichero se quedó ROTO por la 0.2.0 —un `[]` pegado delante de las
320
+ * filas, que no es YAML— → se quita esa línea suelta y se reescribe válido,
321
+ * sin perder ni una fila del usuario. Pasaba al actualizar una casa de la
322
+ * 0.1 y el motor moría: «YAMLException: end of the stream or a document
323
+ * separator is expected».
324
+ * @param perfil - la carpeta del perfil (`<casa>/profiles/web`).
325
+ * @returns `{puesto, reparado, motivo}`.
326
+ */
327
+ function asegurarCapaPresets(perfil) {
328
+ const ruta = join(perfil, 'cordis.patch.yml');
329
+ let texto = '';
330
+ try {
331
+ texto = readFileSync(ruta, 'utf8');
332
+ } catch {
333
+ return { puesto: false, reparado: false, motivo: 'sin cordis.patch.yml' };
334
+ }
335
+ let filas = filasDelParche(texto);
336
+ let reparado = false;
337
+ if (filas === null) {
338
+ const limpio = texto.split('\n').filter((linea) => !LINEA_VACIA_FLUJO.test(linea)).join('\n');
339
+ filas = filasDelParche(limpio);
340
+ if (filas === null) return { puesto: false, reparado: false, motivo: 'el cordis.patch.yml del perfil no se puede leer como YAML: no lo toco' };
341
+ reparado = true;
342
+ texto = limpio;
343
+ }
344
+ if (tieneLaCapa(filas)) {
345
+ if (reparado) escribir(ruta, texto);
346
+ return { puesto: false, reparado, motivo: reparado ? 'ya tenía la capa' : 'ya estaba' };
347
+ }
348
+ if (filas.length === 0) {
349
+ escribir(ruta, PLANTILLA_PARCHE);
350
+ return { puesto: true, reparado, motivo: reparado ? 'estaba vacío' : 'estaba vacío' };
351
+ }
352
+ escribir(ruta, (texto.endsWith('\n') ? texto + '\n' : texto + '\n\n') + CAPA_PRESETS);
353
+ return { puesto: true, reparado, motivo: reparado ? 'capa añadida al final' : 'añadida al final' };
354
+ }
355
+
356
+ /** El perfil: sus bundles, en orden. Si ya existe, NO se pisa: se completa. */
357
+ function prepararPerfil(perfil, nombre, bundles, patchReload) {
358
+ const manifest = join(perfil, 'package.json');
359
+ let pkg;
360
+ if (existsSync(manifest)) {
361
+ pkg = JSON.parse(readFileSync(manifest, 'utf8'));
362
+ } else {
363
+ pkg = { name: nombre, private: true, dependencies: {} };
364
+ }
365
+ pkg.dsh = pkg.dsh ?? {};
366
+ pkg.dsh.profile = pkg.dsh.profile ?? {};
367
+ const lista = (pkg.dsh.profile.bundles = pkg.dsh.profile.bundles ?? []);
368
+ for (const b of bundles) {
369
+ if (!lista.includes(b)) lista.push(b);
370
+ }
371
+ pkg.dsh.profile.patchReload = pkg.dsh.profile.patchReload ?? patchReload;
372
+ mkdirSync(perfil, { recursive: true });
373
+ writeFileSync(manifest, JSON.stringify(pkg, null, 2) + '\n');
374
+ escribirSiFalta(join(perfil, 'cordis.patch.yml'), PLANTILLA_PARCHE);
375
+ escribirSiFalta(join(perfil, 'pnpm-workspace.yaml'), PLANTILLA_PNPM);
376
+ const capa = asegurarCapaPresets(perfil);
377
+ return capa;
378
+ }
379
+
380
+ /**
381
+ * Copia un árbol de ficheros escribiendo SÓLO lo que cambia: lo que ya está
382
+ * igual no se toca (y se cuenta aparte). Se usa para la piel dentro del perfil
383
+ * y para los modos dentro de la casa.
384
+ */
385
+ function copiarArbol(origen, destino, cuenta) {
386
+ mkdirSync(destino, { recursive: true });
387
+ for (const entrada of readdirSync(origen, { withFileTypes: true })) {
388
+ if (entrada.name === 'node_modules' || entrada.name === '.git') continue;
389
+ const o = join(origen, entrada.name);
390
+ const d = join(destino, entrada.name);
391
+ if (entrada.isDirectory()) {
392
+ copiarArbol(o, d, cuenta);
393
+ continue;
394
+ }
395
+ const contenido = readFileSync(o);
396
+ if (existsSync(d) && statSync(d).isFile() && Buffer.compare(readFileSync(d), contenido) === 0) {
397
+ cuenta.iguales += 1;
398
+ continue;
399
+ }
400
+ writeFileSync(d, contenido);
401
+ cuenta.copiados += 1;
402
+ }
403
+ return cuenta;
404
+ }
405
+
406
+ /** Escribe un fichero de texto entero, creando la carpeta que haga falta. */
407
+ function escribir(ruta, texto) {
408
+ mkdirSync(dirname(ruta), { recursive: true });
409
+ writeFileSync(ruta, texto, { mode: 0o600 });
410
+ }
411
+
412
+ // ── 2 · la casa: estreno, aviso, modos y modelo por defecto ─────────────────
413
+ /** 2a · El aviso «Internal Testing», dado por aceptado (sin pisar lo que ya haya). */
414
+ function aceptarAviso(casa) {
415
+ const ruta = join(casa, 'settings.yaml');
416
+ let actual = '';
417
+ try { actual = readFileSync(ruta, 'utf8'); } catch { actual = ''; }
418
+ if (/welcomeNoticeVersion\s*:/.test(actual)) return 'ya estaba';
419
+ const seccion = (actual.trim() === '' ? '' : actual.replace(/\s*$/, '\n'))
420
+ + 'ui-onboarding:\n welcomeNoticeVersion: ' + AVISO_ACEPTADO + '\n';
421
+ mkdirSync(casa, { recursive: true });
422
+ writeFileSync(ruta, seccion, { mode: 0o600 });
423
+ return 'puesto';
424
+ }
425
+
426
+ /** El primer proveedor con clave en el entorno. SÓLO se mira si existe. */
427
+ function primerProveedorConClave(entorno) {
428
+ for (const candidato of PROVEEDORES_POR_ORDEN) {
429
+ const valor = entorno[candidato.variable];
430
+ // Un export vacío es «no puesta» por convención del shell. Nunca se mira
431
+ // el contenido: sólo si hay algo.
432
+ if (typeof valor === 'string' && valor.trim() !== '') return candidato;
433
+ }
434
+ return null;
435
+ }
436
+
437
+ /** El `settings.yaml` de una casa, como objeto; null si no se puede leer. */
438
+ function leerAjustes(casa) {
439
+ try {
440
+ return yaml.load(readFileSync(join(casa, 'settings.yaml'), 'utf8')) ?? {};
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
445
+
446
+ /** El primer modelo que la casa declara para un proveedor, si declara alguno. */
447
+ function primerModeloDeclarado(ajustes, proveedor) {
448
+ const declarado = ajustes?.['llm-pi-ai']?.providers?.[proveedor]?.models;
449
+ if (!Array.isArray(declarado) || declarado.length === 0) return null;
450
+ const primero = declarado[0];
451
+ return typeof primero === 'string' ? primero : (primero?.id ?? null);
452
+ }
453
+
454
+ /**
455
+ * Reescribe el bloque `agent-default-model:` de un `settings.yaml` sin tocar
456
+ * nada más: ni los comentarios, ni los proveedores, ni el orden del documento.
457
+ * Si el bloque no está, se añade al final.
458
+ */
459
+ function ponerModeloEnTexto(texto, proveedor, modelo) {
460
+ const bloque = 'agent-default-model:\n provider: ' + proveedor + '\n model: ' + JSON.stringify(modelo) + '\n';
461
+ const bloqueActual = /^agent-default-model:[ \t]*\n(?:[ \t]+[^\n]*\n)*/m;
462
+ if (bloqueActual.test(texto)) return texto.replace(bloqueActual, bloque);
463
+ return (texto.trim() === '' ? '' : texto.replace(/\s*$/, '\n')) + '\n' + bloque;
464
+ }
465
+
466
+ /**
467
+ * La casa: la PRIMERA vez se estrena con los ajustes de fábrica (proveedores,
468
+ * aviso aceptado y permiso sin preguntar) y con el modelo del primer proveedor
469
+ * con clave. Si ya existe, NO se pisa nada del usuario.
470
+ */
471
+ function estrenarCasa(casa, entorno) {
472
+ const ruta = join(casa, 'settings.yaml');
473
+ if (existsSync(ruta)) return { nueva: false, aviso: aceptarAviso(casa), modelo: null };
474
+ mkdirSync(casa, { recursive: true });
475
+ let texto = readFileSync(FABRICA_ORIGEN, 'utf8');
476
+ const elegido = primerProveedorConClave(entorno);
477
+ if (elegido !== null) {
478
+ const ajustes = yaml.load(texto) ?? {};
479
+ const modelo = primerModeloDeclarado(ajustes, elegido.proveedor) ?? elegido.modelo;
480
+ texto = ponerModeloEnTexto(texto, elegido.proveedor, modelo);
481
+ writeFileSync(ruta, texto, { mode: 0o600 });
482
+ return {
483
+ nueva: true,
484
+ aviso: 'puesto (fábrica)',
485
+ modelo: { proveedor: elegido.proveedor, modelo, variable: elegido.variable },
486
+ };
487
+ }
488
+ writeFileSync(ruta, texto, { mode: 0o600 });
489
+ return { nueva: true, aviso: 'puesto (fábrica)', modelo: null };
490
+ }
491
+
492
+ /**
493
+ * Deja el `settings.yaml` de una casa con `agent-presets.default: modo-rata`
494
+ * cuando el valor que hay es uno de los que HAY QUE migrar
495
+ * ({@link PRESETS_A_MIGRAR}: el `standard` de fábrica, los tres modos que ya no
496
+ * existen y los de serie del motor, que no resuelven con la raíz de serie
497
+ * apagada). Un valor elegido por el usuario —uno de los 9 modos de la casa, o su
498
+ * propio preset— se respeta: no se pelea con quien manda en su casa.
499
+ * Se hace sobre el TEXTO (como `ponerModeloEnTexto`) para no llevarse por
500
+ * delante comentarios ni el orden del documento.
501
+ */
502
+ function ponerPresetPorDefecto(casa, preset = PRESET_POR_DEFECTO) {
503
+ const ruta = join(casa, 'settings.yaml');
504
+ if (!existsSync(ruta)) return { cambiado: false, motivo: 'la casa no tiene settings.yaml' };
505
+ const texto = readFileSync(ruta, 'utf8');
506
+ let actual;
507
+ try {
508
+ actual = (yaml.load(texto) ?? {})?.['agent-presets']?.default;
509
+ } catch {
510
+ return { cambiado: false, motivo: 'el settings.yaml de la casa no se puede leer como YAML: no lo toco' };
511
+ }
512
+ if (actual === preset) return { cambiado: false, motivo: 'ya estaba en ' + preset };
513
+ if (typeof actual === 'string' && actual.trim() !== '' && !PRESETS_A_MIGRAR.includes(actual)) {
514
+ return { cambiado: false, motivo: 'la casa tiene «' + actual + '» puesto a mano: se respeta' };
515
+ }
516
+ const bloque = 'agent-presets:\n default: ' + preset + '\n';
517
+ const bloqueActual = /^agent-presets:[ \t]*\n(?:[ \t]+[^\n]*\n)*/m;
518
+ let nuevo;
519
+ if (bloqueActual.test(texto)) nuevo = texto.replace(bloqueActual, bloque);
520
+ else if (/^agent-presets:[ \t]*\S.*$/m.test(texto)) nuevo = texto.replace(/^agent-presets:[ \t]*\S.*$/m, bloque.trimEnd());
521
+ else nuevo = (texto.trim() === '' ? '' : texto.replace(/\s*$/, '\n')) + '\n' + bloque;
522
+ writeFileSync(ruta, nuevo, { mode: 0o600 });
523
+ return { cambiado: true, antes: actual ?? '(no estaba)', motivo: (actual ?? '(no estaba)') + ' → ' + preset };
524
+ }
525
+
526
+ /**
527
+ * El IDIOMA de la casa: español (R21). DSH guarda el idioma elegido en
528
+ * `settings.yaml` → `locale.preference` (esquema del paquete
529
+ * `@deepseek-ai/dsh-client-locale`: `{preference: <etiqueta BCP 47>}`) y, si no
530
+ * hay nada apuntado, se lo pregunta al navegador. RATACODE es una casa en
531
+ * español, así que se apunta «es» —pero SÓLO si la casa no ha elegido ya otro
532
+ * idioma: la elección del usuario no se toca nunca—. Se escribe sobre el TEXTO,
533
+ * como `ponerPresetPorDefecto`, para no llevarse por delante comentarios ni el
534
+ * orden del documento.
535
+ * @param casa - la casa de RATACODE.
536
+ * @returns `{cambiado, motivo}`.
537
+ */
538
+ function ponerIdiomaPorDefecto(casa, idioma = IDIOMA_POR_DEFECTO) {
539
+ const ruta = join(casa, 'settings.yaml');
540
+ if (!existsSync(ruta)) return { cambiado: false, motivo: 'la casa no tiene settings.yaml' };
541
+ const texto = readFileSync(ruta, 'utf8');
542
+ let actual;
543
+ try {
544
+ actual = (yaml.load(texto) ?? {})?.locale?.preference;
545
+ } catch {
546
+ return { cambiado: false, motivo: 'el settings.yaml de la casa no se puede leer como YAML: no lo toco' };
547
+ }
548
+ if (actual === idioma) return { cambiado: false, motivo: 'ya estaba en ' + idioma };
549
+ if (typeof actual === 'string' && actual.trim() !== '') {
550
+ return { cambiado: false, motivo: 'la casa tiene «' + actual + '» puesto a mano: se respeta' };
551
+ }
552
+ const bloque = 'locale:\n preference: ' + idioma + '\n';
553
+ const bloqueActual = /^locale:[ \t]*\n(?:[ \t]+[^\n]*\n)*/m;
554
+ let nuevo;
555
+ if (bloqueActual.test(texto)) nuevo = texto.replace(bloqueActual, bloque);
556
+ else nuevo = (texto.trim() === '' ? '' : texto.replace(/\s*$/, '\n')) + '\n' + bloque;
557
+ writeFileSync(ruta, nuevo, { mode: 0o600 });
558
+ return { cambiado: true, antes: actual ?? '(no estaba)', motivo: (actual ?? '(no estaba)') + ' → ' + idioma };
559
+ }
560
+
561
+ /**
562
+ * El ASPECTO de la casa (R21): los tres temas (RATACODE PINK, RATACODE YELLOW
563
+ * y MINIMAL) los aplica la vía OFICIAL de temas de DSH, pero el motor no admite
564
+ * un id de tema de fuera en su esquema de ajustes (`light`/`dark`/`system`,
565
+ * `dsh-client-ui-theme`: `THEME_PREFERENCES`), así que CUÁL está puesto se
566
+ * apunta en `<casa>\tema.txt` (una palabra) y la piel lo vuelve a poner al
567
+ * abrir. Aquí sólo se deja apuntado el de fábrica: no se pisa una elección.
568
+ */
569
+ function ponerTemaPorDefecto(casa, tema = TEMA_POR_DEFECTO) {
570
+ const ruta = join(casa, 'tema.txt');
571
+ if (existsSync(ruta)) {
572
+ const leido = readFileSync(ruta, 'utf8').trim();
573
+ if (TEMAS_DE_LA_CASA.includes(leido)) return { cambiado: false, motivo: 'ya estaba en ' + leido };
574
+ }
575
+ mkdirSync(casa, { recursive: true });
576
+ writeFileSync(ruta, tema + '\n', { mode: 0o600 });
577
+ return { cambiado: true, motivo: tema };
578
+ }
579
+
580
+ /**
581
+ * Los modos, EN TODA CASA (no sólo al estrenar): copia los 9 de RATACODE,
582
+ * borra los presets que salieron en versiones anteriores (el enlazador, el
583
+ * promptista y el escritor: `copiarArbol` sólo escribe, nunca borra) y pasa
584
+ * `agent-presets.default` a `modo-rata` si apunta a algo que ya no resuelve
585
+ * (ver {@link ponerPresetPorDefecto}). La elección del usuario se respeta.
586
+ */
587
+ function asegurarModos(casa) {
588
+ const presets = join(casa, '.agent-presets');
589
+ const cuenta = copiarArbol(MODOS_ORIGEN, presets, { copiados: 0, iguales: 0 });
590
+ const borrados = [];
591
+ for (const viejo of PRESETS_VIEJOS) {
592
+ const ruta = join(presets, viejo);
593
+ if (!existsSync(ruta)) continue;
594
+ try {
595
+ rmSync(ruta, { recursive: true, force: true });
596
+ borrados.push(viejo);
597
+ } catch { /* si no se deja borrar, el aviso de abajo lo dice */ }
598
+ }
599
+ return { cuenta, borrados, preset: ponerPresetPorDefecto(casa), presets };
600
+ }
601
+
602
+ // ── 3 · el espacio de trabajo ───────────────────────────────────────────────
603
+ /**
604
+ * Registra una carpeta en el registro de espacios de DSH
605
+ * (`<casa>\storages\workspace.json`), con el formato medido de
606
+ * `@deepseek-ai/dsh-workspace` (`lib/types/spec.js`): dominio `workspace`
607
+ * versión 2, tabla `workspaces` con `{path,title,sessionIds,createdAt,updatedAt}`
608
+ * y el global `{initialized, workspaceIds, archivedSessionIds}` donde
609
+ * `workspaceIds` es el orden de la barra lateral (el nuevo va el primero).
610
+ *
611
+ * La ruta se guarda CANÓNICA (`fs.realpath`), que es la única canon de
612
+ * identidad del registro: si no, DSH crearía un segundo espacio para el mismo
613
+ * sitio. Si el espacio ya está, no se toca (ni el orden ni el título).
614
+ */
615
+ function registrarEspacio(casa, carpeta) {
616
+ const canonica = realpathSync(carpeta);
617
+ const ruta = join(casa, 'storages', 'workspace.json');
618
+ let doc = null;
619
+ if (existsSync(ruta)) {
620
+ try { doc = JSON.parse(readFileSync(ruta, 'utf8')); } catch { doc = null; }
621
+ }
622
+ if (doc !== null && doc?.unit?.name !== 'workspace') doc = null;
623
+ if (doc !== null && doc.unit.version !== 2) {
624
+ return { registrado: false, motivo: 'el registro de espacios de esta casa es versión ' + doc.unit.version
625
+ + ', y yo sé escribir la 2: no lo toco' };
626
+ }
627
+ if (doc === null) {
628
+ doc = {
629
+ unit: { name: 'workspace', version: 2 },
630
+ global: { initialized: true, workspaceIds: [], archivedSessionIds: [] },
631
+ tables: { workspaces: {} },
632
+ };
633
+ }
634
+ doc.global = doc.global ?? {};
635
+ if (!Array.isArray(doc.global.workspaceIds)) doc.global.workspaceIds = [];
636
+ if (!Array.isArray(doc.global.archivedSessionIds)) doc.global.archivedSessionIds = [];
637
+ doc.tables = doc.tables ?? {};
638
+ doc.tables.workspaces = doc.tables.workspaces ?? {};
639
+ for (const [id, registro] of Object.entries(doc.tables.workspaces)) {
640
+ if (registro?.path === canonica) return { registrado: true, nuevo: false, id, canonica };
641
+ }
642
+ const id = randomUUID();
643
+ const ahora = new Date().toISOString();
644
+ doc.tables.workspaces[id] = {
645
+ path: canonica,
646
+ title: basename(canonica) || canonica,
647
+ sessionIds: [],
648
+ createdAt: ahora,
649
+ updatedAt: ahora,
650
+ };
651
+ doc.global.workspaceIds = [id, ...doc.global.workspaceIds];
652
+ doc.global.initialized = true;
653
+ mkdirSync(dirname(ruta), { recursive: true });
654
+ const temporal = ruta + '.' + process.pid + '.tmp';
655
+ writeFileSync(temporal, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
656
+ renameSync(temporal, ruta);
657
+ return { registrado: true, nuevo: true, id, canonica };
658
+ }
659
+
660
+ /** Comprueba que una carpeta existe y es una carpeta, o para con un mensaje claro. */
661
+ function exigirCarpeta(carpeta) {
662
+ if (!existsSync(carpeta)) throw new Error('la carpeta de trabajo no existe: ' + carpeta);
663
+ if (!statSync(carpeta).isDirectory()) throw new Error('la carpeta de trabajo no es una carpeta: ' + carpeta);
664
+ return carpeta;
665
+ }
666
+
667
+ // ── 4 · el motor ────────────────────────────────────────────────────────────
668
+ function binDelMotor() {
669
+ const require = createRequire(import.meta.url);
670
+ const manifiesto = require.resolve('@deepseek-ai/dsh/package.json');
671
+ const pkg = JSON.parse(readFileSync(manifiesto, 'utf8'));
672
+ const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.dsh;
673
+ if (!rel) throw new Error('el motor no declara su binario (¿instalación a medias?)');
674
+ const bin = join(dirname(manifiesto), rel);
675
+ if (!existsSync(bin)) throw new Error('no encuentro el motor en ' + bin);
676
+ return { bin, version: pkg.version };
677
+ }
678
+
679
+ /**
680
+ * El parche que fija el selector de carpeta EN LA WEB. `dsh-web-app` trae
681
+ * `dsh-host-directory-picker-auto`, que resuelve «native» en Windows y abre el
682
+ * diálogo del sistema: una ventana que quien maneja RATACODE desde fuera (un
683
+ * agente, un chat) no puede ver ni contestar. DSH documenta el punto de
684
+ * intercambio: componer el backend «browse» directamente. Hay que montar las
685
+ * DOS caras (backend de host + superficie de cliente); con una sola, la
686
+ * ventana no sale o la fila «Añadir espacio…» no aparece.
687
+ */
688
+ function parcheSelectorCarpeta(casa) {
689
+ const ruta = join(casa, 'perfiles-parche', 'web-selector-carpeta.yml');
690
+ escribir(ruta, [
691
+ '# RATACODE · el selector de carpeta, EN LA WEB (nunca una ventana de Windows).',
692
+ '# Se apaga la fila adaptativa de dsh-web-app y se compone el par «browse».',
693
+ '- id: directory-picker',
694
+ ' disabled: true',
695
+ '',
696
+ '- insert:',
697
+ " - id: directory-picker-browse",
698
+ " name: '@deepseek-ai/dsh-host-directory-picker-browse'",
699
+ '',
700
+ " - id: ui-directory-picker-browse",
701
+ " name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'",
702
+ '',
703
+ ].join('\n'));
704
+ return ruta;
705
+ }
706
+
707
+ /**
708
+ * Resuelve `--modelo X` contra los proveedores que la casa declara.
709
+ * Acepta `proveedor:modelo` y, si no, busca qué proveedor declara ese modelo.
710
+ * Si no lo declara ninguno, usa el proveedor por defecto con ese modelo.
711
+ */
712
+ function resolverModelo(casa, pedido, porDefecto) {
713
+ if (pedido.includes(':')) {
714
+ const [proveedor, ...resto] = pedido.split(':');
715
+ return { proveedor, modelo: resto.join(':') };
716
+ }
717
+ const ajustes = leerAjustes(casa) ?? {};
718
+ const proveedores = ajustes?.['llm-pi-ai']?.providers ?? {};
719
+ for (const [proveedor, perfil] of Object.entries(proveedores)) {
720
+ const modelos = Array.isArray(perfil?.models) ? perfil.models : [];
721
+ for (const m of modelos) {
722
+ if ((typeof m === 'string' ? m : m?.id) === pedido) return { proveedor, modelo: pedido };
723
+ }
724
+ }
725
+ const actual = ajustes?.['agent-default-model'] ?? porDefecto ?? PROVEEDORES_POR_ORDEN[0];
726
+ return { proveedor: actual.provider ?? actual.proveedor, modelo: pedido };
727
+ }
728
+
729
+ /**
730
+ * El parche de `--modelo`: el documento de ajustes del motor pasa a ser una
731
+ * COPIA con el modelo cambiado. Así el encargo usa el modelo pedido sin tocar
732
+ * el `settings.yaml` del usuario (la capa de usuario de DSH manda sobre la
733
+ * composición, así que un parche de composición no bastaría).
734
+ */
735
+ function parcheModelo(casa, eleccion) {
736
+ const original = join(casa, 'settings.yaml');
737
+ const texto = existsSync(original) ? readFileSync(original, 'utf8') : '';
738
+ const copia = join(casa, 'perfiles-parche', 'headless-' + process.pid + '-settings.yaml');
739
+ escribir(copia, ponerModeloEnTexto(texto, eleccion.proveedor, eleccion.modelo));
740
+ const ruta = join(casa, 'perfiles-parche', 'headless-modelo.yml');
741
+ escribir(ruta, [
742
+ '# RATACODE · sólo para ESTE encargo: los ajustes son una copia con el modelo',
743
+ '# cambiado, así no se toca el modelo que el usuario tenga guardado.',
744
+ '- id: settings',
745
+ ' config:',
746
+ ' path: ' + JSON.stringify(copia),
747
+ '',
748
+ ].join('\n'));
749
+ return { parche: ruta, copia };
750
+ }
751
+
752
+ function matarArbol(hijo) {
753
+ if (!hijo || hijo.killed || hijo.pid === undefined) return;
754
+ try {
755
+ if (process.platform === 'win32') {
756
+ spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'taskkill /pid ' + hijo.pid + '/T /F'], { windowsHide: true, stdio: 'ignore' });
757
+ } else hijo.kill('SIGTERM');
758
+ } catch { /* el hijo ya se fue */ }
759
+ }
760
+
761
+ /**
762
+ * ¿Hay alguien escuchando ya en ese puerto? Se pregunta ANTES de arrancar el
763
+ * motor: si está ocupado, el fallo del motor son 45 líneas de traza en inglés
764
+ * con rutas internas (`EADDRINUSE`), y el usuario nuevo se come todas.
765
+ * @param {number} puerto - puerto a probar.
766
+ * @param {string} host - a quién preguntar (loopback).
767
+ * @returns {Promise<boolean>}
768
+ */
769
+ function puertoOcupado(puerto, host = '127.0.0.1') {
770
+ return new Promise((listo) => {
771
+ const s = connect({ host, port: puerto });
772
+ s.setTimeout(1000);
773
+ const terminar = (ocupado) => { s.destroy(); listo(ocupado); };
774
+ s.once('connect', () => terminar(true));
775
+ s.once('error', () => terminar(false));
776
+ s.once('timeout', () => terminar(false));
777
+ });
778
+ }
779
+
780
+ /**
781
+ * ¿Hay credencial para este proveedor? Se mira el ENTORNO y, si el fichero de
782
+ * claves de la casa existe, se da por buena (la resuelve el motor). Nunca se lee
783
+ * el valor de nada.
784
+ * @param {string} casa - la casa de RATACODE.
785
+ * @param {object} ajustes - el settings.yaml ya leído.
786
+ * @param {string} proveedor - la ruta del proveedor.
787
+ * @returns {{nombre: string, esta: boolean, enLaCasa: boolean}|null} null si la ruta no declara credencial.
788
+ */
789
+ function credencialDe(casa, ajustes, proveedor) {
790
+ const enLaCasa = existsSync(join(casa, '.credentials.yaml'));
791
+ if (proveedor === 'deepseek-official') {
792
+ const nombre = ajustes?.['llm-deepseek']?.apiKeyEnv ?? 'DEEPSEEK_API_KEY';
793
+ return { nombre, esta: hayVariable(nombre), enLaCasa };
794
+ }
795
+ const perfil = ajustes?.['llm-pi-ai']?.providers?.[proveedor];
796
+ const nombre = perfil && typeof perfil === 'object' ? perfil.apiKeyEnv : undefined;
797
+ if (typeof nombre !== 'string' || nombre.trim() === '') return null;
798
+ return { nombre, esta: hayVariable(nombre), enLaCasa };
799
+ }
800
+
801
+ /** Las claves ya NO vienen del entorno: el motor arranca sin ellas (ver `entornoSinClaves`). */
802
+ function hayVariable() {
803
+ return false;
804
+ }
805
+
806
+ /** Dónde el panel deja dicho que el usuario ya vio el aviso de migración. */
807
+ function rutaAvisoVisto(casa) {
808
+ return join(casa, 'aviso-claves-visto');
809
+ }
810
+
811
+ /** Dónde se dejan los NOMBRES de las variables de claves que hay en Windows. */
812
+ function rutaAvisoClaves(casa) {
813
+ return join(casa, 'aviso-claves.txt');
814
+ }
815
+
816
+ /**
817
+ * R22 §4 · La migración, dicha UNA vez. La primera vez que una casa arranca con
818
+ * esta versión, si en Windows hay variables de claves de proveedores (de las que
819
+ * RATACODE ya NO hace caso), se dejan sus NOMBRES —nunca sus valores— en
820
+ * `<casa>\aviso-claves.txt`, y el panel lo enseña en una línea. El panel
821
+ * comprueba además que esa clave no esté ya puesta en la casa: si está, no hay
822
+ * nada que avisar. Cuando el usuario lo cierra, queda `<casa>\aviso-claves-visto`
823
+ * y no vuelve.
824
+ * @param {string} casa - la casa de RATACODE.
825
+ * @returns {string[]} los nombres encontrados (para el registro).
826
+ */
827
+ function anotarVariablesDeClaves(casa) {
828
+ const ruta = rutaAvisoClaves(casa);
829
+ if (existsSync(rutaAvisoVisto(casa))) {
830
+ try { rmSync(ruta, { force: true }); } catch { /* da igual */ }
831
+ return [];
832
+ }
833
+ const nombres = new Set([
834
+ 'B_AI_API_KEY', 'BAI_API_KEY', 'OPENROUTER_API_KEY', 'DEEPSEEK_API_KEY', 'GROQ_API_KEY',
835
+ 'GEMINI_API_KEY', 'NVIDIA_API_KEY', 'SAMBANOVA_API_KEY', 'CLOUDFLARE_API_KEY', 'CLOUDFLARE_API_TOKEN',
836
+ ]);
837
+ const ajustes = leerAjustes(casa) ?? {};
838
+ const nativa = ajustes?.['llm-deepseek']?.apiKeyEnv;
839
+ if (typeof nativa === 'string' && nativa.trim() !== '') nombres.add(nativa.trim());
840
+ for (const perfil of Object.values(ajustes?.['llm-pi-ai']?.providers ?? {})) {
841
+ if (perfil && typeof perfil.apiKeyEnv === 'string' && perfil.apiKeyEnv.trim() !== '') nombres.add(perfil.apiKeyEnv.trim());
842
+ }
843
+ // SÓLO se mira si la variable existe: su valor no se lee ni se copia a ningún sitio.
844
+ const puestas = [...nombres].filter((nombre) => {
845
+ const valor = process.env[nombre];
846
+ return typeof valor === 'string' && valor.trim() !== '';
847
+ });
848
+ try {
849
+ if (puestas.length === 0) rmSync(ruta, { force: true });
850
+ else writeFileSync(ruta, puestas.join('\n') + '\n', { mode: 0o600 });
851
+ } catch { /* el aviso es una ayuda, no un requisito para arrancar */ }
852
+ return puestas;
853
+ }
854
+
855
+ /**
856
+ * El entorno del motor SIN las variables de claves de proveedores. Si llegan del
857
+ * entorno, el motor las da por puestas y NO deja editarlas en Ajustes › Models.
858
+ * La única fuente de claves es la casa (lo que se pega en Ajustes › Models).
859
+ */
860
+ function entornoSinClaves(casa) {
861
+ const fuera = new Set(['B_AI_API_KEY', 'BAI_API_KEY', 'OPENROUTER_API_KEY', 'DEEPSEEK_API_KEY', 'GROQ_API_KEY',
862
+ 'GEMINI_API_KEY', 'NVIDIA_API_KEY', 'SAMBANOVA_API_KEY', 'CLOUDFLARE_API_KEY', 'CLOUDFLARE_API_TOKEN']);
863
+ const ajustes = leerAjustes(casa) ?? {};
864
+ const nativa = ajustes?.['llm-deepseek']?.apiKeyEnv;
865
+ if (typeof nativa === 'string') fuera.add(nativa);
866
+ for (const perfil of Object.values(ajustes?.['llm-pi-ai']?.providers ?? {})) {
867
+ if (perfil && typeof perfil.apiKeyEnv === 'string') fuera.add(perfil.apiKeyEnv);
868
+ }
869
+ const env = { ...process.env, DSH_HOME: casa };
870
+ for (const k of Object.keys(env)) if (fuera.has(k.toUpperCase())) delete env[k];
871
+ return env;
872
+ }
873
+
874
+ // ── el encargo sin pantalla ─────────────────────────────────────────────────
875
+ function correrHeadless({ motor, casa, carpeta, perfilHeadless, encargo, modelo }) {
876
+ const parches = [];
877
+ let temporales = [];
878
+ if (modelo !== null) {
879
+ const ajustes = leerAjustes(casa) ?? {};
880
+ const porDefecto = ajustes?.['agent-default-model'];
881
+ const eleccion = resolverModelo(casa, modelo, porDefecto);
882
+ const { parche, copia } = parcheModelo(casa, eleccion);
883
+ parches.push(parche);
884
+ temporales = [copia, parche];
885
+ process.stdout.write('RATACODE · headless · modelo de este encargo: ' + eleccion.modelo
886
+ + ' (proveedor ' + eleccion.proveedor + ')\n');
887
+ }
888
+ process.stdout.write('RATACODE · headless · ' + motor.version + ' · casa: ' + casa + '\n');
889
+ process.stdout.write('RATACODE · headless · carpeta de trabajo: ' + carpeta + '\n');
890
+ process.stdout.write('RATACODE · headless · perfil: ' + perfilHeadless + '\n');
891
+
892
+ const args = [motor.bin, '--profile', 'headless'];
893
+ for (const p of parches) args.push('--patch', p);
894
+ args.push(encargo);
895
+ const empezo = Date.now();
896
+ anotar(casa, 'RATACODE headless arranca · carpeta ' + carpeta);
897
+ const hijo = spawn(process.execPath, args, {
898
+ cwd: carpeta,
899
+ env: entornoSinClaves(casa),
900
+ stdio: ['ignore', 'inherit', 'inherit'],
901
+ windowsHide: true,
902
+ });
903
+ const limpiar = () => {
904
+ for (const ruta of temporales) {
905
+ try { rmSync(ruta, { force: true }); } catch { /* da igual: son temporales */ }
906
+ }
907
+ temporales = [];
908
+ };
909
+ hijo.on('error', (e) => {
910
+ process.stderr.write('RATACODE · el motor no arrancó: ' + e.message + '\n');
911
+ anotar(casa, 'el motor headless no arrancó · ' + e.message);
912
+ limpiar();
913
+ process.exitCode = 1;
914
+ });
915
+ hijo.on('exit', (codigo, senal) => {
916
+ anotar(casa, 'el motor headless se cerró · ' + cierre(codigo, senal)
917
+ + ' · vivió ' + Math.round((Date.now() - empezo) / 1000) + ' s');
918
+ limpiar();
919
+ process.exitCode = typeof codigo === 'number' ? codigo : 1;
920
+ });
921
+ process.on('SIGINT', () => { anotar(casa, 'paro el motor headless (SIGINT)'); matarArbol(hijo); limpiar(); process.exit(130); });
922
+ process.on('SIGTERM', () => { anotar(casa, 'paro el motor headless (SIGTERM)'); matarArbol(hijo); limpiar(); process.exit(143); });
923
+ }
924
+
925
+ // ── el registro de la casa ──────────────────────────────────────────────────
926
+ /** La ruta del registro: `<casa>\ratacode.log`. */
927
+ function rutaDelLog(casa) {
928
+ return join(casa, 'ratacode.log');
929
+ }
930
+
931
+ /**
932
+ * Deja una línea en `<casa>\ratacode.log`. Sirve para saber POR QUÉ se cerró
933
+ * el motor: hasta hoy, una instancia que se caía sola no dejaba ni rastro.
934
+ * Si el registro no se puede escribir, eso NO para el motor.
935
+ */
936
+ function anotar(casa, texto) {
937
+ try {
938
+ mkdirSync(casa, { recursive: true });
939
+ appendFileSync(rutaDelLog(casa), new Date().toISOString() + ' · ' + texto + '\n');
940
+ } catch { /* el registro es una ayuda, no un requisito */ }
941
+ }
942
+
943
+ /** El texto de un cierre, con código de salida y señal. */
944
+ function cierre(codigo, senal) {
945
+ // En Windows, un proceso matado a la fuerza no deja señal: deja 0xFFFFFFFF.
946
+ const nota = codigo === 4294967295 ? ' (0xFFFFFFFF: matado a la fuerza)' : '';
947
+ return 'código ' + (codigo === null || codigo === undefined ? '(ninguno)' : codigo) + nota
948
+ + ' · señal ' + (senal === null || senal === undefined ? '(ninguna)' : senal);
949
+ }
950
+
951
+ // ── el panel ────────────────────────────────────────────────────────────────
952
+ /**
953
+ * Espera a que el puerto de la URL conteste DE VERDAD. DSH imprime la URL en
954
+ * cuanto monta el servidor, pero hasta que el puerto no escucha esa URL no
955
+ * abre: escribir `url.txt` antes deja un fichero que no lleva a ninguna parte.
956
+ */
957
+ function esperarPuerto(url, plazoMs = 20000) {
958
+ return new Promise((listo) => {
959
+ let host;
960
+ let puerto;
961
+ try {
962
+ const u = new URL(url);
963
+ host = u.hostname === 'localhost' ? '127.0.0.1' : u.hostname.replace(/^\[|\]$/g, '');
964
+ puerto = Number(u.port || (u.protocol === 'https:' ? 443 : 80));
965
+ } catch {
966
+ listo(false);
967
+ return;
968
+ }
969
+ if (!Number.isInteger(puerto) || puerto <= 0) { listo(false); return; }
970
+ const limite = Date.now() + plazoMs;
971
+ const intento = () => {
972
+ const s = connect({ host, port: puerto });
973
+ s.setTimeout(1000);
974
+ const fallo = () => {
975
+ s.destroy();
976
+ if (Date.now() >= limite) { listo(false); return; }
977
+ setTimeout(intento, 150);
978
+ };
979
+ s.once('connect', () => { s.destroy(); listo(true); });
980
+ s.once('error', fallo);
981
+ s.once('timeout', fallo);
982
+ };
983
+ intento();
984
+ });
985
+ }
986
+
987
+ function correrPanel({ motor, casa, carpeta, ordenes }) {
988
+ const parche = parcheSelectorCarpeta(casa);
989
+ const conClavesEnWindows = anotarVariablesDeClaves(casa);
990
+ const args = [motor.bin, '--profile', 'web', '--patch', parche, '--port', String(ordenes.puerto)];
991
+ if (!ordenes.abrir) args.push('--no-open');
992
+ const rutaUrl = join(casa, 'url.txt');
993
+ const empezo = Date.now();
994
+ anotar(casa, 'RATACODE arranca · puerto ' + ordenes.puerto + ' · carpeta ' + carpeta
995
+ + (conClavesEnWindows.length > 0 ? ' · variables de claves en Windows: ' + conClavesEnWindows.join(', ') : ''));
996
+ if (conClavesEnWindows.length > 0) {
997
+ process.stdout.write('RATACODE · en Windows tienes ' + conClavesEnWindows.join(', ')
998
+ + ': RATACODE ya no las usa (las claves se ponen en Ajustes › Models). El panel te lo recuerda una vez.\n');
999
+ }
1000
+ const hijo = spawn(process.execPath, args, {
1001
+ cwd: carpeta,
1002
+ env: entornoSinClaves(casa),
1003
+ stdio: ['ignore', 'pipe', 'pipe'],
1004
+ windowsHide: true,
1005
+ });
1006
+
1007
+ let visto = '';
1008
+ let anunciada = false;
1009
+ /** La URL está dicha y el puerto escucha: ahora sí se escribe url.txt. */
1010
+ const cuandoEscuche = async (url) => {
1011
+ const escucha = await esperarPuerto(url);
1012
+ if (hijo.exitCode !== null) return; // se cerró mientras mirábamos
1013
+ if (!escucha) {
1014
+ process.stderr.write('RATACODE · el motor dio su URL, pero el puerto ' + ordenes.puerto
1015
+ + ' no contesta: NO escribo url.txt (' + url + ')\n');
1016
+ anotar(casa, 'el motor dio URL pero el puerto no contestó en 20 s: no se escribió url.txt');
1017
+ return;
1018
+ }
1019
+ try {
1020
+ mkdirSync(casa, { recursive: true });
1021
+ // La URL de la vez anterior se quita AQUÍ, no al arrancar: si esta
1022
+ // instancia no llega a escuchar, la del panel que ya estaba vivo no se
1023
+ // toca (antes, un segundo `ratacode` sobre la misma casa dejaba al primero
1024
+ // sin url.txt antes de saber siquiera si podría escuchar).
1025
+ try { rmSync(rutaUrl, { force: true }); } catch { /* no había nada que borrar */ }
1026
+ writeFileSync(rutaUrl, url + '\n');
1027
+ } catch (e) {
1028
+ process.stderr.write('RATACODE · no pude escribir ' + rutaUrl + ': ' + e.message + '\n');
1029
+ anotar(casa, 'no se pudo escribir url.txt · ' + e.message);
1030
+ }
1031
+ process.stdout.write('\nRATACODE · ABIERTO EN: ' + url + '\n');
1032
+ process.stdout.write('RATACODE · la URL (con su token) está guardada en: ' + rutaUrl + '\n\n');
1033
+ anotar(casa, 'el motor escucha en el puerto ' + ordenes.puerto + ': url.txt escrito');
1034
+ };
1035
+ const cazar = (trozo) => {
1036
+ if (anunciada) return;
1037
+ visto = (visto + trozo).slice(-8192);
1038
+ const m = /dsh web:\s+(\S+)/.exec(visto);
1039
+ if (!m) return;
1040
+ anunciada = true;
1041
+ cuandoEscuche(m[1]);
1042
+ };
1043
+ hijo.stdout.setEncoding('utf8');
1044
+ hijo.stdout.on('data', (t) => { process.stdout.write(t); cazar(t); });
1045
+ hijo.stderr.setEncoding('utf8');
1046
+ hijo.stderr.on('data', (t) => { process.stderr.write(t); cazar(t); });
1047
+ hijo.on('error', (e) => {
1048
+ process.stderr.write('RATACODE · el motor no arrancó: ' + e.message + '\n');
1049
+ anotar(casa, 'el motor no arrancó · ' + e.message);
1050
+ process.exitCode = 1;
1051
+ });
1052
+ hijo.on('exit', (codigo, senal) => {
1053
+ anotar(casa, 'el motor se cerró · ' + cierre(codigo, senal)
1054
+ + ' · URL ' + (anunciada ? 'sí' : 'no')
1055
+ + ' · vivió ' + Math.round((Date.now() - empezo) / 1000) + ' s');
1056
+ if (!anunciada) {
1057
+ process.stderr.write('RATACODE · el motor se cerró sin dar URL (' + cierre(codigo, senal) + '). '
1058
+ + 'Si el puerto ' + ordenes.puerto + ' está ocupado, prueba: ratacode --port 3778\n');
1059
+ } else if (senal) {
1060
+ process.stderr.write('RATACODE · el motor se cerró por la señal ' + senal + '\n');
1061
+ }
1062
+ process.exitCode = typeof codigo === 'number' ? codigo : 1;
1063
+ });
1064
+
1065
+ const fuera = (motivo) => {
1066
+ if (hijo.exitCode === null && !hijo.killed) anotar(casa, 'paro el motor (' + motivo + ')');
1067
+ matarArbol(hijo);
1068
+ };
1069
+ process.on('SIGINT', () => { fuera('SIGINT'); process.exit(130); });
1070
+ process.on('SIGTERM', () => { fuera('SIGTERM'); process.exit(143); });
1071
+ process.on('exit', (codigo) => { fuera('RATACODE se cierra, código ' + codigo); });
1072
+ }
1073
+
1074
+ /**
1075
+ * `ratacode mcp`: el servidor MCP, como subcomando del mismo binario. Se lanza
1076
+ * como hijo con la entrada y la salida HEREDADAS: por stdio, stdout es del
1077
+ * protocolo MCP y RATACODE no puede escribir ni una letra ahí.
1078
+ *
1079
+ * Antes de arrancarlo se estrena la casa (si es nueva) para que el MCP tenga
1080
+ * los mismos proveedores y modelos que la web, pero TODO lo que cuenta
1081
+ * RATACODE va a stderr.
1082
+ */
1083
+ function correrMcp({ casa, argv }) {
1084
+ const bin = join(PAQUETE, 'mcp', 'bin', 'ratacode-mcp.js');
1085
+ if (!existsSync(bin)) {
1086
+ process.stderr.write('RATACODE · no encuentro el servidor MCP en ' + bin
1087
+ + ' (¿instalación sin la carpeta mcp?)\n');
1088
+ process.exitCode = 1;
1089
+ return;
1090
+ }
1091
+ // `--help` y `--status` no arrancan nada: no se toca la casa (estrenarla en un
1092
+ // «--help» sería un efecto raro, y encima fuera de donde el usuario mira).
1093
+ const soloMira = argv.some((a) => a === '-h' || a === '--help' || a === '--status');
1094
+ if (!soloMira) {
1095
+ const estreno = estrenarCasa(casa, process.env);
1096
+ // Los 9 modos, también en una casa que ya existía: el MCP compone sesiones
1097
+ // por el mismo camino que el panel y necesita el mismo roster.
1098
+ const modos = asegurarModos(casa);
1099
+ process.stderr.write('RATACODE · casa ' + (estreno.nueva ? 'NUEVA estrenada: ' : 'ya existía: ') + casa
1100
+ + ' · modos por defecto: ' + PRESET_POR_DEFECTO
1101
+ + (modos.borrados.length > 0 ? ' · fuera los viejos: ' + modos.borrados.join(', ') : '') + '\n');
1102
+ }
1103
+ const hijo = spawn(process.execPath, [bin, ...argv], { env: entornoSinClaves(casa), stdio: 'inherit', windowsHide: true });
1104
+ hijo.on('error', (e) => {
1105
+ process.stderr.write('RATACODE · el MCP no arrancó: ' + e.message + '\n');
1106
+ process.exitCode = 1;
1107
+ });
1108
+ hijo.on('exit', (codigo) => { process.exitCode = typeof codigo === 'number' ? codigo : 1; });
1109
+ const fuera = () => { matarArbol(hijo); };
1110
+ process.on('SIGINT', () => { fuera(); process.exit(130); });
1111
+ process.on('SIGTERM', () => { fuera(); process.exit(143); });
1112
+ process.on('exit', fuera);
1113
+ }
1114
+
1115
+ async function main() {
1116
+ exigirNode();
1117
+ const ordenes = leerOrdenes(process.argv.slice(2));
1118
+ if (ordenes.ayuda) {
1119
+ process.stdout.write(uso());
1120
+ return;
1121
+ }
1122
+ const casa = ordenes.casa ?? resolve(process.env.RATACODE_HOME || join(homedir(), '.ratacode'));
1123
+ if (ordenes.modo === 'mcp') {
1124
+ correrMcp({ casa, argv: ordenes.mcpArgv });
1125
+ return;
1126
+ }
1127
+ const perfilWeb = join(casa, 'profiles', 'web');
1128
+ const perfilHeadless = join(casa, 'profiles', 'headless');
1129
+ const motor = binDelMotor();
1130
+
1131
+ const cuenta = copiarArbol(PIEL_ORIGEN, join(perfilWeb, 'node_modules', NOMBRE_PLUGIN), { copiados: 0, iguales: 0 });
1132
+ // El texto de la conexión viaja CON el plugin: la piel lo sirve en
1133
+ // /ratacode/handshake (Ajustes > Conexiones) y, copiada dentro del perfil, no
1134
+ // tiene el repositorio al lado. Se copia en <plugin>\apreton\.
1135
+ const cuentaApreton = copiarArbol(APRETON_ORIGEN, join(perfilWeb, 'node_modules', NOMBRE_PLUGIN, 'apreton'), { copiados: 0, iguales: 0 });
1136
+ // Y la ruta de ESTA instalación (la carpeta del paquete, con `mcp\tunel.mjs`
1137
+ // dentro), para que los dos comandos del MCP que enseña Ajustes > Conexiones
1138
+ // lleven la ruta de verdad y no un «<ruta>» que el usuario tenga que buscar.
1139
+ writeFileSync(join(perfilWeb, 'node_modules', NOMBRE_PLUGIN, 'instalacion.txt'), PAQUETE + '\n');
1140
+ const capaWeb = prepararPerfil(perfilWeb, 'dsh-profile-web',
1141
+ ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', NOMBRE_PLUGIN], 'live');
1142
+ prepararPerfil(perfilHeadless, 'dsh-profile-headless',
1143
+ ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'], 'startup');
1144
+ const estreno = estrenarCasa(casa, process.env);
1145
+ // Los 9 modos se copian SIEMPRE, también en una casa que ya existía: los de
1146
+ // versiones anteriores salieron con dos campos obligatorios sin poner y NO
1147
+ // cargaban («invalid config: - $.x missing required value»). `asegurarModos`
1148
+ // sólo escribe lo que cambia, borra los presets de RATACODE que ya no existen
1149
+ // (enlazador, promptista, escritor) y deja `agent-presets.default` en
1150
+ // modo-rata; los presets del USUARIO no se rozan.
1151
+ const modos = asegurarModos(casa);
1152
+ // R21 · el español de la casa: se apunta `locale.preference: es` si la casa no
1153
+ // ha elegido ya otro idioma (el inglés y el chino siguen en Ajustes › General ›
1154
+ // Language, y la elección del usuario se respeta siempre).
1155
+ const idioma = ponerIdiomaPorDefecto(casa);
1156
+ // R21 · el aspecto: se apunta el tema de fábrica (RATACODE PINK) si la casa no
1157
+ // tiene ninguno apuntado. La elección del usuario no se toca.
1158
+ const aspecto = ponerTemaPorDefecto(casa);
1159
+
1160
+ const carpeta = exigirCarpeta(ordenes.carpeta ?? process.cwd());
1161
+ const espacio = registrarEspacio(casa, carpeta);
1162
+
1163
+ process.stdout.write('RATACODE · ' + (motor.version ? 'v' + motor.version : '')
1164
+ + (ordenes.modo === 'headless' ? ' · headless' : ' · puerto ' + ordenes.puerto) + '\n');
1165
+ process.stdout.write('RATACODE · casa: ' + casa + (estreno.nueva ? ' (NUEVA)' : ' (ya existía)')
1166
+ + ' · aviso Internal Testing: ' + estreno.aviso + '\n');
1167
+ process.stdout.write('RATACODE · espacio de trabajo: ' + carpeta
1168
+ + (espacio.registrado
1169
+ ? (espacio.nuevo ? ' (registrado en el registro de espacios)' : ' (ya estaba registrado)')
1170
+ : ' (NO registrado: ' + espacio.motivo + ')') + '\n');
1171
+ process.stdout.write('RATACODE · modelo por defecto: ' + (estreno.modelo
1172
+ ? estreno.modelo.modelo + ' (proveedor ' + estreno.modelo.proveedor + ', porque existe '
1173
+ + estreno.modelo.variable + ' en el entorno)'
1174
+ : (estreno.nueva
1175
+ ? 'el de fábrica (ninguna clave en el entorno: B_AI_API_KEY, OPENROUTER_API_KEY ni DEEPSEEK_API_KEY)'
1176
+ : 'el que ya tuviera la casa (no se toca settings.yaml)')) + '\n');
1177
+ process.stdout.write('RATACODE · piel: ' + cuenta.copiados + ' fichero(s) puesto(s), ' + cuenta.iguales + ' ya estaban igual'
1178
+ + ' · conexiones: ' + cuentaApreton.copiados + ' puesto(s), ' + cuentaApreton.iguales + ' igual\n');
1179
+ process.stdout.write('RATACODE · modos: ' + modos.cuenta.copiados + ' fichero(s) puesto(s), ' + modos.cuenta.iguales
1180
+ + ' ya estaban igual en ' + modos.presets
1181
+ + (modos.borrados.length > 0 ? ' · fuera los viejos: ' + modos.borrados.join(', ') : '')
1182
+ + ' · por defecto: ' + (modos.preset.cambiado ? 'puesto en ' + PRESET_POR_DEFECTO + ' (' + modos.preset.motivo + ')' : modos.preset.motivo) + '\n');
1183
+ if (capaWeb.reparado) {
1184
+ process.stdout.write('RATACODE · tu perfil tenía el parche roto (un `[]` pegado delante de las filas, de la 0.2.0):'
1185
+ + ' reparado · ' + capaWeb.motivo + '\n');
1186
+ }
1187
+ process.stdout.write('RATACODE · proveedores: ' + (estreno.nueva
1188
+ ? 'Ajustes › Models: las 8 APIs con clave (B.AI, OpenRouter, Groq, Google Gemini, NVIDIA NIM, SambaNova, Cloudflare Workers AI y DeepSeek nativo) · Ajustes › Modelos locales: Ollama y LM Studio, sin clave'
1189
+ : 'los que ya tuviera la casa (no se toca settings.yaml): añade a mano los que falten de las 8 APIs') + '\n');
1190
+ process.stdout.write('RATACODE · manos: el texto de la conexión y el MCP viven en Ajustes › Conexiones\n');
1191
+ process.stdout.write('RATACODE · idioma: ' + (idioma.cambiado
1192
+ ? 'español puesto por defecto (' + idioma.motivo + ')'
1193
+ : 'el que ya tuviera la casa (' + idioma.motivo + ')')
1194
+ + ' · inglés y chino siguen en Ajustes › General › Language\n');
1195
+ process.stdout.write('RATACODE · aspecto: ' + (aspecto.cambiado
1196
+ ? 'RATACODE PINK puesto por defecto (' + aspecto.motivo + ')'
1197
+ : 'el que ya tuviera la casa (' + aspecto.motivo + ')')
1198
+ + ' · los tres (PINK, YELLOW, MINIMAL) en Ajustes › General › Aspecto\n');
1199
+
1200
+ if (ordenes.modo === 'headless') {
1201
+ // Antes de arrancar el motor, mira si hay con qué: si el modelo por defecto
1202
+ // apunta a un proveedor sin clave por ningún lado, el motor falla en inglés
1203
+ // y con tripas (`MISSING_CREDENTIAL: llm-pi-ai: no credential for provider
1204
+ // route "b-ai"…`). Mejor dos líneas en español y no arrancar nada.
1205
+ const ajustesCasa = leerAjustes(casa) ?? {};
1206
+ const porDefecto = ajustesCasa['agent-default-model'] ?? {};
1207
+ const eleccion = ordenes.modelo === null
1208
+ ? { proveedor: porDefecto.provider, modelo: porDefecto.model }
1209
+ : resolverModelo(casa, ordenes.modelo, porDefecto);
1210
+ if (typeof eleccion.proveedor === 'string' && eleccion.proveedor !== '') {
1211
+ const credencial = credencialDe(casa, ajustesCasa, eleccion.proveedor);
1212
+ if (credencial !== null && !credencial.esta && !credencial.enLaCasa) {
1213
+ process.stderr.write([
1214
+ '',
1215
+ 'RATACODE · PARA.',
1216
+ '',
1217
+ ' El modelo «' + (eleccion.modelo ?? '(sin nombre)') + '» va por el proveedor «' + eleccion.proveedor
1218
+ + '», y no encuentro su clave (' + credencial.nombre + ').',
1219
+ ' Ponla en la web (Ajustes → Models: queda en ' + join(casa, '.credentials.yaml')
1220
+ + ') o expórtala: ' + credencial.nombre + '=... (y vuelve a lanzarlo).',
1221
+ '',
1222
+ ].join('\n'));
1223
+ process.exitCode = 1;
1224
+ return;
1225
+ }
1226
+ }
1227
+ correrHeadless({ motor, casa, carpeta, perfilHeadless, encargo: ordenes.encargo, modelo: ordenes.modelo });
1228
+ return;
1229
+ }
1230
+ // El puerto, ANTES de arrancar el motor: si está ocupado, el fallo crudo del
1231
+ // motor (EADDRINUSE en inglés, 45 líneas) no le sirve a nadie.
1232
+ if (ordenes.puerto !== 0 && await puertoOcupado(ordenes.puerto)) {
1233
+ process.stderr.write([
1234
+ '',
1235
+ 'RATACODE · PARA.',
1236
+ '',
1237
+ ' El puerto ' + ordenes.puerto + ' ya está ocupado (¿tienes otro RATACODE abierto?).',
1238
+ ' Prueba con otro: ratacode --port ' + (ordenes.puerto === 3778 ? 3779 : 3778),
1239
+ ' O mira quién lo tiene: Get-NetTCPConnection -LocalPort ' + ordenes.puerto,
1240
+ '',
1241
+ ].join('\n'));
1242
+ process.exitCode = 1;
1243
+ return;
1244
+ }
1245
+ correrPanel({ motor, casa, carpeta, ordenes });
1246
+ }
1247
+
1248
+ main().catch((e) => {
1249
+ process.stderr.write('RATACODE · ' + (e && e.message ? e.message : String(e)) + '\n');
1250
+ process.exitCode = 1;
1251
+ });