@danieltmn/tamnora 0.16.1 → 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 (64) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +58 -4
  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 +17 -3
  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/procs/detect.js +59 -8
  17. package/harness/packages/core/src/procs/kill.js +26 -5
  18. package/harness/packages/core/src/redact.js +25 -0
  19. package/harness/packages/core/src/skills/catalog.js +78 -0
  20. package/harness/packages/core/src/skills/remote.js +51 -0
  21. package/harness/packages/core/src/tools/executors.js +93 -2
  22. package/harness/packages/core/src/tools/index.js +27 -6
  23. package/harness/packages/core/src/tools/labels.js +2 -0
  24. package/harness/packages/core/src/tools/mcp.js +91 -0
  25. package/harness/packages/core/src/tools/permissions.js +53 -8
  26. package/harness/packages/core/src/tools/registry.js +14 -7
  27. package/harness/packages/core/src/tools/schema.js +86 -0
  28. package/harness/packages/core/src/tts/edge.js +28 -7
  29. package/harness/packages/core/src/tunnel.js +51 -5
  30. package/harness/packages/core/test/compact.test.js +2 -0
  31. package/harness/packages/core/test/labels.test.js +1 -0
  32. package/harness/packages/core/test/mcp-registry.test.js +37 -1
  33. package/harness/packages/core/test/mcp-routing.test.js +115 -0
  34. package/harness/packages/core/test/procs.test.js +70 -2
  35. package/harness/packages/core/test/redact.test.js +22 -0
  36. package/harness/packages/core/test/skills.test.js +11 -0
  37. package/harness/packages/core/test/tools.test.js +97 -3
  38. package/harness/packages/server/public/app.js +629 -28
  39. package/harness/packages/server/public/index.html +44 -2
  40. package/harness/packages/server/public/styles.css +106 -7
  41. package/harness/packages/server/src/lib/agentTools.js +45 -3
  42. package/harness/packages/server/src/lib/channels/telegram.js +2 -0
  43. package/harness/packages/server/src/lib/procs.js +9 -3
  44. package/harness/packages/server/src/lib/prompt.js +4 -1
  45. package/harness/packages/server/src/lib/questions.js +72 -0
  46. package/harness/packages/server/src/lib/runManager.js +29 -2
  47. package/harness/packages/server/src/lib/scheduler.js +12 -1
  48. package/harness/packages/server/src/lib/tasks.js +3 -1
  49. package/harness/packages/server/src/routes/chat.js +40 -3
  50. package/harness/packages/server/src/routes/mcp.js +54 -2
  51. package/harness/packages/server/src/routes/preview.js +49 -28
  52. package/harness/packages/server/src/routes/procs.js +20 -3
  53. package/harness/packages/server/src/routes/providers.js +12 -0
  54. package/harness/packages/server/src/routes/sessions.js +3 -1
  55. package/harness/packages/server/src/routes/skills.js +48 -1
  56. package/harness/packages/server/src/routes/state.js +8 -1
  57. package/harness/packages/server/src/server.js +37 -1
  58. package/harness/packages/server/test/preview.test.js +111 -0
  59. package/harness/packages/server/test/server.test.js +285 -34
  60. package/harness/packages/server/test/telegram-features.test.js +2 -0
  61. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  62. package/harness/packages/server/test/telegram.test.js +2 -0
  63. package/harness/tamnora.example.json +3 -1
  64. package/package.json +1 -1
@@ -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 }`
@@ -24,12 +24,53 @@ const SCRIPT_ORDER = ['dev', 'start', 'serve', 'preview'];
24
24
  const STATIC_SERVER = fileURLToPath(new URL('../../../../scripts/static-server.mjs', import.meta.url));
25
25
  const STATIC_PORT = 8080;
26
26
 
27
+ /** Archivo por proyecto (relativo a su raiz) que define sus procesos de dev. */
28
+ export const PROCS_FILE = '.tamnora/procs.json';
29
+
27
30
  function findStaticRoot(dir) {
28
31
  if (existsSync(path.join(dir, 'index.html'))) return '.';
29
32
  if (existsSync(path.join(dir, 'public', 'index.html'))) return 'public';
30
33
  return null;
31
34
  }
32
35
 
36
+ /** True si `target` es `root` o vive dentro de el (sin salirse por `..`). */
37
+ function isInside(root, target) {
38
+ const rel = path.relative(root, target);
39
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
40
+ }
41
+
42
+ /**
43
+ * Procesos definidos a mano por el proyecto en `<root>/.tamnora/procs.json`.
44
+ * Formato: `{ "processes": [{ label?, dir?, command, args?, port? }] }` o un array
45
+ * directo. `dir` es relativo a la raiz y debe quedar dentro de ella. Cada chip
46
+ * sigue pasando por la whitelist (`processes.allow`) al arrancar.
47
+ */
48
+ export function loadProjectProcs(root) {
49
+ const file = path.join(root, PROCS_FILE);
50
+ if (!existsSync(file)) return [];
51
+ const raw = readJson(file);
52
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.processes) ? raw.processes : [];
53
+ const chips = [];
54
+ for (const entry of list) {
55
+ if (!entry || typeof entry !== 'object') continue;
56
+ const command = String(entry.command ?? '').trim();
57
+ if (!command) continue;
58
+ const rel = String(entry.dir ?? '').trim().replace(/^[/\\]+/, '');
59
+ const cwd = path.resolve(root, rel || '.');
60
+ if (!isInside(root, cwd) || !existsSync(cwd)) continue;
61
+ const port = entry.port ? Number(entry.port) : undefined;
62
+ chips.push({
63
+ label: String(entry.label ?? command).trim() || command,
64
+ command,
65
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
66
+ cwd,
67
+ port: Number.isInteger(port) && port > 0 ? port : undefined,
68
+ source: 'project',
69
+ });
70
+ }
71
+ return chips;
72
+ }
73
+
33
74
  /** Chip para servir `dir` como sitio estático con el servidor sin dependencias. */
34
75
  export function staticServerChip(dir, { port = STATIC_PORT, label } = {}) {
35
76
  return {
@@ -59,9 +100,9 @@ export function packageManager(dir) {
59
100
  return 'npm';
60
101
  }
61
102
 
62
- /** Devuelve chips `{ label, command, args, cwd }` para arrancar el proyecto. */
103
+ /** Devuelve chips `{ label, command, args, cwd, source }` para arrancar el proyecto. */
63
104
  export function detectProcs(dir) {
64
- const chips = [];
105
+ const auto = [];
65
106
  const pkg = readJson(path.join(dir, 'package.json'));
66
107
  if (pkg?.scripts && typeof pkg.scripts === 'object') {
67
108
  const pm = packageManager(dir);
@@ -70,29 +111,39 @@ export function detectProcs(dir) {
70
111
  const first = Object.keys(pkg.scripts)[0];
71
112
  if (first) names.push(first);
72
113
  }
73
- for (const name of names) chips.push({ label: `${name} (${pm})`, command: pm, args: ['run', name], cwd: dir });
114
+ for (const name of names) auto.push({ label: `${name} (${pm})`, command: pm, args: ['run', name], cwd: dir });
74
115
  }
75
116
 
76
117
  if (existsSync(path.join(dir, 'artisan'))) {
77
- chips.push({ label: 'artisan serve', command: 'php', args: ['artisan', 'serve'], cwd: dir });
118
+ auto.push({ label: 'artisan serve', command: 'php', args: ['artisan', 'serve'], cwd: dir });
78
119
  } else if (readJson(path.join(dir, 'composer.json'))?.scripts?.dev) {
79
- chips.push({ label: 'composer dev', command: 'composer', args: ['run', 'dev'], cwd: dir });
120
+ auto.push({ label: 'composer dev', command: 'composer', args: ['run', 'dev'], cwd: dir });
80
121
  }
81
122
 
82
123
  for (const rel of ['public/index.php', 'backend/public/index.php', 'index.php']) {
83
124
  if (!existsSync(path.join(dir, rel))) continue;
84
125
  const docroot = path.dirname(rel) === '.' ? '.' : path.dirname(rel);
85
- chips.push({ label: `php -S (${rel})`, command: 'php', args: ['-S', '127.0.0.1:8000', '-t', docroot], cwd: dir });
126
+ auto.push({ label: `php -S (${rel})`, command: 'php', args: ['-S', '127.0.0.1:8000', '-t', docroot], cwd: dir });
86
127
  break;
87
128
  }
88
129
 
89
- if (!chips.length) {
130
+ if (!auto.length) {
90
131
  const staticRoot = findStaticRoot(dir);
91
132
  if (staticRoot) {
92
133
  const target = staticRoot === '.' ? dir : path.join(dir, staticRoot);
93
- chips.push(staticServerChip(target));
134
+ auto.push(staticServerChip(target));
94
135
  }
95
136
  }
96
137
 
138
+ const chips = [];
139
+ const seen = new Set();
140
+ const add = (chip) => {
141
+ const key = `${chip.command}|${(chip.args ?? []).join(' ')}|${chip.cwd ?? ''}`;
142
+ if (seen.has(key)) return;
143
+ seen.add(key);
144
+ chips.push(chip);
145
+ };
146
+ for (const chip of loadProjectProcs(dir)) add(chip);
147
+ for (const chip of auto) add({ ...chip, source: 'auto' });
97
148
  return chips;
98
149
  }
@@ -71,16 +71,37 @@ async function pidsOnUnix(port) {
71
71
  return [];
72
72
  }
73
73
 
74
+ function validPort(port) {
75
+ const value = Number(port);
76
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
77
+ throw new Error('Puerto inválido (usá 1-65535).');
78
+ }
79
+ return value;
80
+ }
81
+
82
+ /** PIDs escuchando en `port` (Windows `netstat` o Unix lsof/fuser/ss). */
83
+ export async function pidsOnPort(port) {
84
+ const value = validPort(port);
85
+ return process.platform === 'win32' ? pidsOnWindows(value) : pidsOnUnix(value);
86
+ }
87
+
88
+ /**
89
+ * Estado de un puerto.
90
+ * @returns {Promise<{port:number,listening:boolean,pids:number[]}>}
91
+ */
92
+ export async function portInfo(port) {
93
+ const value = validPort(port);
94
+ const pids = await pidsOnPort(value);
95
+ return { port: value, listening: pids.length > 0, pids };
96
+ }
97
+
74
98
  /**
75
99
  * Kill every process listening on `port`.
76
100
  * @returns {Promise<{port:number,pids:number[],killed:number[],errors:string[]}>}
77
101
  */
78
102
  export async function killPort(port) {
79
- const value = Number(port);
80
- if (!Number.isInteger(value) || value < 1 || value > 65535) {
81
- throw new Error('Puerto inválido (usá 1-65535).');
82
- }
83
- const pids = process.platform === 'win32' ? await pidsOnWindows(value) : await pidsOnUnix(value);
103
+ const value = validPort(port);
104
+ const pids = await pidsOnPort(value);
84
105
  const killed = [];
85
106
  const errors = [];
86
107
  for (const pid of pids) {
@@ -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).
@@ -0,0 +1,78 @@
1
+ // Catalogo curado de skills sugeridas. Se instalan (copian su SKILL.md a la
2
+ // casa) deshabilitadas; el usuario las habilita desde la consola.
3
+
4
+ export const SKILLS_CATALOG = [
5
+ {
6
+ id: 'browser-qa',
7
+ name: 'QA de webs',
8
+ description: 'Verifica una web en el navegador headless antes de darla por buena.',
9
+ when: 'cuando toques HTML/CSS/JS de una web o necesites confirmar que renderiza',
10
+ body: `# QA de webs
11
+
12
+ Cuando trabajes en una web, verificala en el navegador antes de dar el cambio por terminado.
13
+
14
+ 1. Levantá el dev server (pestaña procs o \`bash\`) y anotá el puerto.
15
+ 2. Sacá una captura con la tool \`screenshot\` sobre la URL local.
16
+ 3. Revisá el resultado y los errores de consola con \`console_errors\`.
17
+ 4. Si hay errores, corregí y repetí. No des por bueno un cambio sin una captura limpia.
18
+ 5. Para webs con JavaScript o Cloudflare que \`fetch_url\` no puede leer, usá \`screenshot\`/\`console_errors\`.`,
19
+ },
20
+ {
21
+ id: 'git-commits',
22
+ name: 'Commits del repo',
23
+ description: 'Convenciones de mensajes de commit y qué no commitear.',
24
+ when: 'cuando vayas a proponer o preparar un commit',
25
+ body: `# Commits
26
+
27
+ - Mensajes en español, con prefijos tipo \`feat:\`, \`fix:\`, \`docs:\`, \`chore(release):\`.
28
+ - Una unidad lógica por commit; no mezcles refactors con features.
29
+ - Antes de commitear, revisá \`git status\` y \`git diff\`; commiteá solo lo que corresponde.
30
+ - Nunca agregues \`.tamnora/\`, \`tamnora.json\`, \`.env*\`, \`.npmrc\` ni \`*.db\`.
31
+ - No commitees, hagas push ni abras PRs si el usuario no lo pidió.`,
32
+ },
33
+ {
34
+ id: 'code-review',
35
+ name: 'Revisión de cambios',
36
+ description: 'Revisa el diff antes de commitear buscando riesgos y secretos.',
37
+ when: 'antes de commitear o cuando el usuario pida una revisión',
38
+ body: `# Revisión de cambios
39
+
40
+ - Leé el diff completo con \`git_diff\`.
41
+ - Buscá: secretos o claves hardcodeadas, rutas que se salen del workspace, cambios no pedidos, logs de datos sensibles.
42
+ - Confirmá que cada cambio responde al pedido; si hay refactors de más, señalalo.
43
+ - Verificá que el código nuevo tenga su test si el repo usa tests.
44
+ - Devolvé un resumen breve con hallazgos y, si está todo bien, decilo explícitamente.`,
45
+ },
46
+ {
47
+ id: 'static-landing',
48
+ name: 'Landing estática',
49
+ description: 'Arma una página estática sin dependencias ni build.',
50
+ when: 'cuando pidan una landing o página simple',
51
+ body: `# Landing estática
52
+
53
+ - \`index.html\` + \`styles.css\` (y \`script.js\` solo si hace falta). Sin frameworks ni build.
54
+ - CSS moderno: variables, grid/flex, responsive con una columna en mobile.
55
+ - Accesibilidad: \`lang\`, \`alt\` en imágenes, contraste y foco visible.
56
+ - Sin errores de consola: verificalo con \`screenshot\`/\`console_errors\`.
57
+
58
+ ## Cierre
59
+ Al terminar, levantá el sitio y confirmá con una captura que se ve bien en adaptable y en móvil.`,
60
+ },
61
+ {
62
+ id: 'changelog',
63
+ name: 'Changelog',
64
+ description: 'Mantiene CHANGELOG.md con entradas claras.',
65
+ when: 'cuando haya cambios que el usuario quiera registrar en el changelog',
66
+ body: `# Changelog
67
+
68
+ - Mantené una sección \`## [No publicado]\` (o \`Unreleased\`) arriba con \`### Agregado\`, \`### Cambiado\`, \`### Corregido\`.
69
+ - Entradas en imperativo y orientadas al usuario, no al archivo tocado.
70
+ - Al publicar, mové lo de "No publicado" a la versión con fecha \`YYYY-MM-DD\`.
71
+ - No dupliques entradas ni describas detalles internos irrelevantes.`,
72
+ },
73
+ ];
74
+
75
+ /** Entrada del catalogo de skills por id. */
76
+ export function skillCatalogEntry(id) {
77
+ return SKILLS_CATALOG.find((item) => item.id === id) ?? null;
78
+ }
@@ -41,6 +41,57 @@ async function ghJson(url) {
41
41
  return res.json();
42
42
  }
43
43
 
44
+ /**
45
+ * Busca skills en GitHub y devuelve refs (`owner/repo[/slug]`) instalables.
46
+ * Sin token usa la busqueda de repositorios (limitada); con token usa la
47
+ * busqueda de codigo por `filename:SKILL.md`, que es mucho mas precisa.
48
+ */
49
+ export async function searchSkillsGithub(query, { token, limit = 10 } = {}) {
50
+ const q = String(query ?? '').trim();
51
+ if (!q) return [];
52
+ const headers = { 'User-Agent': 'tamnora-harness', Accept: 'application/vnd.github+json' };
53
+ if (token) headers.Authorization = `Bearer ${token}`;
54
+ const only = Math.max(1, Math.min(30, Number(limit) || 10));
55
+
56
+ if (token) {
57
+ const url = new URL(`${GITHUB_API}/search/code`);
58
+ url.searchParams.set('q', `${q} filename:SKILL.md`);
59
+ url.searchParams.set('per_page', String(only));
60
+ const res = await fetch(url.toString(), { headers });
61
+ if (!res.ok) throw new Error(`GitHub respondio HTTP ${res.status}`);
62
+ const data = await res.json();
63
+ return (data.items ?? [])
64
+ .map((item) => {
65
+ const [owner, repo] = String(item.repository?.full_name ?? '').split('/');
66
+ const parts = String(item.path ?? '').split('/');
67
+ const idx = parts.lastIndexOf('SKILL.md');
68
+ const slug = idx > 0 ? parts[idx - 1] : null;
69
+ return { ref: slug ? `${owner}/${repo}/${slug}` : `${owner}/${repo}`, owner, repo, slug, source: `${owner}/${repo}` };
70
+ })
71
+ .filter((r) => r.owner && r.repo);
72
+ }
73
+
74
+ const url = new URL(`${GITHUB_API}/search/repositories`);
75
+ url.searchParams.set('q', `${q} SKILL.md in:readme`);
76
+ url.searchParams.set('per_page', String(only));
77
+ const res = await fetch(url.toString(), { headers });
78
+ if (!res.ok) throw new Error(`GitHub respondio HTTP ${res.status}`);
79
+ const data = await res.json();
80
+ return (data.items ?? [])
81
+ .map((item) => {
82
+ const [owner, repo] = String(item.full_name ?? '').split('/');
83
+ return {
84
+ ref: `${owner}/${repo}`,
85
+ owner,
86
+ repo,
87
+ slug: null,
88
+ source: `${owner}/${repo}`,
89
+ description: item.description ?? '',
90
+ };
91
+ })
92
+ .filter((r) => r.owner && r.repo);
93
+ }
94
+
44
95
  /**
45
96
  * Baja los archivos de una skill (`SKILL.md` y los del mismo directorio) desde
46
97
  * el repositorio de GitHub. Sin `slug`, toma el `SKILL.md` de la raiz.
@@ -6,6 +6,7 @@ import { globToRegExp, walkFiles } from './glob.js';
6
6
  import { diffLines } from './diff.js';
7
7
  import { captureScreenshot, readConsole } from '../browser/index.js';
8
8
  import { screenshotsDir } from '../config/home.js';
9
+ import { scrubEnv } from '../redact.js';
9
10
 
10
11
  const MAX_READ_LINES = 2000;
11
12
  const MAX_LIST = 500;
@@ -314,7 +315,7 @@ export function bashTool(root, { command, timeout } = {}, { signal } = {}) {
314
315
  const seconds = Math.min(Math.max(Number(timeout) || 30, 1), 300);
315
316
  let child;
316
317
  try {
317
- child = spawn(command, { cwd: root, shell: true, windowsHide: true });
318
+ child = spawn(command, { cwd: root, shell: true, windowsHide: true, env: scrubEnv(process.env) });
318
319
  } catch (err) {
319
320
  resolve(`Error ejecutando el comando: ${err.message}`);
320
321
  return;
@@ -359,7 +360,7 @@ function runGit(root, args) {
359
360
  return new Promise((resolve) => {
360
361
  let child;
361
362
  try {
362
- child = spawn('git', args, { cwd: root, windowsHide: true });
363
+ child = spawn('git', args, { cwd: root, windowsHide: true, env: scrubEnv(process.env) });
363
364
  } catch (err) {
364
365
  resolve(`Error ejecutando git: ${err.message}`);
365
366
  return;
@@ -458,6 +459,93 @@ export function previewUrlTool(_root, { url } = {}) {
458
459
  return `Vista previa pedida: ${parsed.url}. La consola la abre en el panel de vista previa.`;
459
460
  }
460
461
 
462
+ const QUESTION_TYPES = new Set(['text', 'textarea', 'select', 'confirm']);
463
+
464
+ function normalizeQuestions(raw) {
465
+ if (!Array.isArray(raw)) return [];
466
+ const out = [];
467
+ for (const item of raw) {
468
+ if (!item || typeof item !== 'object') continue;
469
+ const id = String(item.id ?? '').trim();
470
+ const label = String(item.label ?? '').trim();
471
+ if (!id || !label) continue;
472
+ let type = QUESTION_TYPES.has(item.type) ? item.type : 'text';
473
+ const options = Array.isArray(item.options) ? item.options.map((o) => String(o)) : [];
474
+ if (type === 'select' && !options.length) type = 'text';
475
+ out.push({
476
+ id,
477
+ label,
478
+ type,
479
+ options,
480
+ placeholder: String(item.placeholder ?? ''),
481
+ required: item.required === true,
482
+ });
483
+ if (out.length >= 12) break;
484
+ }
485
+ return out;
486
+ }
487
+
488
+ function formatAnswer(value) {
489
+ if (value === undefined || value === null || value === '') return '(sin respuesta)';
490
+ if (Array.isArray(value)) return value.join(', ');
491
+ if (typeof value === 'boolean') return value ? 'sí' : 'no';
492
+ return String(value);
493
+ }
494
+
495
+ /**
496
+ * Pregunta al usuario con un formulario interactivo. La entrega es del cliente
497
+ * (como `speak`/`preview_url`): aca se normaliza el pedido y se espera la
498
+ * respuesta via `onQuestion`. Sin canal interactivo, se le pide al modelo que
499
+ * pregunte en el texto.
500
+ */
501
+ export async function askUserTool(_root, args = {}, { onQuestion } = {}) {
502
+ const questions = normalizeQuestions(args.questions);
503
+ if (!questions.length) return 'Error: falta "questions" (una lista con id y label).';
504
+ if (typeof onQuestion !== 'function') {
505
+ return 'No hay formulario interactivo en este canal: hacé las preguntas en el texto de la respuesta.';
506
+ }
507
+ const result = await onQuestion({ title: String(args.title ?? '').trim(), questions });
508
+ if (!result || result.ok === false || result.cancelled) return 'El usuario no respondió el formulario.';
509
+ const answers = result.answers ?? {};
510
+ const lines = questions.map((q) => `- ${q.label}: ${formatAnswer(answers[q.id])}`);
511
+ return `Respuestas del usuario:\n${lines.join('\n')}`;
512
+ }
513
+
514
+ /**
515
+ * Ofrece opciones como botones tappables. La entrega es del cliente (como
516
+ * `speak`/`preview_url`): aca solo se valida; al tocar una, la consola manda la
517
+ * opcion como mensaje del usuario.
518
+ */
519
+ export function offerChoicesTool(_root, { question, options } = {}) {
520
+ const prompt = String(question ?? '').trim();
521
+ const items = Array.isArray(options) ? options.map((o) => String(o).trim()).filter(Boolean).slice(0, 6) : [];
522
+ if (!prompt || !items.length) return 'Error: pasá "question" y "options" (2 a 6 opciones).';
523
+ return `Opciones mostradas al usuario: ${items.join(' | ')}. Al tocar una, la consola la manda como su mensaje.`;
524
+ }
525
+
526
+ const PLAN_STATUSES = new Set(['pending', 'active', 'done']);
527
+
528
+ /**
529
+ * Muestra un plan/checklist interactivo. Solo lectura: el cliente lo dibuja; el
530
+ * modelo lo reemite para actualizar el estado de los pasos.
531
+ */
532
+ export function showPlanTool(_root, { title, steps } = {}) {
533
+ const clean = Array.isArray(steps)
534
+ ? steps
535
+ .filter((step) => step && typeof step === 'object' && String(step.text ?? '').trim())
536
+ .slice(0, 20)
537
+ .map((step) => ({
538
+ text: String(step.text).trim(),
539
+ status: PLAN_STATUSES.has(step.status) ? step.status : 'pending',
540
+ }))
541
+ : [];
542
+ if (!clean.length) return 'Error: pasá "steps" (una lista con "text").';
543
+ const marks = { done: '[x]', active: '[~]', pending: '[ ]' };
544
+ const head = String(title ?? '').trim();
545
+ const lines = clean.map((step) => `- ${marks[step.status]} ${step.text}`);
546
+ return `Plan mostrado al usuario${head ? ` ("${head}")` : ''}:\n${lines.join('\n')}`;
547
+ }
548
+
461
549
  /**
462
550
  * Captura de pantalla con el navegador headless del sistema. La guarda en la
463
551
  * casa (`screenshots/`) y devuelve un `meta.screenshot` para que la consola la
@@ -535,6 +623,9 @@ export const EXECUTORS = {
535
623
  git_commit: gitCommitTool,
536
624
  speak: speakTool,
537
625
  preview_url: previewUrlTool,
626
+ ask_user: askUserTool,
627
+ offer_choices: offerChoicesTool,
628
+ show_plan: showPlanTool,
538
629
  screenshot: screenshotTool,
539
630
  console_errors: consoleTool,
540
631
  };
@@ -1,28 +1,49 @@
1
1
  import { createToolRegistry } from './registry.js';
2
+ import { MCP_ROUTER_NAME, planMcpTools } from './mcp.js';
2
3
 
3
4
  export { createToolRegistry } from './registry.js';
4
5
  export { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
5
- export { DEFAULT_PERMISSIONS, evaluatePermission } from './permissions.js';
6
+ export { DEFAULT_PERMISSIONS, evaluatePermission, bashGuard } from './permissions.js';
6
7
  export { resolveInRoot, toRel } from './paths.js';
7
8
  export { globToRegExp, walkFiles, DEFAULT_IGNORES } from './glob.js';
9
+ export { MCP_ROUTER_NAME, MCP_AUTO_THRESHOLD, useLazyMcp, buildMcpIndex, mcpRouterDefinition, planMcpTools } from './mcp.js';
8
10
  export * as executors from './executors.js';
9
11
 
10
12
  /**
11
13
  * Combine local tools with an optional MCP registry into a single
12
14
  * `{ definitions, execute }` pair for the agent loop.
13
15
  *
16
+ * When the MCP registry advertises many tools (`mcpMode: 'auto'`/`'lazy'`),
17
+ * only the pinned tools keep their full schema and the rest are reached through
18
+ * the `mcp_call` router; the caller injects `buildMcpIndex(mcp.tools)` into the
19
+ * prompt so the model knows what exists.
20
+ *
14
21
  * @param {object} opts - same options as createToolRegistry, plus:
15
- * @param {{definitions:object[], handles:(n:string)=>boolean, call:Function}} [opts.mcp]
22
+ * @param {{definitions:object[], tools?:object[], handles:(n:string)=>boolean, call:Function}} [opts.mcp]
23
+ * @param {'auto'|'eager'|'lazy'} [opts.mcpMode]
24
+ * @param {string[]} [opts.mcpPinned] - tool/server names always exposed in full
25
+ * @param {number} [opts.mcpThreshold] - auto cut-over (default 12)
16
26
  */
17
- export function createTools({ mcp, ...registryOptions } = {}) {
27
+ export function createTools({ mcp, mcpMode = 'auto', mcpPinned = [], mcpThreshold, ...registryOptions } = {}) {
18
28
  const registry = createToolRegistry(registryOptions);
19
- const definitions = [...registry.definitions, ...(mcp?.definitions ?? [])];
29
+ const planned = planMcpTools(mcp, { mode: mcpMode, pinned: mcpPinned, threshold: mcpThreshold });
30
+ const definitions = [...registry.definitions, ...planned.definitions];
20
31
 
21
32
  async function execute(name, args, context) {
22
33
  if (registry.handles(name)) return registry.execute(name, args, context);
23
- if (mcp?.handles?.(name)) return mcp.call(name, args);
34
+ // Las tools MCP y el router pasan por el mismo control de permisos que las
35
+ // locales (default `ask`): se evalua/consulta con el nombre real de la tool.
36
+ const routed = planned.routed && name === MCP_ROUTER_NAME;
37
+ const toolName = routed ? String(args?.tool ?? '').trim() : name;
38
+ if (mcp?.handles?.(toolName)) {
39
+ const toolArgs = routed ? args?.args ?? {} : args;
40
+ const decision = await registry.authorize(toolName, toolArgs);
41
+ if (decision === 'deny') return `Permiso denegado para "${toolName}".`;
42
+ return mcp.call(toolName, toolArgs);
43
+ }
44
+ if (routed) return `Herramienta MCP desconocida: "${toolName}". Mirá el índice de MCP.`;
24
45
  return `Error: herramienta desconocida "${name}".`;
25
46
  }
26
47
 
27
- return { definitions, execute, registry };
48
+ return { definitions, execute, registry, mcp: { routed: planned.routed } };
28
49
  }
@@ -67,6 +67,8 @@ export function summarizeToolCall(name, args = {}) {
67
67
  case 'task':
68
68
  case 'spawn_agent':
69
69
  return shorten(a.description || a.prompt, 80);
70
+ case 'mcp_call':
71
+ return shorten(a.tool, 70);
70
72
  default:
71
73
  return '';
72
74
  }
@@ -0,0 +1,91 @@
1
+ // Enrutamiento de tools MCP (progressive disclosure). En vez de inyectar el
2
+ // esquema de TODAS las tools MCP en cada turno (ruido de contexto y "fatiga de
3
+ // atención"), cuando hay muchas se expone un indice compacto y una unica tool
4
+ // router `mcp_call({ tool, args })`; las tools "fijadas" (hot) siguen cargadas
5
+ // con su esquema completo.
6
+
7
+ export const MCP_ROUTER_NAME = 'mcp_call';
8
+
9
+ /** Corte de `auto`: por debajo de esto se cargan completas, por encima se difiere. */
10
+ export const MCP_AUTO_THRESHOLD = 12;
11
+
12
+ const INDEX_DESC_CHARS = 140;
13
+
14
+ /** Decide si conviene diferir las tools MCP segun el modo y el total. */
15
+ export function useLazyMcp(mode = 'auto', total = 0, threshold = MCP_AUTO_THRESHOLD) {
16
+ if (mode === 'eager') return false;
17
+ if (mode === 'lazy') return total > 0;
18
+ return total > threshold;
19
+ }
20
+
21
+ /**
22
+ * Indice compacto agrupado por servidor (nombre + descripcion recortada), sin
23
+ * los esquemas. El modelo lo lee y llama `mcp_call` con el nombre exacto.
24
+ */
25
+ export function buildMcpIndex(tools = []) {
26
+ if (!Array.isArray(tools) || !tools.length) return '';
27
+ const groups = new Map();
28
+ for (const tool of tools) {
29
+ const key = tool.server ?? 'mcp';
30
+ if (!groups.has(key)) groups.set(key, { name: tool.serverName ?? key, tools: [] });
31
+ groups.get(key).tools.push(tool);
32
+ }
33
+ const blocks = [
34
+ [
35
+ 'Tenés herramientas MCP disponibles, pero son demasiadas para cargarlas todas en contexto.',
36
+ 'Según la tarea, usá `mcp_call({ tool, args })` con el nombre exacto de la herramienta de este índice:',
37
+ ].join(' '),
38
+ ];
39
+ for (const group of groups.values()) {
40
+ const lines = [`\n## ${group.name}`];
41
+ for (const tool of group.tools) {
42
+ const desc = String(tool.description ?? '').replace(/\s+/g, ' ').trim();
43
+ const hint = desc.length > INDEX_DESC_CHARS ? `${desc.slice(0, INDEX_DESC_CHARS)}…` : desc;
44
+ lines.push(`- ${tool.name}${hint ? ` — ${hint}` : ''}`);
45
+ }
46
+ blocks.push(lines.join('\n'));
47
+ }
48
+ return blocks.join('\n');
49
+ }
50
+
51
+ /** Definicion (OpenAI-schema) del router que ejecuta cualquier tool MCP. */
52
+ export function mcpRouterDefinition() {
53
+ return {
54
+ type: 'function',
55
+ function: {
56
+ name: MCP_ROUTER_NAME,
57
+ description:
58
+ 'Ejecuta una herramienta MCP por su nombre completo. Usala para las herramientas del índice de MCP que no estén cargadas directamente (hay demasiadas para exponerlas todas).',
59
+ parameters: {
60
+ type: 'object',
61
+ properties: {
62
+ tool: { type: 'string', description: 'Nombre completo, ej. "mcp__postgres__query".' },
63
+ args: { type: 'object', description: 'Argumentos de la herramienta (según su esquema).' },
64
+ },
65
+ required: ['tool'],
66
+ },
67
+ },
68
+ };
69
+ }
70
+
71
+ function isPinned(name, pinned) {
72
+ for (const entry of pinned) {
73
+ const text = String(entry ?? '').trim();
74
+ if (!text) continue;
75
+ if (name === text || name.startsWith(`mcp__${text}__`)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Elige que definiciones de MCP exponer este turno.
82
+ * @returns {{definitions:object[], routed:boolean}}
83
+ */
84
+ export function planMcpTools(mcp, { mode = 'auto', pinned = [], threshold = MCP_AUTO_THRESHOLD } = {}) {
85
+ const all = Array.isArray(mcp?.definitions) ? mcp.definitions : [];
86
+ if (!all.length) return { definitions: [], routed: false };
87
+ if (!useLazyMcp(mode, all.length, threshold)) return { definitions: all, routed: false };
88
+ const hot = Array.isArray(pinned) ? pinned : [];
89
+ const eager = all.filter((d) => isPinned(d.function?.name, hot));
90
+ return { definitions: [...eager, mcpRouterDefinition()], routed: true };
91
+ }