@danieltmn/tamnora 0.17.0 → 0.18.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +37 -3
  3. package/SECURITY.md +8 -0
  4. package/harness/packages/cli/src/commands/mcp.js +31 -4
  5. package/harness/packages/cli/src/commands/repl.js +7 -2
  6. package/harness/packages/cli/src/commands/run.js +5 -2
  7. package/harness/packages/cli/src/commands/tui.js +9 -2
  8. package/harness/packages/cli/src/lib/runtime.js +23 -1
  9. package/harness/packages/cli/test/tunnel.test.js +13 -1
  10. package/harness/packages/core/src/agent/modes.js +3 -0
  11. package/harness/packages/core/src/config/index.js +3 -1
  12. package/harness/packages/core/src/index.js +15 -1
  13. package/harness/packages/core/src/mcp/catalog.js +79 -0
  14. package/harness/packages/core/src/mcp/index.js +14 -2
  15. package/harness/packages/core/src/mcp/registry.js +29 -1
  16. package/harness/packages/core/src/redact.js +25 -0
  17. package/harness/packages/core/src/skills/catalog.js +78 -0
  18. package/harness/packages/core/src/skills/remote.js +51 -0
  19. package/harness/packages/core/src/tools/executors.js +93 -2
  20. package/harness/packages/core/src/tools/index.js +27 -6
  21. package/harness/packages/core/src/tools/labels.js +2 -0
  22. package/harness/packages/core/src/tools/mcp.js +91 -0
  23. package/harness/packages/core/src/tools/permissions.js +53 -8
  24. package/harness/packages/core/src/tools/registry.js +14 -7
  25. package/harness/packages/core/src/tools/schema.js +86 -0
  26. package/harness/packages/core/src/tts/edge.js +28 -7
  27. package/harness/packages/core/src/tunnel.js +51 -5
  28. package/harness/packages/core/test/compact.test.js +2 -0
  29. package/harness/packages/core/test/labels.test.js +1 -0
  30. package/harness/packages/core/test/mcp-registry.test.js +37 -1
  31. package/harness/packages/core/test/mcp-routing.test.js +115 -0
  32. package/harness/packages/core/test/redact.test.js +22 -0
  33. package/harness/packages/core/test/skills.test.js +11 -0
  34. package/harness/packages/core/test/tools.test.js +97 -3
  35. package/harness/packages/server/public/app.js +501 -23
  36. package/harness/packages/server/public/index.html +41 -2
  37. package/harness/packages/server/public/styles.css +99 -3
  38. package/harness/packages/server/src/lib/agentTools.js +45 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +2 -0
  40. package/harness/packages/server/src/lib/prompt.js +4 -1
  41. package/harness/packages/server/src/lib/questions.js +72 -0
  42. package/harness/packages/server/src/lib/scheduler.js +12 -1
  43. package/harness/packages/server/src/lib/tasks.js +3 -1
  44. package/harness/packages/server/src/routes/chat.js +31 -2
  45. package/harness/packages/server/src/routes/mcp.js +54 -2
  46. package/harness/packages/server/src/routes/preview.js +49 -28
  47. package/harness/packages/server/src/routes/providers.js +12 -0
  48. package/harness/packages/server/src/routes/sessions.js +3 -1
  49. package/harness/packages/server/src/routes/skills.js +48 -1
  50. package/harness/packages/server/src/routes/state.js +8 -1
  51. package/harness/packages/server/src/server.js +37 -1
  52. package/harness/packages/server/test/preview.test.js +111 -0
  53. package/harness/packages/server/test/server.test.js +186 -19
  54. package/harness/packages/server/test/telegram-features.test.js +2 -0
  55. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  56. package/harness/packages/server/test/telegram.test.js +2 -0
  57. package/harness/tamnora.example.json +3 -1
  58. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.18.0] - 2026-10-12
9
+
10
+ ### Agregado
11
+
12
+ - **UI interactiva**: el agente pide un **formulario** (`ask_user`), ofrece
13
+ **botones** (`offer_choices`) o muestra un **checklist** (`show_plan`); la
14
+ consola los renderiza (el formulario pausa el turno, los botones se mandan como
15
+ mensaje, el checklist tiene estado) y los reconstruye al recargar la sesión.
16
+ - **MCP con carga diferida**: cuando hay muchas tools, se expone un índice y un
17
+ router `mcp_call` en vez de inyectar todos los esquemas (`mcpMode` `auto` /
18
+ `eager` / `lazy`; `mcpPinned` fija tools o servidores). Ajustes → MCP muestra
19
+ modo, fijas y conteo en contexto.
20
+ - **Catálogo y búsqueda de MCP y skills**: catálogo curado en "sugeridos" (se
21
+ agregan deshabilitados) y búsqueda en el registro oficial de MCP y en GitHub. El
22
+ agente suma `search_mcp` / `install_mcp` y `search_skills` / `install_skill`.
23
+ - **Preview remoto**: la pestaña preview publica el dev server por Tailscale
24
+ (`tailscale serve --https=8443`) para verlo desde otro dispositivo.
25
+
26
+ ### Cambiado
27
+
28
+ - **Voz (Edge)**: la síntesis usa el host nuevo
29
+ (`api.msedgeservices.com/.../websocket/v1`) con el token como
30
+ `Ocp-Apim-Subscription-Key`; el host viejo respondía 403.
31
+ - **Asesor**: la tarjeta cambia de color y etiqueta según el tipo
32
+ (código / explicación / consejo / resumen).
33
+
34
+ ### Seguridad
35
+
36
+ - **Tools MCP por permisos**: ahora pasan por el mismo control que las locales
37
+ (default `ask`); antes se ejecutaban sin consultar. Se habilita por tool con
38
+ `permissions: { "mcp__...": "allow" }`.
39
+ - **Endurecimiento de `bash`**: piso de seguridad que **deniega** los comandos
40
+ catastróficos aunque la política sea `allow` o `--yes` (`rm -rf /`/`~`, fork
41
+ bomb, `mkfs`, `dd of=/dev/`, formatear/borrar una unidad) y marca como `ask` los
42
+ irreversibles (`git push --force`, `git reset --hard`, `git clean -f`).
43
+ - **Scrub de entorno**: `bash` y los `git` locales ya no heredan las variables con
44
+ nombre de credencial (`*_API_KEY`, `*_TOKEN`, `*PASSWORD`, ...).
45
+
8
46
  ## [0.17.0] - 2026-10-11
9
47
 
10
48
  ### Cambios
package/README.md CHANGED
@@ -22,8 +22,11 @@ con TUI, consola web, tools locales, MCP y evaluacion de modelos.
22
22
  que se inyectan solo cuando el agente toca el archivo.
23
23
  - **Automatizacion**: tareas programadas (`every`, `daily`, cron) que corren solas
24
24
  y **deshacer** un turno (restaura los archivos a como estaban).
25
- - **Contexto y costo**: skills on-demand, auto-compactacion, `max_tokens` por ronda
26
- y prompt caching.
25
+ - **Contexto y costo**: skills on-demand, MCP con **carga diferida** (índice +
26
+ router `mcp_call`), auto-compactacion, `max_tokens` por ronda y prompt caching.
27
+ - **UI interactiva**: el agente pide un **formulario** (`ask_user`), ofrece
28
+ **botones** (`offer_choices`) o muestra un **checklist** (`show_plan`); la
29
+ consola los renderiza.
27
30
  - **Plataforma**: hooks y plugins locales (`.tamnora/hooks`, `.tamnora/plugins`).
28
31
 
29
32
  ## Instalacion
@@ -154,6 +157,20 @@ menu "mas" (los tres puntitos). En el chat:
154
157
  archivo; con **deshacer cambios** (menú `⋮` de cada respuesta) se restauran los
155
158
  archivos y se borra el turno del historial.
156
159
 
160
+ ### UI interactiva
161
+
162
+ El agente no numera preguntas en el texto: cuando necesita decidir algo, usa un
163
+ componente que la consola dibuja.
164
+
165
+ - **Formulario** (`ask_user`): pausa el turno y devuelve las respuestas (texto,
166
+ textarea, select o confirmar). En Telegram/CLI cae a una pregunta en prosa.
167
+ - **Botones** (`offer_choices`): opciones tappables; al tocar una se manda como tu
168
+ mensaje (bifurcaciones A/B/C). No pausa el turno.
169
+ - **Checklist** (`show_plan`): pasos con estado (`pending`/`active`/`done`); es de
170
+ solo lectura y el agente lo reemite para actualizarlo.
171
+
172
+ Los tres se renderizan en vivo y se reconstruyen al recargar la consola.
173
+
157
174
  ### Contexto y costo
158
175
 
159
176
  - **Skills on-demand**: en vez de inyectar todas las skills, el prompt lleva un
@@ -269,14 +286,31 @@ Los MCP stdio **no heredan `process.env`**: reciben un baseline minimo mas el
269
286
  (ver [`SECURITY.md`](SECURITY.md)). Para un servidor de confianza que necesite el
270
287
  entorno completo, `"inheritEnv": true`.
271
288
 
289
+ Cuando hay **muchas** tools MCP, el harness no inyecta todos los esquemas en cada
290
+ turno: expone un **indice** y una tool router `mcp_call({ tool, args })`, y deja
291
+ solo las "calientes" con esquema completo (`mcpMode`: `auto` | `eager` | `lazy`;
292
+ `mcpPinned` fija tools o servidores enteros). Ademas, las tools MCP pasan por los
293
+ **mismos permisos** que las locales (default `ask`): se habilita por tool con
294
+ `permissions: { "mcp__postgres__query": "allow" }`. Se ajustan en Ajustes → MCP.
295
+
296
+ Desde Ajustes → MCP tambien hay un **catalogo de sugeridos** y busqueda en el
297
+ registro oficial; se agregan deshabilitados y los prendes cuando quieras.
298
+
272
299
  ## Tools
273
300
 
274
301
  Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
275
302
  `edit`, `bash`, `fetch_url`), de navegador (`screenshot`, `console_errors`,
276
- `preview_url`), de voz (`speak`), de Git (`git_status`, `git_diff`, `git_log`,
303
+ `preview_url`), de voz (`speak`), de interfaz interactiva (`ask_user`,
304
+ `offer_choices`, `show_plan`), de Git (`git_status`, `git_diff`, `git_log`,
277
305
  `git_commit`) y de conocimiento (`memory`, `skill`), con permisos
278
306
  `allow`/`ask`/`deny` por tool o por patron.
279
307
 
308
+ `bash` esta **endurecido**: un piso de seguridad **deniega** los comandos
309
+ catastroficos (`rm -rf /`/`~`, fork bomb, `mkfs`, `dd of=/dev/`, formatear/borrar
310
+ una unidad) aunque la politica sea `allow`, marca como `ask` los irreversibles
311
+ (`git push --force`, `git reset --hard`) y el shell **no hereda** las variables con
312
+ nombre de credencial (`scrubEnv`), asi que no filtra tus API keys.
313
+
280
314
  `fetch_url` lee URLs (HTML a texto) y avisa cuando un sitio exige JavaScript o
281
315
  esta detras de Cloudflare; para esos casos las tools `screenshot` / `console_errors`
282
316
  usan el **Chrome/Edge/Chromium del sistema** como navegador headless (sin
package/SECURITY.md CHANGED
@@ -35,6 +35,14 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
35
35
  servidores de confianza. Un tool tipo `get-env` no puede leer tus secretos del
36
36
  shell, y los resultados MCP se **redactan** (`redactSecrets`) por si devuelven un
37
37
  token. No pases credenciales en los argumentos de las tools: van por `env`.
38
+ - **Tools locales y `bash`**: `bash` corre en tu maquina como tu usuario, asi que
39
+ hay un piso de seguridad que **no se puede saltear** ni con `bash: "allow"` ni
40
+ con `--yes`: los comandos catastroficos (`rm -rf /`/`~`, fork bomb, `mkfs`,
41
+ `dd of=/dev/`, formatear/borrar una unidad) quedan **denegados**, y los
42
+ irreversibles (`rm -rf *`/`.`, `git push --force`, `git reset --hard`,
43
+ `git clean -f`) al menos `ask`. Ademas el shell y los `git` locales **no heredan**
44
+ las variables con nombre de credencial (`scrubEnv` quita `*_API_KEY`, `*_TOKEN`,
45
+ `*PASSWORD`, ...), asi que `env`/`printenv` no filtran tus claves del proceso.
38
46
 
39
47
  ## Buenas practicas
40
48
 
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { loadConfig, buildMcpRegistry, mcpTransport } from '../lib/core.js';
2
+ import { loadConfig, buildMcpRegistry, mcpTransport, Store, databasePath } from '../lib/core.js';
3
3
  import { parse } from '../lib/util.js';
4
4
  import { color } from '../ui/color.js';
5
5
 
@@ -9,6 +9,26 @@ const OPTIONS = {
9
9
  help: { type: 'boolean', short: 'h' },
10
10
  };
11
11
 
12
+ /** Servidores de `tamnora.json` + los guardados desde la consola (gana la base). */
13
+ function mergedServers(config, store) {
14
+ const fromConfig = Array.isArray(config.mcp) ? config.mcp : [];
15
+ const fromStore = store.listMcpServers().map((row) => ({
16
+ id: row.id,
17
+ name: row.name ?? row.id,
18
+ type: row.transport ?? 'stdio',
19
+ command: row.command ?? undefined,
20
+ args: Array.isArray(row.config?.args) ? row.config.args : [],
21
+ url: row.url ?? undefined,
22
+ env: row.config?.env ?? undefined,
23
+ inheritEnv: row.config?.inheritEnv === true,
24
+ enabled: row.enabled !== false,
25
+ }));
26
+ const byId = new Map();
27
+ for (const server of fromConfig) if (server?.id) byId.set(server.id, server);
28
+ for (const server of fromStore) byId.set(server.id, server);
29
+ return [...byId.values()].filter((server) => server.enabled !== false);
30
+ }
31
+
12
32
  export async function mcpCommand(argv) {
13
33
  const { values } = parse(argv, OPTIONS);
14
34
  if (values.help) {
@@ -18,11 +38,17 @@ export async function mcpCommand(argv) {
18
38
 
19
39
  const cwd = values.dir ? path.resolve(values.dir) : process.cwd();
20
40
  const config = await loadConfig(cwd);
21
- const servers = Array.isArray(config.mcp) ? config.mcp : [];
41
+ const store = new Store(databasePath(cwd));
42
+ let servers;
43
+ try {
44
+ servers = mergedServers(config, store);
45
+ } finally {
46
+ store.close?.();
47
+ }
22
48
 
23
49
  if (!servers.length) {
24
50
  if (values.json) process.stdout.write(`${JSON.stringify({ servers: [] }, null, 2)}\n`);
25
- else process.stdout.write(color.dim('No hay servidores MCP configurados en tamnora.json.\n'));
51
+ else process.stdout.write(color.dim('No hay servidores MCP habilitados (tamnora.json + consola).\n'));
26
52
  return 0;
27
53
  }
28
54
 
@@ -93,7 +119,8 @@ function safeTransport(server) {
93
119
  function printHelp() {
94
120
  console.log(`Uso: tamnora mcp [opciones]
95
121
 
96
- Conecta a los servidores MCP de tamnora.json y lista sus herramientas.
122
+ Conecta a los servidores MCP habilitados (de tamnora.json y de la consola) y
123
+ lista sus herramientas.
97
124
 
98
125
  Opciones:
99
126
  --dir <path> Directorio del proyecto (tamnora.json)
@@ -9,7 +9,7 @@ import {
9
9
  scopeDirective,
10
10
  } from '../lib/core.js';
11
11
  import { parse, databasePath, num } from '../lib/util.js';
12
- import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider } from '../lib/runtime.js';
12
+ import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider, mcpSettings, mcpIndexBlock } from '../lib/runtime.js';
13
13
  import { color } from '../ui/color.js';
14
14
 
15
15
  const OPTIONS = {
@@ -31,11 +31,12 @@ export async function replCommand(argv) {
31
31
 
32
32
  const instructions = await loadInstructions(cwd, config.instructions);
33
33
  let systemExtra = '';
34
+ let mcpBlock = '';
34
35
  const maxRounds = num(values['max-steps'], config.maxRounds);
35
36
  const store = new Store(databasePath(cwd));
36
37
  let strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
37
38
  const buildPrompt = () =>
38
- buildSystemPrompt(instructions, [systemExtra, scopeDirective(strict)].filter(Boolean).join('\n\n'));
39
+ buildSystemPrompt(instructions, [systemExtra, mcpBlock, scopeDirective(strict)].filter(Boolean).join('\n\n'));
39
40
  const resolved = resolveCliProvider(store, config, { provider: values.provider, model: values.model });
40
41
  let providerId = resolved.providerId;
41
42
  let model = resolved.model;
@@ -76,6 +77,8 @@ export async function replCommand(argv) {
76
77
  process.stdout.write(color.yellow(`MCP: ${err.message}\n`));
77
78
  }
78
79
  }
80
+ const { mode: mcpMode, pinned: mcpPinned } = mcpSettings(store, config);
81
+ mcpBlock = mcpIndexBlock(mcp, { mode: mcpMode });
79
82
  const enabled = values['no-tools']
80
83
  ? []
81
84
  : values.tools
@@ -90,6 +93,8 @@ export async function replCommand(argv) {
90
93
  yes: Boolean(values.yes),
91
94
  onAsk,
92
95
  mcp,
96
+ mcpMode,
97
+ mcpPinned,
93
98
  });
94
99
 
95
100
  process.stdout.write(color.dim(`Tamnora REPL · ${providerId}/${model}\n`));
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { loadConfig, loadInstructions, Store, createTools, buildMcpRegistry, scopeDirective } from '../lib/core.js';
3
3
  import { parse, databasePath, num } from '../lib/util.js';
4
- import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider } from '../lib/runtime.js';
4
+ import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider, mcpSettings, mcpIndexBlock } from '../lib/runtime.js';
5
5
  import { color } from '../ui/color.js';
6
6
 
7
7
  const OPTIONS = {
@@ -64,12 +64,15 @@ export async function runCommand(argv) {
64
64
  : Array.isArray(config.tools) && config.tools.length
65
65
  ? config.tools
66
66
  : undefined;
67
+ const { mode: mcpMode, pinned: mcpPinned } = mcpSettings(store, config);
67
68
  const { definitions: toolDefs, execute } = createTools({
68
69
  root: cwd,
69
70
  enabled,
70
71
  permissions: config.permissions,
71
72
  yes: Boolean(values.yes),
72
73
  mcp,
74
+ mcpMode,
75
+ mcpPinned,
73
76
  });
74
77
 
75
78
  let sessionId = values.session;
@@ -108,7 +111,7 @@ export async function runCommand(argv) {
108
111
  customProviders,
109
112
  systemPrompt: buildSystemPrompt(
110
113
  instructions,
111
- [values.system, scopeDirective(strict)].filter(Boolean).join('\n\n')
114
+ [values.system, mcpIndexBlock(mcp, { mode: mcpMode }), scopeDirective(strict)].filter(Boolean).join('\n\n')
112
115
  ),
113
116
  history,
114
117
  input: prompt,
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { loadConfig, loadInstructions, Store, buildMcpRegistry, resolveMode, scopeDirective } from '../lib/core.js';
3
3
  import { parse, databasePath, num } from '../lib/util.js';
4
- import { buildSystemPrompt, resolveCliProvider } from '../lib/runtime.js';
4
+ import { buildSystemPrompt, resolveCliProvider, mcpSettings, mcpIndexBlock } from '../lib/runtime.js';
5
5
  import { runTui } from '../tui/app.js';
6
6
  import { color } from '../ui/color.js';
7
7
 
@@ -55,6 +55,8 @@ export async function tuiCommand(argv) {
55
55
  process.stderr.write(color.yellow(`MCP: ${err.message}\n`));
56
56
  }
57
57
  }
58
+ const { mode: mcpMode, pinned: mcpPinned } = mcpSettings(store, config);
59
+ const mcpBlock = mcpIndexBlock(mcp, { mode: mcpMode });
58
60
 
59
61
  const baseEnabled = values['no-tools']
60
62
  ? []
@@ -69,7 +71,10 @@ export async function tuiCommand(argv) {
69
71
  const enabledFor = (m) =>
70
72
  m.tools ? (baseEnabled ? baseEnabled.filter((tool) => m.tools.includes(tool)) : m.tools) : baseEnabled;
71
73
  const promptFor = (m) =>
72
- buildSystemPrompt(instructions, [values.system, m.systemPrompt, scopeDirective(strict)].filter(Boolean).join('\n\n'));
74
+ buildSystemPrompt(
75
+ instructions,
76
+ [values.system, m.systemPrompt, mcpBlock, scopeDirective(strict)].filter(Boolean).join('\n\n')
77
+ );
73
78
  const modeContext = (id) => {
74
79
  const m = resolveMode(id);
75
80
  return { label: m.label, enabled: enabledFor(m), systemPrompt: promptFor(m) };
@@ -101,6 +106,8 @@ export async function tuiCommand(argv) {
101
106
  enabled: enabledFor(mode),
102
107
  permissions: config.permissions,
103
108
  mcp,
109
+ mcpMode,
110
+ mcpPinned,
104
111
  yes: Boolean(values.yes),
105
112
  },
106
113
  store,
@@ -1,4 +1,26 @@
1
- import { runAgent, resolveProviderRuntime, composeProviderSettings, resolveApiKey } from './core.js';
1
+ import { runAgent, resolveProviderRuntime, composeProviderSettings, resolveApiKey, useLazyMcp, buildMcpIndex, MCP_AUTO_THRESHOLD } from './core.js';
2
+
3
+ const MCP_MODES = new Set(['auto', 'eager', 'lazy']);
4
+
5
+ /** Modo y tools fijadas de MCP (setting de la base, con fallback a tamnora.json). */
6
+ export function mcpSettings(store, config = {}) {
7
+ const raw = String(store.getSetting('mcpMode') ?? config.mcpMode ?? 'auto');
8
+ const mode = MCP_MODES.has(raw) ? raw : 'auto';
9
+ const stored = store.getSetting('mcpPinned');
10
+ const pinned = Array.isArray(stored)
11
+ ? stored.map(String)
12
+ : Array.isArray(config.mcpPinned)
13
+ ? config.mcpPinned.map(String)
14
+ : [];
15
+ return { mode, pinned };
16
+ }
17
+
18
+ /** Bloque de indice de MCP para el prompt (vacio si el modo es completo). */
19
+ export function mcpIndexBlock(mcp, { mode = 'auto' } = {}) {
20
+ const total = mcp?.definitions?.length ?? 0;
21
+ if (!total || !useLazyMcp(mode, total, MCP_AUTO_THRESHOLD)) return '';
22
+ return buildMcpIndex(mcp.tools ?? []);
23
+ }
2
24
 
3
25
  /**
4
26
  * Resolve provider/model/apiKey/baseUrl for CLI commands, merging the active
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { parseTunnelmoleUrls, parseTailscaleStatus, startTunnel } from '../src/lib/tunnel.js';
3
+ import { parseTunnelmoleUrls, parseTailscaleStatus, startTunnel, tailscalePreviewUrl } from '../src/lib/tunnel.js';
4
4
  import { qrTerminal } from '../src/lib/qr.js';
5
5
 
6
6
  test('parseTunnelmoleUrls extracts the https URL', () => {
@@ -32,6 +32,18 @@ test('startTunnel rechaza el proveedor retirado (relay)', async () => {
32
32
  await assert.rejects(() => startTunnel(4517, 'relay'), /no soportado/);
33
33
  });
34
34
 
35
+ test('startTunnel con provider none no abre nada', async () => {
36
+ const tun = await startTunnel(4517, 'none', { preview: true });
37
+ assert.equal(tun.provider, 'none');
38
+ assert.equal(tun.url, '');
39
+ });
40
+
41
+ test('tailscalePreviewUrl usa el 8443 y omite el 443', () => {
42
+ assert.equal(tailscalePreviewUrl('pc.tailnet.ts.net'), 'https://pc.tailnet.ts.net:8443');
43
+ assert.equal(tailscalePreviewUrl('pc.tailnet.ts.net', 9000), 'https://pc.tailnet.ts.net:9000');
44
+ assert.equal(tailscalePreviewUrl('pc.tailnet.ts.net', 443), 'https://pc.tailnet.ts.net');
45
+ });
46
+
35
47
  test('qrTerminal renders a block matrix for a URL', () => {
36
48
  const art = qrTerminal('https://abc-123.tunnelmole.net', 1);
37
49
  assert.ok(art.length > 0);
@@ -23,6 +23,9 @@ export const MODES = {
23
23
  'screenshot',
24
24
  'console_errors',
25
25
  'preview_url',
26
+ 'ask_user',
27
+ 'offer_choices',
28
+ 'show_plan',
26
29
  'git_status',
27
30
  'git_diff',
28
31
  'git_log',
@@ -21,9 +21,11 @@ export const DEFAULT_CONFIG = {
21
21
  tools: [],
22
22
  permissions: {},
23
23
  mcp: [],
24
+ mcpMode: 'auto',
25
+ mcpPinned: [],
24
26
  server: { host: '127.0.0.1', port: 4517 },
25
27
  processes: { enabled: true, allow: [] },
26
- preview: { provider: 'tunnelmole' },
28
+ preview: { provider: 'tailscale', tailscalePort: 8443, fallback: true },
27
29
  telegram: { token: '', allowedChatIds: [] },
28
30
  agents: { maxRounds: 6 },
29
31
  };
@@ -4,6 +4,8 @@ export {
4
4
  redactDeep,
5
5
  secretValues,
6
6
  isSecretName,
7
+ isSensitiveEnvName,
8
+ scrubEnv,
7
9
  maskSecret,
8
10
  maskEnv,
9
11
  REDACTED,
@@ -77,8 +79,15 @@ export {
77
79
  TOOL_NAMES,
78
80
  DEFAULT_PERMISSIONS,
79
81
  evaluatePermission,
82
+ bashGuard,
80
83
  resolveInRoot,
81
84
  toRel,
85
+ MCP_ROUTER_NAME,
86
+ MCP_AUTO_THRESHOLD,
87
+ useLazyMcp,
88
+ buildMcpIndex,
89
+ mcpRouterDefinition,
90
+ planMcpTools,
82
91
  } from './tools/index.js';
83
92
  export { Store, openDatabase } from './storage/sqlite.js';
84
93
  export { runTask } from './eval/runTask.js';
@@ -96,7 +105,8 @@ export {
96
105
  buildSkillsPrompt,
97
106
  buildSkillsIndex,
98
107
  } from './skills/index.js';
99
- export { parseSkillRef, fetchSkillFromGithub } from './skills/remote.js';
108
+ export { parseSkillRef, fetchSkillFromGithub, searchSkillsGithub } from './skills/remote.js';
109
+ export { SKILLS_CATALOG, skillCatalogEntry } from './skills/catalog.js';
100
110
  export {
101
111
  MEMORY_DIR,
102
112
  MEMORY_INDEX_NAME,
@@ -130,16 +140,20 @@ export {
130
140
  searchMcpRegistry,
131
141
  getMcpRegistryServer,
132
142
  registryServerToConfig,
143
+ registryServerToCandidate,
133
144
  normalizeMcpConfig,
134
145
  } from './mcp/registry.js';
146
+ export { MCP_CATALOG, mcpCatalogEntry } from './mcp/catalog.js';
135
147
  export {
136
148
  parseTunnelmoleUrls,
137
149
  parseTailscaleStatus,
138
150
  probeTailscale,
139
151
  resetTailscaleServe,
140
152
  startTailscaleServe,
153
+ startTailscalePreview,
141
154
  startTunnelmole,
142
155
  startTunnel,
156
+ tailscalePreviewUrl,
143
157
  tunnelState,
144
158
  tunnelActive,
145
159
  } from './tunnel.js';
@@ -0,0 +1,79 @@
1
+ // Catalogo curado de servidores MCP sugeridos. No se instalan solos: la consola
2
+ // (o el agente) los agrega deshabilitados y el usuario decide habilitarlos.
3
+ // Los `env` con valor "env:VAR" se resuelven desde el entorno al arrancar el MCP.
4
+
5
+ export const MCP_CATALOG = [
6
+ {
7
+ id: 'playwright',
8
+ name: 'Playwright (navegador)',
9
+ description:
10
+ 'Automatiza un navegador real: webs con JavaScript, formularios y scraping. Complementa las tools screenshot/console_errors.',
11
+ type: 'stdio',
12
+ command: 'npx',
13
+ args: ['-y', '@playwright/mcp@latest'],
14
+ enabled: false,
15
+ },
16
+ {
17
+ id: 'fetch',
18
+ name: 'Fetch (webs con JS)',
19
+ description: 'Trae cualquier URL y la convierte a texto, incluidas las que renderizan con JavaScript. Requiere uv.',
20
+ type: 'stdio',
21
+ command: 'uvx',
22
+ args: ['mcp-server-fetch'],
23
+ enabled: false,
24
+ },
25
+ {
26
+ id: 'github',
27
+ name: 'GitHub',
28
+ description: 'Repos, issues, PRs y búsqueda en GitHub. Necesita un token personal.',
29
+ type: 'stdio',
30
+ command: 'npx',
31
+ args: ['-y', '@modelcontextprotocol/server-github'],
32
+ env: { GITHUB_PERSONAL_ACCESS_TOKEN: 'env:GITHUB_PERSONAL_ACCESS_TOKEN' },
33
+ requiredEnv: ['GITHUB_PERSONAL_ACCESS_TOKEN'],
34
+ enabled: false,
35
+ },
36
+ {
37
+ id: 'memory',
38
+ name: 'Memory (grafo)',
39
+ description: 'Memoria de conocimiento persistente: entidades y relaciones que sobreviven entre sesiones.',
40
+ type: 'stdio',
41
+ command: 'npx',
42
+ args: ['-y', '@modelcontextprotocol/server-memory'],
43
+ enabled: false,
44
+ },
45
+ {
46
+ id: 'sequential-thinking',
47
+ name: 'Sequential Thinking',
48
+ description: 'Razonamiento paso a paso para descomponer problemas complejos.',
49
+ type: 'stdio',
50
+ command: 'npx',
51
+ args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
52
+ enabled: false,
53
+ },
54
+ {
55
+ id: 'sqlite',
56
+ name: 'SQLite',
57
+ description: 'Consulta una base SQLite. Reemplazá la ruta por tu archivo .db. Requiere uv.',
58
+ type: 'stdio',
59
+ command: 'uvx',
60
+ args: ['mcp-server-sqlite', '--db-path', 'ruta/a/base.db'],
61
+ enabled: false,
62
+ },
63
+ {
64
+ id: 'time',
65
+ name: 'Time',
66
+ description: 'Hora actual y conversión de zonas horarias. Requiere uv.',
67
+ type: 'stdio',
68
+ command: 'uvx',
69
+ args: ['mcp-server-time'],
70
+ enabled: false,
71
+ },
72
+ ];
73
+
74
+ /** Entrada del catalogo por id, como copia (siempre deshabilitada). */
75
+ export function mcpCatalogEntry(id) {
76
+ const entry = MCP_CATALOG.find((item) => item.id === id);
77
+ if (!entry) return null;
78
+ return { ...entry, args: [...(entry.args ?? [])], env: entry.env ? { ...entry.env } : undefined, enabled: false };
79
+ }
@@ -70,12 +70,19 @@ export async function buildMcpRegistry(servers = [], { signal } = {}) {
70
70
 
71
71
  const map = new Map();
72
72
  const definitions = [];
73
+ const tools = [];
73
74
  const sessions = [];
74
75
  const errors = [];
75
76
 
76
- const add = (name, definition, call) => {
77
+ const add = (server, name, definition, call) => {
77
78
  map.set(name, call);
78
79
  definitions.push(definition);
80
+ tools.push({
81
+ name,
82
+ server: server.id,
83
+ serverName: server.name ?? server.id,
84
+ description: definition.function?.description ?? '',
85
+ });
79
86
  };
80
87
 
81
88
  for (const r of results) {
@@ -101,19 +108,21 @@ export async function buildMcpRegistry(servers = [], { signal } = {}) {
101
108
 
102
109
  for (const tool of session.tools ?? []) {
103
110
  const name = qualifiedToolName(server.id, tool.name);
104
- add(name, mcpToolToDefinition(tool, server, name), async (args) => (await reconnect()).callTool(tool.name, args));
111
+ add(server, name, mcpToolToDefinition(tool, server, name), async (args) => (await reconnect()).callTool(tool.name, args));
105
112
  }
106
113
 
107
114
  if (session.capabilities?.resources || session.resources?.length) {
108
115
  const label = server.name ?? server.id;
109
116
  const listName = qualifiedToolName(server.id, 'list_resources');
110
117
  add(
118
+ server,
111
119
  listName,
112
120
  syntheticDefinition(listName, `Lista los recursos MCP del servidor ${label}.`),
113
121
  async () => formatResources(await (await reconnect()).listResources())
114
122
  );
115
123
  const readName = qualifiedToolName(server.id, 'read_resource');
116
124
  add(
125
+ server,
117
126
  readName,
118
127
  syntheticDefinition(readName, `Lee un recurso MCP del servidor ${label} a partir de su uri.`, {
119
128
  type: 'object',
@@ -128,12 +137,14 @@ export async function buildMcpRegistry(servers = [], { signal } = {}) {
128
137
  const label = server.name ?? server.id;
129
138
  const listName = qualifiedToolName(server.id, 'list_prompts');
130
139
  add(
140
+ server,
131
141
  listName,
132
142
  syntheticDefinition(listName, `Lista los prompts MCP del servidor ${label}.`),
133
143
  async () => formatPrompts(await (await reconnect()).listPrompts())
134
144
  );
135
145
  const getName = qualifiedToolName(server.id, 'get_prompt');
136
146
  add(
147
+ server,
137
148
  getName,
138
149
  syntheticDefinition(getName, `Obtiene un prompt MCP del servidor ${label}.`, {
139
150
  type: 'object',
@@ -150,6 +161,7 @@ export async function buildMcpRegistry(servers = [], { signal } = {}) {
150
161
 
151
162
  return {
152
163
  definitions,
164
+ tools,
153
165
  sessions,
154
166
  errors,
155
167
  resources: sessions.flatMap((s) => s.resources ?? []),
@@ -54,7 +54,9 @@ export function registryServerToConfig(server) {
54
54
  if (runtime === 'npx' && !args.includes('-y')) args.unshift('-y');
55
55
  args.push(`${pkg.identifier}${pkg.version ? `@${pkg.version}` : ''}`);
56
56
  const env = {};
57
- for (const variable of pkg.environmentVariables ?? []) env[variable.name] = '';
57
+ for (const variable of pkg.environmentVariables ?? []) {
58
+ env[variable.name] = variable.isRequired ? `env:${variable.name}` : '';
59
+ }
58
60
  const requiredEnv = (pkg.environmentVariables ?? []).filter((v) => v.isRequired).map((v) => v.name);
59
61
  return {
60
62
  id,
@@ -70,6 +72,32 @@ export function registryServerToConfig(server) {
70
72
  throw new Error('El servidor del registro no tiene remotes ni packages compatibles.');
71
73
  }
72
74
 
75
+ /** Vista liviana de un server del registro, para mostrar candidatos sin instalar. */
76
+ export function registryServerToCandidate(server) {
77
+ const id = slugifyId(server?.name) || 'mcp';
78
+ const remote = (server?.remotes ?? [])[0];
79
+ const pkg = (server?.packages ?? [])[0];
80
+ const runtime = pkg ? pkg.runtimeHint || (pkg.registryType === 'npm' ? 'npx' : 'uvx') : '';
81
+ const requiredEnv = (pkg?.environmentVariables ?? []).filter((v) => v.isRequired).map((v) => v.name);
82
+ const args = pkg
83
+ ? [
84
+ ...(pkg.runtimeArguments ?? []).filter((a) => a.type === 'positional' && a.value).map((a) => a.value),
85
+ `${pkg.identifier}${pkg.version ? `@${pkg.version}` : ''}`,
86
+ ]
87
+ : [];
88
+ return {
89
+ id,
90
+ name: server?.title || server?.name || id,
91
+ description: server?.description ?? '',
92
+ registry: server?.name ?? null,
93
+ transport: remote?.url ? 'http' : 'stdio',
94
+ url: remote?.url ?? null,
95
+ command: runtime || null,
96
+ args,
97
+ requiredEnv,
98
+ };
99
+ }
100
+
73
101
  /**
74
102
  * Normaliza un snippet JSON a nuestra config. Acepta:
75
103
  * - `{ command, args, env }` o `{ url }`
@@ -27,6 +27,31 @@ export function isSecretName(name) {
27
27
  return SECRET_NAME.test(String(name ?? ''));
28
28
  }
29
29
 
30
+ /**
31
+ * Nombre sensible con criterio mas estricto (para el scrub de entorno): solo
32
+ * credenciales reales, para no romper variables legitimas como `SSH_AUTH_SOCK`
33
+ * o `DBUS_SESSION_BUS_ADDRESS` que matchean el patron amplio de `isSecretName`.
34
+ */
35
+ const SENSITIVE_ENV_NAME = /(API[_-]?KEY|ACCESS[_-]?KEY|SECRET|PASSWORD|PASSWD|TOKEN|CREDENTIAL|PRIVATE[_-]?KEY|OAUTH)/i;
36
+
37
+ export function isSensitiveEnvName(name) {
38
+ return SENSITIVE_ENV_NAME.test(String(name ?? ''));
39
+ }
40
+
41
+ /**
42
+ * Copia el entorno sin las variables con nombre de credencial, para no filtrar
43
+ * secretos a un proceso hijo (`bash`). Conserva PATH/HOME/etc. Defensa en
44
+ * profundidad: el comando del modelo no puede leer `env` y ver las claves.
45
+ */
46
+ export function scrubEnv(env = getEnv()) {
47
+ const out = {};
48
+ for (const [name, value] of Object.entries(env ?? {})) {
49
+ if (isSensitiveEnvName(name)) continue;
50
+ out[name] = value;
51
+ }
52
+ return out;
53
+ }
54
+
30
55
  /**
31
56
  * Valores de entorno que hay que redactar: los de variables con nombre
32
57
  * sensible y al menos 8 caracteres (evita tapar valores triviales).