@danieltmn/tamnora 0.1.0 → 0.3.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 +63 -0
  2. package/harness/packages/cli/src/cli.js +2 -0
  3. package/harness/packages/cli/src/commands/eval.js +3 -0
  4. package/harness/packages/cli/src/commands/repl.js +2 -0
  5. package/harness/packages/cli/src/commands/run.js +2 -1
  6. package/harness/packages/cli/src/commands/tui.js +18 -11
  7. package/harness/packages/cli/src/lib/runtime.js +3 -1
  8. package/harness/packages/cli/src/lib/tunnel.js +2 -113
  9. package/harness/packages/cli/src/lib/util.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +45 -4
  11. package/harness/packages/cli/src/tui/keys.js +3 -0
  12. package/harness/packages/cli/test/tui.test.js +5 -0
  13. package/harness/packages/core/src/agent/modes.js +2 -2
  14. package/harness/packages/core/src/config/index.js +3 -0
  15. package/harness/packages/core/src/console.js +18 -0
  16. package/harness/packages/core/src/eval/judge.js +3 -3
  17. package/harness/packages/core/src/eval/runner.js +3 -0
  18. package/harness/packages/core/src/index.js +11 -0
  19. package/harness/packages/core/src/mcp/registry.js +117 -0
  20. package/harness/packages/core/src/procs/detect.js +69 -0
  21. package/harness/packages/core/src/providers/ollama.js +3 -3
  22. package/harness/packages/core/src/providers/openaiCompatible.js +3 -3
  23. package/harness/packages/core/src/providers/openrouter.js +2 -2
  24. package/harness/packages/core/src/providers/resilience.js +1 -1
  25. package/harness/packages/core/src/providers/utils.js +43 -0
  26. package/harness/packages/core/src/skills/index.js +90 -0
  27. package/harness/packages/core/src/skills/remote.js +80 -0
  28. package/harness/packages/core/src/storage/sqlite.js +21 -0
  29. package/harness/packages/core/src/tools/executors.js +55 -0
  30. package/harness/packages/core/src/tools/permissions.js +2 -0
  31. package/harness/packages/core/src/tools/schema.js +16 -0
  32. package/harness/packages/core/src/tunnel.js +113 -0
  33. package/harness/packages/core/test/config.test.js +15 -0
  34. package/harness/packages/core/test/mcp-registry.test.js +49 -0
  35. package/harness/packages/core/test/procs.test.js +34 -0
  36. package/harness/packages/core/test/providers.test.js +20 -0
  37. package/harness/packages/core/test/resilience.test.js +1 -0
  38. package/harness/packages/core/test/skills.test.js +69 -0
  39. package/harness/packages/core/test/tools.test.js +10 -0
  40. package/harness/packages/server/public/app.js +484 -6
  41. package/harness/packages/server/public/index.html +134 -6
  42. package/harness/packages/server/public/styles.css +45 -1
  43. package/harness/packages/server/src/cli.js +2 -0
  44. package/harness/packages/server/src/lib/agentTools.js +238 -0
  45. package/harness/packages/server/src/lib/procs.js +125 -0
  46. package/harness/packages/server/src/lib/prompt.js +6 -3
  47. package/harness/packages/server/src/lib/skills.js +69 -0
  48. package/harness/packages/server/src/lib/telegram.js +267 -0
  49. package/harness/packages/server/src/routes/chat.js +9 -1
  50. package/harness/packages/server/src/routes/mcp.js +128 -9
  51. package/harness/packages/server/src/routes/preview.js +58 -0
  52. package/harness/packages/server/src/routes/procs.js +47 -0
  53. package/harness/packages/server/src/routes/sessions.js +10 -0
  54. package/harness/packages/server/src/routes/skills.js +94 -0
  55. package/harness/packages/server/src/server.js +79 -6
  56. package/harness/packages/server/test/server.test.js +268 -1
  57. package/harness/tamnora.example.json +7 -1
  58. package/package.json +1 -1
@@ -57,6 +57,7 @@ const ICON_PATHS = {
57
57
  'folder-open': ['m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2'],
58
58
  file: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z', 'M14 2v4a2 2 0 0 0 2 2h4'],
59
59
  'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
60
+ trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
60
61
  };
61
62
 
62
63
  function icon(name, size = 16, className = '') {
@@ -166,6 +167,7 @@ function activateTab(name) {
166
167
  hideMoreMenu();
167
168
  if (name === 'runs') refreshRuns();
168
169
  if (name === 'proveedores') refreshProviders();
170
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); }
169
171
  }
170
172
 
171
173
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -210,8 +212,10 @@ const PALETTE_COMMANDS = [
210
212
  { id: 'init', label: 'Analizar el proyecto y crear AGENTS.md', hint: '/init', run: () => runSlash('init') },
211
213
  { id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
212
214
  { id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
215
+ { id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
213
216
  { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => settingsModal?.showModal() },
214
217
  { id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('proveedores') },
218
+ { id: 'ajustes', label: 'MCP y Skills (ajustes)', run: () => activateTab('ajustes') },
215
219
  { id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
216
220
  { id: 'sidebar', label: 'Mostrar u ocultar sesiones', run: () => setSidebarOpen(!state.sidebarOpen) },
217
221
  { id: 'theme', label: 'Cambiar tema', run: () => applyTheme(document.documentElement.dataset.theme === 'paper' ? 'dark' : 'paper') },
@@ -369,14 +373,36 @@ async function loadModes() {
369
373
  updateStatus();
370
374
  }
371
375
 
372
- modeSelect.addEventListener('change', async () => {
373
- await api('/api/settings', { method: 'PUT', body: { mode: modeSelect.value } }).catch(() => null);
376
+ function modeLabel(id) {
377
+ const option = [...modeSelect.options].find((o) => o.value === id);
378
+ return option ? option.textContent : id;
379
+ }
380
+
381
+ async function setMode(id, { persist = true } = {}) {
382
+ if (![...modeSelect.options].some((o) => o.value === id)) return;
383
+ modeSelect.value = id;
384
+ if (persist) await api('/api/settings', { method: 'PUT', body: { mode: id } }).catch(() => null);
374
385
  updateStatus();
386
+ }
387
+
388
+ async function toggleMode() {
389
+ const next = modeSelect.value === 'plan' ? 'agent' : 'plan';
390
+ await setMode(next);
391
+ notify(`Modo **${modeLabel(next)}**${next === 'plan' ? ' (solo lectura)' : ''}`);
392
+ }
393
+
394
+ modeSelect.addEventListener('change', () => setMode(modeSelect.value));
395
+
396
+ document.addEventListener('keydown', (event) => {
397
+ if ((event.ctrlKey || event.metaKey) && event.key === '.') {
398
+ event.preventDefault();
399
+ toggleMode();
400
+ }
375
401
  });
376
402
 
377
403
  function updateStatus(extra = {}) {
378
404
  const p = state.providers.find((x) => x.id === state.active.provider);
379
- $('#sb-mode').textContent = modeSelect.value || 'agent';
405
+ $('#sb-mode').textContent = modeLabel(modeSelect.value) || 'Build';
380
406
  $('#sb-provider').textContent = p ? `${p.id}` : state.active.provider || '—';
381
407
  $('#sb-model').textContent = state.active.model || '—';
382
408
  if (extra.rounds !== undefined) $('#sb-rounds').textContent = `${extra.rounds} rondas`;
@@ -948,6 +974,16 @@ async function refreshSessions() {
948
974
  twisty.appendChild(icon(collapsed ? 'chevron-right' : 'chevron-down', 14));
949
975
  head.append(twisty, icon('folder', 15, 'fi'), el('span', 'gname', key ? basename(key) : 'sin carpeta'), el('span', 'gcount', String(items.length)));
950
976
  head.title = key || 'sesiones sin carpeta';
977
+ const delGroup = el('button', 'group-del');
978
+ delGroup.type = 'button';
979
+ delGroup.title = 'eliminar proyecto (borra sus sesiones, no los archivos)';
980
+ delGroup.setAttribute('aria-label', 'eliminar proyecto');
981
+ delGroup.appendChild(icon('trash', 13));
982
+ delGroup.addEventListener('click', (event) => {
983
+ event.stopPropagation();
984
+ removeProject(key, items.length);
985
+ });
986
+ head.appendChild(delGroup);
951
987
  const list = el('ul', 'group-sessions');
952
988
  list.hidden = collapsed;
953
989
  head.addEventListener('click', () => {
@@ -961,8 +997,18 @@ async function refreshSessions() {
961
997
  });
962
998
  for (const session of items) {
963
999
  const li = el('li', `session-item${session.id === state.sessionId ? ' active' : ''}`);
964
- li.appendChild(el('div', '', session.title || '(sin título)'));
1000
+ li.appendChild(el('span', 'session-title', session.title || '(sin título)'));
965
1001
  li.title = `${session.provider ?? ''}/${session.model ?? ''}${session.cwd ? ` · ${session.cwd}` : ''}`;
1002
+ const del = el('button', 'session-del');
1003
+ del.type = 'button';
1004
+ del.title = 'eliminar sesión';
1005
+ del.setAttribute('aria-label', `eliminar sesión ${session.title || ''}`);
1006
+ del.appendChild(icon('trash', 13));
1007
+ del.addEventListener('click', (event) => {
1008
+ event.stopPropagation();
1009
+ removeSession(session);
1010
+ });
1011
+ li.appendChild(del);
966
1012
  li.addEventListener('click', () => {
967
1013
  if (isMobile()) setSidebarOpen(false);
968
1014
  openTab(session.id);
@@ -975,6 +1021,32 @@ async function refreshSessions() {
975
1021
  if (!data.sessions.length) sessionsEl.appendChild(el('li', 'muted', 'sin sesiones'));
976
1022
  }
977
1023
 
1024
+ function closeSessionsOf(cwd) {
1025
+ for (const [id, record] of [...state.panes]) {
1026
+ if ((record.cwd || '') === (cwd || '')) closeTab(id);
1027
+ }
1028
+ }
1029
+
1030
+ async function removeSession(session) {
1031
+ const name = session.title || '(sin título)';
1032
+ if (!window.confirm(`¿Eliminar la sesión "${name}"? No se puede deshacer.`)) return;
1033
+ const error = await api(`/api/sessions/${session.id}`, { method: 'DELETE' }).then(() => null).catch((e) => e);
1034
+ if (error) return notify(`No se pudo eliminar: ${error.message}`, 'notice err');
1035
+ if (state.panes.has(session.id)) closeTab(session.id);
1036
+ refreshSessions();
1037
+ }
1038
+
1039
+ async function removeProject(cwd, count) {
1040
+ const label = cwd ? basename(cwd) : 'sin carpeta';
1041
+ if (!window.confirm(`¿Eliminar ${count} sesión(es) del proyecto "${label}"? No se tocan los archivos del disco.`)) return;
1042
+ const error = await api(`/api/projects?cwd=${encodeURIComponent(cwd ?? '')}`, { method: 'DELETE' })
1043
+ .then(() => null)
1044
+ .catch((e) => e);
1045
+ if (error) return notify(`No se pudo eliminar el proyecto: ${error.message}`, 'notice err');
1046
+ closeSessionsOf(cwd);
1047
+ refreshSessions();
1048
+ }
1049
+
978
1050
  /* --- session tabs --------------------------------------------------------- */
979
1051
 
980
1052
  function registerSession(session, messages = [], tokens = { in: 0, out: 0 }) {
@@ -1847,6 +1919,275 @@ providerModal.addEventListener('click', (event) => {
1847
1919
  if (event.target === providerModal) closeProviderForm();
1848
1920
  });
1849
1921
 
1922
+ /* --- ajustes: MCP + Skills ----------------------------------------------- */
1923
+
1924
+ const mcpListEl = $('#mcp-list');
1925
+ const mcpModal = $('#mcp-modal');
1926
+ const mcpForm = $('#mcp-form');
1927
+ const skillListEl = $('#skill-list');
1928
+ const skillModal = $('#skill-modal');
1929
+ const skillForm = $('#skill-form');
1930
+
1931
+ function setStatus(node, text, cls = '') {
1932
+ const elNode = $(node);
1933
+ if (!elNode) return;
1934
+ elNode.className = `summary ${cls}`.trim();
1935
+ elNode.textContent = text;
1936
+ }
1937
+
1938
+ async function refreshMcp({ connect = false } = {}) {
1939
+ const data = await api(`/api/mcp${connect ? '?connect=1' : ''}`).catch(() => null);
1940
+ if (!data) return;
1941
+ clear(mcpListEl);
1942
+ if (!data.servers.length) {
1943
+ mcpListEl.appendChild(el('li', 'muted', 'sin servidores MCP'));
1944
+ return;
1945
+ }
1946
+ for (const s of data.servers) {
1947
+ const li = el('li', s.enabled ? 'active' : '');
1948
+ li.appendChild(el('span', `pdot${s.ok !== false ? ' on' : ''}`));
1949
+ const main = el('div', 'grow');
1950
+ const title = el('div');
1951
+ title.append(el('span', 'pid', s.id), document.createTextNode(' '), el('span', 'pmeta', s.name || ''));
1952
+ const detail = s.transport === 'http' ? s.url || '' : `${s.command || ''} ${(s.args || []).join(' ')}`.trim();
1953
+ const parts = [s.transport, detail || '(sin destino)', s.source === 'config' ? 'tamnora.json' : 'guardado'];
1954
+ if (connect) {
1955
+ if (s.ok === false) parts.push(`error: ${s.error}`);
1956
+ else parts.push(`${(s.tools || []).length} tools`);
1957
+ }
1958
+ main.append(title, el('div', 'pmeta', parts.join(' · ')));
1959
+ li.appendChild(main);
1960
+ li.appendChild(button(s.enabled ? 'deshabilitar' : 'habilitar', (event) => { event.stopPropagation(); toggleMcp(s); }));
1961
+ li.appendChild(button('editar', (event) => { event.stopPropagation(); openMcpForm(s); }));
1962
+ if (s.source !== 'config') li.appendChild(button('borrar', (event) => { event.stopPropagation(); removeMcp(s.id); }, 'btn danger'));
1963
+ li.addEventListener('click', () => openMcpForm(s));
1964
+ mcpListEl.appendChild(li);
1965
+ }
1966
+ }
1967
+
1968
+ function syncMcpFormFields() {
1969
+ const http = $('#mf-transport').value === 'http';
1970
+ $('#mf-command-field').hidden = http;
1971
+ $('#mf-args-field').hidden = http;
1972
+ $('#mf-url-field').hidden = !http;
1973
+ }
1974
+
1975
+ function openMcpForm(server = null) {
1976
+ $('#mcp-title').textContent = server ? `editar ${server.id}` : 'nuevo servidor MCP';
1977
+ $('#mf-id').value = server?.id ?? '';
1978
+ $('#mf-id').readOnly = Boolean(server);
1979
+ $('#mf-name').value = server?.name ?? '';
1980
+ $('#mf-transport').value = server?.transport === 'http' ? 'http' : 'stdio';
1981
+ $('#mf-command').value = server?.command ?? '';
1982
+ $('#mf-args').value = (server?.args ?? []).join(' ');
1983
+ $('#mf-url').value = server?.url ?? '';
1984
+ $('#mf-enabled').checked = server ? server.enabled !== false : true;
1985
+ syncMcpFormFields();
1986
+ setStatus('#mf-status', '');
1987
+ mcpModal.showModal();
1988
+ if (!server) $('#mf-id').focus();
1989
+ }
1990
+
1991
+ function closeMcpForm() {
1992
+ if (mcpModal.open) mcpModal.close();
1993
+ }
1994
+
1995
+ async function toggleMcp(server) {
1996
+ await api(`/api/mcp/servers/${encodeURIComponent(server.id)}`, {
1997
+ method: 'PUT',
1998
+ body: {
1999
+ name: server.name,
2000
+ transport: server.transport,
2001
+ command: server.command,
2002
+ args: server.args,
2003
+ url: server.url,
2004
+ enabled: !server.enabled,
2005
+ },
2006
+ }).catch((error) => notify(`No se pudo actualizar MCP: ${error.message}`, 'notice err'));
2007
+ refreshMcp();
2008
+ }
2009
+
2010
+ async function removeMcp(id) {
2011
+ if (!window.confirm(`¿Eliminar el servidor MCP "${id}"?`)) return;
2012
+ const error = await api(`/api/mcp/servers/${encodeURIComponent(id)}`, { method: 'DELETE' })
2013
+ .then(() => null)
2014
+ .catch((e) => e);
2015
+ if (error) return notify(`No se pudo eliminar: ${error.message}`, 'notice err');
2016
+ refreshMcp();
2017
+ }
2018
+
2019
+ async function submitMcp(event) {
2020
+ event.preventDefault();
2021
+ const id = $('#mf-id').value.trim();
2022
+ if (!id) return setStatus('#mf-status', 'Falta el id.', 'err');
2023
+ const body = {
2024
+ name: $('#mf-name').value.trim() || id,
2025
+ transport: $('#mf-transport').value,
2026
+ command: $('#mf-command').value.trim(),
2027
+ args: $('#mf-args').value.trim(),
2028
+ url: $('#mf-url').value.trim(),
2029
+ enabled: $('#mf-enabled').checked,
2030
+ };
2031
+ try {
2032
+ await api(`/api/mcp/servers/${encodeURIComponent(id)}`, { method: 'PUT', body });
2033
+ } catch (error) {
2034
+ return setStatus('#mf-status', error.message, 'err');
2035
+ }
2036
+ closeMcpForm();
2037
+ refreshMcp();
2038
+ }
2039
+
2040
+ async function refreshSkills() {
2041
+ const data = await api('/api/skills').catch(() => null);
2042
+ if (!data) return;
2043
+ clear(skillListEl);
2044
+ if (!data.skills.length) {
2045
+ skillListEl.appendChild(el('li', 'muted', 'sin skills todavía'));
2046
+ return;
2047
+ }
2048
+ for (const s of data.skills) {
2049
+ const li = el('li', s.enabled ? 'active' : '');
2050
+ li.appendChild(el('span', `pdot${s.enabled ? ' on' : ''}`));
2051
+ const main = el('div', 'grow');
2052
+ main.append(
2053
+ el('div', '', s.name || s.id),
2054
+ el('div', 'pmeta', s.description || s.when || '(sin descripción)')
2055
+ );
2056
+ li.appendChild(main);
2057
+ li.appendChild(button(s.enabled ? 'deshabilitar' : 'habilitar', (event) => { event.stopPropagation(); toggleSkill(s.id); }));
2058
+ li.appendChild(button('editar', (event) => { event.stopPropagation(); openSkillForm(s.id); }));
2059
+ if (s.removable) li.appendChild(button('borrar', (event) => { event.stopPropagation(); removeSkill(s.id); }, 'btn danger'));
2060
+ li.addEventListener('click', () => openSkillForm(s.id));
2061
+ skillListEl.appendChild(li);
2062
+ }
2063
+ }
2064
+
2065
+ async function openSkillForm(id = null) {
2066
+ $('#skill-title').textContent = id ? `editar ${id}` : 'nueva skill';
2067
+ $('#sf-id').value = id ?? '';
2068
+ $('#sf-id').readOnly = Boolean(id);
2069
+ $('#sf-name').value = '';
2070
+ $('#sf-description').value = '';
2071
+ $('#sf-when').value = '';
2072
+ $('#sf-body').value = '';
2073
+ setStatus('#sf-status', '');
2074
+ if (id) {
2075
+ const data = await api(`/api/skills/${encodeURIComponent(id)}`).catch(() => null);
2076
+ if (data?.skill) {
2077
+ $('#sf-name').value = data.skill.name ?? '';
2078
+ $('#sf-description').value = data.skill.description ?? '';
2079
+ $('#sf-when').value = data.skill.when ?? '';
2080
+ $('#sf-body').value = data.skill.body ?? '';
2081
+ }
2082
+ }
2083
+ skillModal.showModal();
2084
+ if (!id) $('#sf-id').focus();
2085
+ }
2086
+
2087
+ function closeSkillForm() {
2088
+ if (skillModal.open) skillModal.close();
2089
+ }
2090
+
2091
+ async function toggleSkill(id) {
2092
+ const error = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST' })
2093
+ .then(() => null)
2094
+ .catch((e) => e);
2095
+ if (error) return notify(`No se pudo cambiar la skill: ${error.message}`, 'notice err');
2096
+ refreshSkills();
2097
+ }
2098
+
2099
+ async function removeSkill(id) {
2100
+ if (!window.confirm(`¿Eliminar la skill "${id}"?`)) return;
2101
+ const error = await api(`/api/skills/${encodeURIComponent(id)}`, { method: 'DELETE' })
2102
+ .then(() => null)
2103
+ .catch((e) => e);
2104
+ if (error) return notify(`No se pudo eliminar: ${error.message}`, 'notice err');
2105
+ refreshSkills();
2106
+ }
2107
+
2108
+ async function submitSkill(event) {
2109
+ event.preventDefault();
2110
+ const id = $('#sf-id').value.trim();
2111
+ if (!id) return setStatus('#sf-status', 'Falta el id.', 'err');
2112
+ const body = {
2113
+ name: $('#sf-name').value.trim() || id,
2114
+ description: $('#sf-description').value.trim(),
2115
+ when: $('#sf-when').value.trim(),
2116
+ body: $('#sf-body').value,
2117
+ };
2118
+ try {
2119
+ await api(`/api/skills/${encodeURIComponent(id)}`, { method: 'PUT', body });
2120
+ } catch (error) {
2121
+ return setStatus('#sf-status', error.message, 'err');
2122
+ }
2123
+ closeSkillForm();
2124
+ refreshSkills();
2125
+ }
2126
+
2127
+ async function importMcp() {
2128
+ const input = $('#mcp-import');
2129
+ const value = input.value.trim();
2130
+ if (!value) return;
2131
+ setStatus('#mcp-import-status', 'importando…');
2132
+ const payload = value.startsWith('{')
2133
+ ? { json: value }
2134
+ : /^https?:\/\//i.test(value)
2135
+ ? { url: value }
2136
+ : { name: value };
2137
+ const data = await api('/api/mcp/import', { method: 'POST', body: payload }).catch((e) => ({ error: e.message }));
2138
+ if (data?.error) return setStatus('#mcp-import-status', data.error, 'err');
2139
+ input.value = '';
2140
+ const required = (data.requiredEnv ?? []).length ? ` · requiere env: ${data.requiredEnv.join(', ')}` : '';
2141
+ setStatus('#mcp-import-status', `Importado ${data.imported}${required}`, 'ok');
2142
+ refreshMcp();
2143
+ }
2144
+
2145
+ async function importSkill() {
2146
+ const input = $('#skill-import');
2147
+ const ref = input.value.trim();
2148
+ if (!ref) return;
2149
+ setStatus('#skill-import-status', 'importando desde GitHub…');
2150
+ const data = await api('/api/skills/import', { method: 'POST', body: { ref } }).catch((e) => ({ error: e.message }));
2151
+ if (data?.error) return setStatus('#skill-import-status', data.error, 'err');
2152
+ input.value = '';
2153
+ setStatus('#skill-import-status', `Importada ${data.installed} (${data.source})`, 'ok');
2154
+ refreshSkills();
2155
+ }
2156
+
2157
+ if (mcpListEl) {
2158
+ $('#mcp-new').addEventListener('click', () => openMcpForm());
2159
+ $('#mcp-import-btn')?.addEventListener('click', importMcp);
2160
+ $('#mcp-import')?.addEventListener('keydown', (event) => {
2161
+ if (event.key === 'Enter') {
2162
+ event.preventDefault();
2163
+ importMcp();
2164
+ }
2165
+ });
2166
+ $('#skill-import-btn')?.addEventListener('click', importSkill);
2167
+ $('#skill-import')?.addEventListener('keydown', (event) => {
2168
+ if (event.key === 'Enter') {
2169
+ event.preventDefault();
2170
+ importSkill();
2171
+ }
2172
+ });
2173
+ $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
2174
+ $('#mcp-close').addEventListener('click', closeMcpForm);
2175
+ $('#mf-cancel').addEventListener('click', closeMcpForm);
2176
+ $('#mf-transport').addEventListener('change', syncMcpFormFields);
2177
+ mcpForm.addEventListener('submit', submitMcp);
2178
+ mcpModal.addEventListener('click', (event) => {
2179
+ if (event.target === mcpModal) closeMcpForm();
2180
+ });
2181
+
2182
+ $('#skill-new').addEventListener('click', () => openSkillForm());
2183
+ $('#skill-close').addEventListener('click', closeSkillForm);
2184
+ $('#sf-cancel').addEventListener('click', closeSkillForm);
2185
+ skillForm.addEventListener('submit', submitSkill);
2186
+ skillModal.addEventListener('click', (event) => {
2187
+ if (event.target === skillModal) closeSkillForm();
2188
+ });
2189
+ }
2190
+
1850
2191
  /* --- eval ---------------------------------------------------------------- */
1851
2192
 
1852
2193
  const evalTasks = $('#eval-tasks');
@@ -2018,7 +2359,7 @@ function setExplorerOpen(open) {
2018
2359
  } catch {
2019
2360
  // storage unavailable
2020
2361
  }
2021
- if (open) refreshExplorer();
2362
+ if (open) refreshExplorerMode();
2022
2363
  }
2023
2364
 
2024
2365
  function fileListUrl(relPath) {
@@ -2147,9 +2488,146 @@ async function openFilePreview(relPath, name) {
2147
2488
  $('#file-modal').showModal();
2148
2489
  }
2149
2490
 
2491
+ /* --- explorer panes: estructura / preview / procs ------------------------- */
2492
+
2493
+ const xpPanes = { files: $('#xp-files'), preview: $('#xp-preview'), procs: $('#xp-procs') };
2494
+ let explorerMode = 'files';
2495
+
2496
+ function setExplorerMode(mode) {
2497
+ explorerMode = xpPanes[mode] ? mode : 'files';
2498
+ for (const [name, pane] of Object.entries(xpPanes)) pane.hidden = name !== explorerMode;
2499
+ document.querySelectorAll('.xtab').forEach((b) => b.classList.toggle('is-active', b.dataset.xp === explorerMode));
2500
+ refreshExplorerMode();
2501
+ }
2502
+
2503
+ function refreshExplorerMode() {
2504
+ if (!state.explorerOpen) return;
2505
+ if (explorerMode === 'files') refreshExplorer();
2506
+ else if (explorerMode === 'procs') detectProcs();
2507
+ }
2508
+
2509
+ document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
2510
+
2511
+ /* preview: iframe del dev server (local o por tunel) */
2512
+
2513
+ const previewFrame = $('#preview-frame');
2514
+ const previewUrlInput = $('#preview-url');
2515
+ const previewStatusEl = $('#preview-status');
2516
+
2517
+ function setPreviewStatus(text, isError = false) {
2518
+ previewStatusEl.textContent = text;
2519
+ previewStatusEl.classList.toggle('err', isError);
2520
+ }
2521
+
2522
+ function loadPreview(url) {
2523
+ const value = String(url ?? '').trim();
2524
+ if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
2525
+ previewUrlInput.value = value;
2526
+ previewFrame.src = value;
2527
+ setPreviewStatus(`cargando ${value} …`);
2528
+ }
2529
+
2530
+ $('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
2531
+ previewUrlInput?.addEventListener('keydown', (event) => {
2532
+ if (event.key === 'Enter') {
2533
+ event.preventDefault();
2534
+ loadPreview(previewUrlInput.value);
2535
+ }
2536
+ });
2537
+ previewFrame?.addEventListener('load', () => {
2538
+ if (previewFrame.src) setPreviewStatus(previewFrame.src);
2539
+ });
2540
+ $('#preview-tunnel')?.addEventListener('click', async () => {
2541
+ const match = String(previewUrlInput.value).match(/:(\d+)/);
2542
+ const port = match ? Number(match[1]) : 0;
2543
+ if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
2544
+ setPreviewStatus('abriendo túnel público…');
2545
+ const data = await api('/api/preview/tunnel', { method: 'POST', body: { port } }).catch((e) => ({ error: e.message }));
2546
+ if (data?.error) return setPreviewStatus(`error: ${data.error}`, true);
2547
+ loadPreview(data.tunnel.url);
2548
+ setPreviewStatus(`túnel público: ${data.tunnel.url}`);
2549
+ });
2550
+
2551
+ /* procs: deteccion y dev servers */
2552
+
2553
+ const procsListEl = $('#procs-list');
2554
+ const procsChipsEl = $('#procs-chips');
2555
+ const procsCwdEl = $('#procs-cwd');
2556
+ const procsLogEl = $('#procs-log');
2557
+ let selectedProc = null;
2558
+
2559
+ async function detectProcs() {
2560
+ clear(procsChipsEl);
2561
+ const cwd = state.cwd || '';
2562
+ procsCwdEl.textContent = cwd ? `cwd: ${cwd}` : 'sin carpeta';
2563
+ const data = await api(`/api/procs/detect?cwd=${encodeURIComponent(cwd)}`).catch(() => null);
2564
+ if (!data) return;
2565
+ for (const chip of data.chips) procsChipsEl.appendChild(button(chip.label, () => startProc(chip), 'xchip'));
2566
+ if (!data.chips.length) procsChipsEl.appendChild(el('span', 'muted', 'no detecté comandos de dev'));
2567
+ await refreshProcs();
2568
+ }
2569
+
2570
+ async function refreshProcs() {
2571
+ const data = await api('/api/procs').catch(() => null);
2572
+ if (!data) return;
2573
+ clear(procsListEl);
2574
+ if (!data.enabled) {
2575
+ procsListEl.appendChild(el('li', 'muted', 'procesos deshabilitados (processes.enabled = false)'));
2576
+ return;
2577
+ }
2578
+ if (!data.procs.length) {
2579
+ procsListEl.appendChild(el('li', 'muted', 'sin procesos corriendo'));
2580
+ return;
2581
+ }
2582
+ for (const proc of data.procs) {
2583
+ const li = el('li', proc.running ? 'active' : '');
2584
+ const main = el('div', 'grow');
2585
+ main.append(
2586
+ el('div', '', `${proc.command} ${(proc.args || []).join(' ')}`),
2587
+ el('div', 'pmeta', `${proc.status}${proc.port ? ` · puerto ${proc.port}` : ''}`)
2588
+ );
2589
+ li.appendChild(main);
2590
+ li.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
2591
+ if (proc.port) li.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(`http://127.0.0.1:${proc.port}`); }));
2592
+ if (proc.running) li.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
2593
+ procsListEl.appendChild(li);
2594
+ }
2595
+ }
2596
+
2597
+ async function startProc(chip) {
2598
+ const data = await api('/api/procs/start', {
2599
+ method: 'POST',
2600
+ body: { cwd: chip.cwd || state.cwd, command: chip.command, args: chip.args, port: chip.port },
2601
+ }).catch((e) => ({ error: e.message }));
2602
+ if (data?.error) {
2603
+ procsCwdEl.classList.add('err');
2604
+ procsCwdEl.textContent = `error: ${data.error}`;
2605
+ return;
2606
+ }
2607
+ procsCwdEl.classList.remove('err');
2608
+ selectedProc = data.proc.id;
2609
+ await refreshProcs();
2610
+ refreshProcLog();
2611
+ }
2612
+
2613
+ async function stopProc(id) {
2614
+ await api(`/api/procs/${id}/stop`, { method: 'POST' }).catch(() => null);
2615
+ await refreshProcs();
2616
+ }
2617
+
2618
+ async function refreshProcLog() {
2619
+ if (!selectedProc) return;
2620
+ const data = await api(`/api/procs/${selectedProc}/logs`).catch(() => null);
2621
+ if (!data) return;
2622
+ procsLogEl.textContent = (data.logs || []).map((l) => l.text).join('').slice(-4000) || '(sin salida)';
2623
+ procsLogEl.scrollTop = procsLogEl.scrollHeight;
2624
+ }
2625
+
2626
+ $('#procs-detect')?.addEventListener('click', detectProcs);
2627
+
2150
2628
  explorerToggle.addEventListener('click', () => setExplorerOpen(!state.explorerOpen));
2151
2629
  $('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
2152
- $('#explorer-refresh').addEventListener('click', refreshExplorer);
2630
+ $('#explorer-refresh').addEventListener('click', refreshExplorerMode);
2153
2631
  explorerFilter.addEventListener('input', applyTreeFilter);
2154
2632
  $('#file-close').addEventListener('click', () => $('#file-modal').close());
2155
2633
  $('#file-modal').addEventListener('click', (event) => {