@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
@@ -13,6 +13,7 @@ const state = {
13
13
  active: { provider: '', model: '' },
14
14
  pendingInput: null,
15
15
  permissionCards: new Map(),
16
+ questionCards: new Map(),
16
17
  attachments: [],
17
18
  home: null,
18
19
  cwd: '',
@@ -32,6 +33,8 @@ const state = {
32
33
  // del stream a proposito: un cliente sin stream abierto igual tiene que poder
33
34
  // ver que la IA esta esperando una autorizacion suya.
34
35
  pendingAsks: new Map(),
36
+ // Formularios interactivos (`ask_user`) esperando respuesta: askId -> { sessionId }.
37
+ pendingQuestions: new Map(),
35
38
  // Ponerlo en cola para reconciliar apenas termine la que esta en curso.
36
39
  reconcileAgain: false,
37
40
  };
@@ -196,7 +199,7 @@ function activateTab(name) {
196
199
  hideMoreMenu();
197
200
  if (name === 'runs') refreshRuns();
198
201
  if (name === 'providers') refreshProviders();
199
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshMemory(); refreshSchedules(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
202
+ if (name === 'ajustes') { refreshMcp(); refreshMcpCatalog(); refreshSkills(); refreshSkillCatalog(); refreshMemory(); refreshSchedules(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
200
203
  }
201
204
 
202
205
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -1021,6 +1024,7 @@ async function renderPane(id) {
1021
1024
  // borra. Hay que repintarlos aca: sin esto una tarjeta de permiso se ve y
1022
1025
  // al primer re-render (run_start, sweep, reconexion) desaparece para siempre.
1023
1026
  for (const ask of detail.permissions ?? []) showStandalonePermission(record, ask);
1027
+ for (const ask of detail.questions ?? []) showStandaloneQuestion(record, ask);
1024
1028
  return detail;
1025
1029
  }
1026
1030
 
@@ -1521,6 +1525,152 @@ function resolvePermissionCard(entry, allow) {
1521
1525
  entry.verdict.textContent = allow ? 'aprobado desde otra ventana' : 'denegado desde otra ventana';
1522
1526
  }
1523
1527
 
1528
+ /* --- formularios interactivos (ask_user) ---------------------------------- */
1529
+
1530
+ function liveQuestion(askId) {
1531
+ const entry = state.questionCards.get(askId);
1532
+ return entry && entry.card.isConnected ? entry : null;
1533
+ }
1534
+
1535
+ /**
1536
+ * Formulario que el modelo pide con `ask_user`: en vez de preguntas numeradas en
1537
+ * el texto, la consola renderiza los campos y el usuario los contesta de una.
1538
+ */
1539
+ function addQuestionCard(container, data) {
1540
+ const previous = state.questionCards.get(data.askId);
1541
+ if (previous && previous.card.isConnected) return previous.card;
1542
+ const card = el('div', 'question-card');
1543
+ if (data.title) card.appendChild(el('div', 'question-title', data.title));
1544
+ const form = el('form', 'question-form');
1545
+ const fields = [];
1546
+ for (const q of data.questions ?? []) {
1547
+ const field = el('label', 'question-field');
1548
+ field.appendChild(el('span', 'question-label', q.label));
1549
+ let input;
1550
+ if (q.type === 'textarea') {
1551
+ input = document.createElement('textarea');
1552
+ input.rows = 3;
1553
+ } else if (q.type === 'select' || q.type === 'confirm') {
1554
+ input = document.createElement('select');
1555
+ const options = q.type === 'confirm' ? ['sí', 'no'] : q.options ?? [];
1556
+ for (const opt of options) {
1557
+ const option = document.createElement('option');
1558
+ option.value = opt;
1559
+ option.textContent = opt;
1560
+ input.appendChild(option);
1561
+ }
1562
+ } else {
1563
+ input = document.createElement('input');
1564
+ input.type = 'text';
1565
+ }
1566
+ if (q.placeholder && 'placeholder' in input) input.placeholder = q.placeholder;
1567
+ input.name = q.id;
1568
+ input.className = 'question-input';
1569
+ field.appendChild(input);
1570
+ form.appendChild(field);
1571
+ fields.push({ q, input });
1572
+ }
1573
+ const actions = el('div', 'question-actions');
1574
+ const status = el('span', 'muted', '');
1575
+ const submit = button('enviar', () => {});
1576
+ submit.type = 'submit';
1577
+ submit.classList.add('btn', 'primary');
1578
+ actions.append(status, el('span', 'spacer'), submit);
1579
+ form.appendChild(actions);
1580
+ card.appendChild(form);
1581
+ (container.tools ?? container).appendChild(card);
1582
+ scrollMessages(paneOf(container));
1583
+ state.questionCards.set(data.askId, { card, fields, status });
1584
+
1585
+ const settle = (text, klass) => {
1586
+ card.classList.add(klass);
1587
+ status.textContent = text;
1588
+ submit.disabled = true;
1589
+ for (const { input } of fields) input.disabled = true;
1590
+ state.pendingQuestions.delete(data.askId);
1591
+ renderAlertSignals();
1592
+ };
1593
+
1594
+ form.addEventListener('submit', async (event) => {
1595
+ event.preventDefault();
1596
+ const answers = {};
1597
+ for (const { q, input } of fields) {
1598
+ answers[q.id] = q.type === 'confirm' ? input.value === 'sí' : input.value;
1599
+ }
1600
+ submit.disabled = true;
1601
+ try {
1602
+ await api(`/api/questions/${encodeURIComponent(data.askId)}`, { method: 'POST', body: { answers } });
1603
+ settle('enviado', 'answered');
1604
+ } catch (error) {
1605
+ if (error?.status === 404) {
1606
+ settle('ya no aplica', 'answered');
1607
+ reconcile({ immediate: true });
1608
+ return;
1609
+ }
1610
+ submit.disabled = false;
1611
+ status.textContent = `no se pudo enviar: ${error.message}`;
1612
+ }
1613
+ });
1614
+ return card;
1615
+ }
1616
+
1617
+ /** Formulario pendiente recreado tras un reload (no se reconstruye de mensajes). */
1618
+ function showStandaloneQuestion(record, ask) {
1619
+ if (liveQuestion(ask.askId)) return;
1620
+ record.el.querySelector('.empty')?.remove();
1621
+ const holder = addMessage('assistant', '', record.el);
1622
+ holder.textEl.remove();
1623
+ addQuestionCard(holder, { askId: ask.askId, title: ask.title, questions: ask.questions });
1624
+ state.pendingQuestions.set(ask.askId, { sessionId: record.id });
1625
+ renderAlertSignals();
1626
+ }
1627
+
1628
+ /* --- botones de opcion (offer_choices) ------------------------------------ */
1629
+
1630
+ function sendChoice(label) {
1631
+ chatInput.value = label;
1632
+ sendMessage();
1633
+ }
1634
+
1635
+ /** Botones tappables: al tocar uno se manda como mensaje del usuario. */
1636
+ function addChoiceCard(container, { question, options } = {}) {
1637
+ const items = Array.isArray(options) ? options.map((o) => String(o).trim()).filter(Boolean).slice(0, 6) : [];
1638
+ if (!items.length || !container) return null;
1639
+ const card = el('div', 'choice-card');
1640
+ if (question) card.appendChild(el('div', 'choice-question', question));
1641
+ const row = el('div', 'choice-options');
1642
+ for (const label of items) row.appendChild(button(label, () => sendChoice(label), 'btn choice-btn'));
1643
+ card.appendChild(row);
1644
+ (container.tools ?? container).appendChild(card);
1645
+ scrollMessages(paneOf(container));
1646
+ return card;
1647
+ }
1648
+
1649
+ /* --- plan/checklist (show_plan) ------------------------------------------- */
1650
+
1651
+ const PLAN_MARKS = { done: '✓', active: '◐', pending: '○' };
1652
+
1653
+ /** Checklist interactivo: el modelo lo reemite para actualizar el estado. */
1654
+ function addPlanCard(container, { title, steps } = {}) {
1655
+ const items = Array.isArray(steps)
1656
+ ? steps.filter((s) => s && String(s.text ?? '').trim()).slice(0, 20)
1657
+ : [];
1658
+ if (!items.length || !container) return null;
1659
+ const card = el('div', 'plan-card');
1660
+ if (title) card.appendChild(el('div', 'plan-title', String(title)));
1661
+ const list = el('ul', 'plan-steps');
1662
+ for (const step of items) {
1663
+ const status = ['done', 'active', 'pending'].includes(step.status) ? step.status : 'pending';
1664
+ const li = el('li', `plan-step ${status}`);
1665
+ li.append(el('span', 'plan-mark', PLAN_MARKS[status]), el('span', 'plan-text', String(step.text)));
1666
+ list.appendChild(li);
1667
+ }
1668
+ card.appendChild(list);
1669
+ (container.tools ?? container).appendChild(card);
1670
+ scrollMessages(paneOf(container));
1671
+ return card;
1672
+ }
1673
+
1524
1674
  function userText(content) {
1525
1675
  if (typeof content === 'string') return content;
1526
1676
  if (Array.isArray(content)) {
@@ -1540,11 +1690,15 @@ function userImages(content) {
1540
1690
  .filter(Boolean);
1541
1691
  }
1542
1692
 
1543
- function advisorGlyph(kind) {
1544
- if (kind === 'code') return '⚠';
1545
- if (kind === 'explain') return 'ℹ';
1546
- if (kind === 'summary') return '✎';
1547
- return '✦';
1693
+ const ADVISOR_KINDS = {
1694
+ code: { label: 'Cuidado!', glyph: '⚠' },
1695
+ explain: { label: 'Importante!', glyph: 'ℹ' },
1696
+ advice: { label: 'Consejo', glyph: '✦' },
1697
+ summary: { label: 'Resumen', glyph: '✎' },
1698
+ };
1699
+
1700
+ function advisorMeta(kind) {
1701
+ return ADVISOR_KINDS[kind] || { label: 'Asesor', glyph: '✦' };
1548
1702
  }
1549
1703
 
1550
1704
  /** Tarjeta del asesor: una capa de comentario, no un mensaje del agente. */
@@ -1553,9 +1707,15 @@ function addAdvisorCard(container, { text, kind } = {}) {
1553
1707
  if (!clean || !container) return null;
1554
1708
  const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
1555
1709
  if (exists) return null;
1556
- const card = el('div', `advisor-card${kind ? ` advisor-${kind}` : ''}`);
1710
+ const meta = advisorMeta(kind);
1711
+ const klass = ADVISOR_KINDS[kind] ? ` advisor-${kind}` : '';
1712
+ const card = el('div', `advisor-card${klass}`);
1557
1713
  const head = el('div', 'advisor-head');
1558
- head.append(el('span', 'advisor-glyph', advisorGlyph(kind)), el('span', 'advisor-title', 'asesor'));
1714
+ head.append(
1715
+ el('span', 'advisor-glyph', meta.glyph),
1716
+ el('span', 'advisor-title', 'asesor'),
1717
+ el('span', `advisor-badge advisor-badge-${kind || 'advice'}`, meta.label)
1718
+ );
1559
1719
  card.append(head, el('div', 'advisor-text', clean));
1560
1720
  container.appendChild(card);
1561
1721
  return card;
@@ -1586,11 +1746,14 @@ function renderStored(message, pane) {
1586
1746
  }
1587
1747
  for (const call of message.tool_calls ?? []) {
1588
1748
  const name = call.function?.name ?? call.name;
1589
- const tool = addToolCard(bubble, name, parseToolArgs(call), { callId: call.id, open: false });
1749
+ const args = parseToolArgs(call);
1750
+ const tool = addToolCard(bubble, name, args, { callId: call.id, open: false });
1590
1751
  tool.card.classList.remove('running');
1591
1752
  tool.card.classList.add('done');
1592
1753
  tool.glyph.textContent = '✓';
1593
1754
  if (call.id) toolCardIndex.set(call.id, tool);
1755
+ if (name === 'offer_choices') addChoiceCard(bubble, { question: args.question, options: args.options });
1756
+ if (name === 'show_plan') addPlanCard(bubble, { title: args.title, steps: args.steps });
1594
1757
  }
1595
1758
  } else if (message.role === 'tool') {
1596
1759
  const tool = toolCardIndex.get(message.tool_call_id);
@@ -1780,7 +1943,7 @@ async function removeProject(cwd, count) {
1780
1943
 
1781
1944
  /* --- session tabs --------------------------------------------------------- */
1782
1945
 
1783
- function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, running = false, permissions = []) {
1946
+ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, running = false, permissions = [], questions = []) {
1784
1947
  const id = session.id;
1785
1948
  if (state.panes.has(id)) return state.panes.get(id);
1786
1949
  const pane = document.createElement('div');
@@ -1807,6 +1970,7 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, run
1807
1970
  // sesion. Sin esto, recargar con un permiso en el aire dejaba el run
1808
1971
  // bloqueado esperando una decision que no se podia tomar.
1809
1972
  for (const ask of permissions) showStandalonePermission(record, ask);
1973
+ for (const ask of questions) showStandaloneQuestion(record, ask);
1810
1974
  return record;
1811
1975
  }
1812
1976
 
@@ -1868,7 +2032,7 @@ async function openTab(id) {
1868
2032
  return null;
1869
2033
  });
1870
2034
  if (!data) return;
1871
- registerSession(data.session, data.messages, data.tokens, data.running, data.permissions);
2035
+ registerSession(data.session, data.messages, data.tokens, data.running, data.permissions, data.questions);
1872
2036
  activateSession(id);
1873
2037
  }
1874
2038
 
@@ -2005,7 +2169,7 @@ async function restoreTabs() {
2005
2169
  for (const id of ids) {
2006
2170
  if (state.panes.has(id)) continue;
2007
2171
  const data = await api(`/api/sessions/${id}`).catch(() => null);
2008
- if (data) registerSession(data.session, data.messages, data.tokens, data.running, data.permissions);
2172
+ if (data) registerSession(data.session, data.messages, data.tokens, data.running, data.permissions, data.questions);
2009
2173
  }
2010
2174
  const active = saved?.active && state.panes.has(saved.active) ? saved.active : state.tabOrder[state.tabOrder.length - 1];
2011
2175
  if (active) activateSession(active);
@@ -2206,11 +2370,35 @@ function handleChatEvent(event, data, turn) {
2206
2370
  }
2207
2371
  if (turn.live && data.name === 'preview_url' && data.args?.url) {
2208
2372
  setExplorerMode('preview');
2209
- loadPreview(data.args.url);
2373
+ // Desde otro dispositivo, 127.0.0.1 apunta al dispositivo: si el agente
2374
+ // pidio una URL local, la publicamos por tailscale/tunel.
2375
+ const port = isLocalHost() ? 0 : localPortOf(data.args.url);
2376
+ if (port) openRemoteTunnel(port);
2377
+ else loadPreview(data.args.url);
2378
+ }
2379
+ if (turn.live && data.name === 'offer_choices') {
2380
+ addChoiceCard(target, { question: data.args?.question, options: data.args?.options });
2381
+ }
2382
+ if (turn.live && data.name === 'show_plan') {
2383
+ addPlanCard(target, { title: data.args?.title, steps: data.args?.steps });
2210
2384
  }
2211
2385
  } else if (event === 'tool_result') {
2212
2386
  setActivity(turn.assistant(), 'thinking');
2213
2387
  finishToolCard(turn.lastTool, data);
2388
+ } else if (event === 'question') {
2389
+ if (!liveQuestion(data.askId)) addQuestionCard(turn.assistant(), data);
2390
+ state.pendingQuestions.set(data.askId, { sessionId: data.sessionId ?? turn.sessionKey });
2391
+ renderAlertSignals();
2392
+ if (paneOf(turn.active) !== activePane) toast('Formulario pendiente en otra pestaña.');
2393
+ } else if (event === 'question_answered') {
2394
+ state.pendingQuestions.delete(data?.askId);
2395
+ renderAlertSignals();
2396
+ const entry = state.questionCards.get(data.askId);
2397
+ if (entry && !entry.card.classList.contains('answered')) {
2398
+ entry.card.classList.add('answered');
2399
+ entry.status.textContent = data?.cancelled ? 'cancelado' : 'respondido en otra ventana';
2400
+ for (const { input } of entry.fields) input.disabled = true;
2401
+ }
2214
2402
  } else if (event === 'permission') {
2215
2403
  // Si la tarjeta ya esta en el DOM (la creo `renderPane` antes del replay, o
2216
2404
  // llego por el canal global) no hay que crear OTRO mensaje del asistente
@@ -3830,9 +4018,16 @@ providerModal.addEventListener('click', (event) => {
3830
4018
  /* --- ajustes: MCP + Skills ----------------------------------------------- */
3831
4019
 
3832
4020
  const mcpListEl = $('#mcp-list');
4021
+ const mcpSearchResultsEl = $('#mcp-search-results');
4022
+ const mcpCatalogEl = $('#mcp-catalog');
3833
4023
  const mcpModal = $('#mcp-modal');
3834
4024
  const mcpForm = $('#mcp-form');
4025
+ const mcpModeEl = $('#mcp-mode');
4026
+ const mcpCountEl = $('#mcp-count');
4027
+ const mcpPinnedEl = $('#mcp-pinned');
3835
4028
  const skillListEl = $('#skill-list');
4029
+ const skillSearchResultsEl = $('#skill-search-results');
4030
+ const skillCatalogEl = $('#skill-catalog');
3836
4031
  const skillModal = $('#skill-modal');
3837
4032
  const skillForm = $('#skill-form');
3838
4033
  const memoryListEl = $('#memory-list');
@@ -3927,6 +4122,16 @@ function setStatus(node, text, cls = '') {
3927
4122
  async function refreshMcp({ connect = false } = {}) {
3928
4123
  const data = await api(`/api/mcp${connect ? '?connect=1' : ''}`).catch(() => null);
3929
4124
  if (!data) return;
4125
+ if (mcpModeEl && data.mode) mcpModeEl.value = data.mode;
4126
+ if (mcpPinnedEl && Array.isArray(data.pinned) && document.activeElement !== mcpPinnedEl) {
4127
+ mcpPinnedEl.value = data.pinned.join(', ');
4128
+ }
4129
+ if (mcpCountEl) {
4130
+ mcpCountEl.textContent =
4131
+ data.toolCount == null
4132
+ ? ''
4133
+ : `${data.toolCount} herramientas · ${data.lazy ? 'diferidas (mcp_call)' : 'completas en contexto'}`;
4134
+ }
3930
4135
  clear(mcpListEl);
3931
4136
  if (!data.servers.length) {
3932
4137
  mcpListEl.appendChild(el('li', 'muted', 'sin servidores MCP'));
@@ -5020,6 +5225,137 @@ async function importSkill() {
5020
5225
  });
5021
5226
  }
5022
5227
 
5228
+ /* --- ajustes: biblioteca y buscador de MCP y skills ----------------------- */
5229
+
5230
+ async function refreshMcpCatalog() {
5231
+ if (!mcpCatalogEl) return;
5232
+ const data = await api('/api/mcp/catalog').catch(() => null);
5233
+ if (!data) return;
5234
+ clear(mcpCatalogEl);
5235
+ for (const entry of data.catalog) {
5236
+ const li = el('li', entry.installed ? 'active' : '');
5237
+ const main = el('div', 'grow');
5238
+ main.append(el('div', '', entry.name), el('div', 'pmeta', entry.description || ''));
5239
+ li.appendChild(main);
5240
+ const actions = el('div', 'pactions');
5241
+ if (entry.requiredEnv?.length) actions.appendChild(el('span', 'pmeta', `env: ${entry.requiredEnv.join(', ')}`));
5242
+ if (entry.installed) actions.appendChild(el('span', 'pmeta', 'instalado'));
5243
+ else actions.appendChild(button('agregar', (event) => { event.stopPropagation(); installCatalogMcp(entry.id); }));
5244
+ li.appendChild(actions);
5245
+ mcpCatalogEl.appendChild(li);
5246
+ }
5247
+ }
5248
+
5249
+ async function installCatalogMcp(id) {
5250
+ setStatus('#mcp-import-status', `agregando ${id}…`);
5251
+ const data = await api('/api/mcp/import', { method: 'POST', body: { catalog: id } }).catch((e) => ({ error: e.message }));
5252
+ if (data?.error) return setStatus('#mcp-import-status', data.error, 'err');
5253
+ setStatus('#mcp-import-status', `agregado ${id} (deshabilitado)`, 'ok');
5254
+ await refreshMcp();
5255
+ await refreshMcpCatalog();
5256
+ }
5257
+
5258
+ async function searchMcp() {
5259
+ const input = $('#mcp-search');
5260
+ const q = input?.value.trim();
5261
+ if (!q) return;
5262
+ setStatus('#mcp-search-status', 'buscando…');
5263
+ const data = await api(`/api/mcp/search?q=${encodeURIComponent(q)}`).catch((e) => ({ error: e.message }));
5264
+ if (data?.error) return setStatus('#mcp-search-status', data.error, 'err');
5265
+ clear(mcpSearchResultsEl);
5266
+ if (!data.results.length) {
5267
+ setStatus('#mcp-search-status', 'sin resultados');
5268
+ return;
5269
+ }
5270
+ setStatus('#mcp-search-status', `${data.results.length} resultado(s)`);
5271
+ for (const r of data.results) {
5272
+ const li = el('li', r.installed ? 'active' : '');
5273
+ const main = el('div', 'grow');
5274
+ main.append(el('div', '', r.name || r.id), el('div', 'pmeta', r.description || r.id));
5275
+ li.appendChild(main);
5276
+ const actions = el('div', 'pactions');
5277
+ if (r.requiredEnv?.length) actions.appendChild(el('span', 'pmeta', `env: ${r.requiredEnv.join(', ')}`));
5278
+ if (r.installed) actions.appendChild(el('span', 'pmeta', 'instalado'));
5279
+ else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installMcpFromRegistry(r.registry || r.name); }));
5280
+ li.appendChild(actions);
5281
+ mcpSearchResultsEl.appendChild(li);
5282
+ }
5283
+ }
5284
+
5285
+ async function installMcpFromRegistry(name) {
5286
+ setStatus('#mcp-search-status', `instalando ${name}…`);
5287
+ const data = await api('/api/mcp/import', { method: 'POST', body: { name } }).catch((e) => ({ error: e.message }));
5288
+ if (data?.error) return setStatus('#mcp-search-status', data.error, 'err');
5289
+ const required = (data.requiredEnv ?? []).length ? ` · requiere env: ${data.requiredEnv.join(', ')}` : '';
5290
+ setStatus('#mcp-search-status', `instalado ${data.imported}${required}`, 'ok');
5291
+ await refreshMcp();
5292
+ await refreshMcpCatalog();
5293
+ await searchMcp();
5294
+ }
5295
+
5296
+ async function refreshSkillCatalog() {
5297
+ if (!skillCatalogEl) return;
5298
+ const data = await api('/api/skills/catalog').catch(() => null);
5299
+ if (!data) return;
5300
+ clear(skillCatalogEl);
5301
+ for (const entry of data.catalog) {
5302
+ const li = el('li', entry.installed ? 'active' : '');
5303
+ const main = el('div', 'grow');
5304
+ main.append(el('div', '', entry.name), el('div', 'pmeta', entry.description || ''));
5305
+ li.appendChild(main);
5306
+ const actions = el('div', 'pactions');
5307
+ if (entry.installed) actions.appendChild(el('span', 'pmeta', 'instalada'));
5308
+ else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installCatalogSkill(entry.id); }));
5309
+ li.appendChild(actions);
5310
+ skillCatalogEl.appendChild(li);
5311
+ }
5312
+ }
5313
+
5314
+ async function installCatalogSkill(id) {
5315
+ setStatus('#skill-import-status', `instalando ${id}…`);
5316
+ const data = await api('/api/skills', { method: 'POST', body: { id } }).catch((e) => ({ error: e.message }));
5317
+ if (data?.error) return setStatus('#skill-import-status', data.error, 'err');
5318
+ setStatus('#skill-import-status', `instalada ${id} (deshabilitada)`, 'ok');
5319
+ await refreshSkills();
5320
+ await refreshSkillCatalog();
5321
+ }
5322
+
5323
+ async function searchSkills() {
5324
+ const input = $('#skill-search');
5325
+ const q = input?.value.trim();
5326
+ if (!q) return;
5327
+ setStatus('#skill-search-status', 'buscando…');
5328
+ const data = await api(`/api/skills/search?q=${encodeURIComponent(q)}`).catch((e) => ({ error: e.message }));
5329
+ if (data?.error) return setStatus('#skill-search-status', data.error, 'err');
5330
+ clear(skillSearchResultsEl);
5331
+ if (!data.results.length) {
5332
+ setStatus('#skill-search-status', 'sin resultados');
5333
+ return;
5334
+ }
5335
+ setStatus('#skill-search-status', `${data.results.length} resultado(s)`);
5336
+ for (const r of data.results) {
5337
+ const li = el('li', r.installed ? 'active' : '');
5338
+ const main = el('div', 'grow');
5339
+ main.append(el('div', '', r.source), el('div', 'pmeta', r.description || r.ref));
5340
+ li.appendChild(main);
5341
+ const actions = el('div', 'pactions');
5342
+ if (r.installed) actions.appendChild(el('span', 'pmeta', 'instalada'));
5343
+ else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installSkillRef(r.ref); }));
5344
+ li.appendChild(actions);
5345
+ skillSearchResultsEl.appendChild(li);
5346
+ }
5347
+ }
5348
+
5349
+ async function installSkillRef(ref) {
5350
+ setStatus('#skill-search-status', `instalando ${ref}…`);
5351
+ const data = await api('/api/skills/import', { method: 'POST', body: { ref } }).catch((e) => ({ error: e.message }));
5352
+ if (data?.error) return setStatus('#skill-search-status', data.error, 'err');
5353
+ setStatus('#skill-search-status', `instalada ${data.installed}`, 'ok');
5354
+ await refreshSkills();
5355
+ await refreshSkillCatalog();
5356
+ await searchSkills();
5357
+ }
5358
+
5023
5359
  if (mcpListEl) {
5024
5360
  $('#mcp-new').addEventListener('click', () => openMcpForm());
5025
5361
  $('#mcp-import-btn')?.addEventListener('click', importMcp);
@@ -5036,7 +5372,46 @@ if (mcpListEl) {
5036
5372
  importSkill();
5037
5373
  }
5038
5374
  });
5375
+ $('#mcp-search-btn')?.addEventListener('click', searchMcp);
5376
+ $('#mcp-search')?.addEventListener('keydown', (event) => {
5377
+ if (event.key === 'Enter') {
5378
+ event.preventDefault();
5379
+ searchMcp();
5380
+ }
5381
+ });
5382
+ $('#skill-search-btn')?.addEventListener('click', searchSkills);
5383
+ $('#skill-search')?.addEventListener('keydown', (event) => {
5384
+ if (event.key === 'Enter') {
5385
+ event.preventDefault();
5386
+ searchSkills();
5387
+ }
5388
+ });
5389
+ refreshMcpCatalog();
5390
+ refreshSkillCatalog();
5039
5391
  $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
5392
+ mcpModeEl?.addEventListener('change', async () => {
5393
+ try {
5394
+ await api('/api/settings', { method: 'PUT', body: { mcpMode: mcpModeEl.value } });
5395
+ await refreshMcp({ connect: true });
5396
+ } catch (error) {
5397
+ toast(error.message);
5398
+ }
5399
+ });
5400
+ const savePinnedMcp = async () => {
5401
+ try {
5402
+ await api('/api/settings', { method: 'PUT', body: { mcpPinned: String(mcpPinnedEl.value || '') } });
5403
+ await refreshMcp({ connect: true });
5404
+ } catch (error) {
5405
+ toast(error.message);
5406
+ }
5407
+ };
5408
+ $('#mcp-pinned-save')?.addEventListener('click', savePinnedMcp);
5409
+ mcpPinnedEl?.addEventListener('keydown', (event) => {
5410
+ if (event.key === 'Enter') {
5411
+ event.preventDefault();
5412
+ savePinnedMcp();
5413
+ }
5414
+ });
5040
5415
  $('#tg-save')?.addEventListener('click', submitChannel);
5041
5416
  $('#tg-test')?.addEventListener('click', testChannel);
5042
5417
  $('#voz-save')?.addEventListener('click', submitVoz);
@@ -5523,6 +5898,7 @@ function refreshExplorerMode() {
5523
5898
  if (explorerMode === 'files') refreshExplorer();
5524
5899
  else if (explorerMode === 'procs') detectProcs();
5525
5900
  else if (explorerMode === 'git') refreshGit();
5901
+ else if (explorerMode === 'preview') syncPreviewTunnel();
5526
5902
  }
5527
5903
 
5528
5904
  document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
@@ -5575,16 +5951,75 @@ const previewTools = setupPreviewTools({
5575
5951
  teleport: true,
5576
5952
  });
5577
5953
  restorePreviewMax = () => previewTools.setMax(false);
5578
- previewTools.applyDevice();
5954
+
5955
+ const PREVIEW_URL_KEY = 'tamnora-preview-url';
5956
+ const PREVIEW_DEVICE_KEY = 'tamnora-preview-device';
5957
+
5958
+ /** True si la consola corre en la misma maquina que el server (loopback). */
5959
+ function isLocalHost() {
5960
+ const host = String(location.hostname || '').toLowerCase();
5961
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]' || host.endsWith('.localhost');
5962
+ }
5963
+
5964
+ /** Puerto si la URL apunta a loopback (127.0.0.1/localhost/::1). */
5965
+ function localPortOf(url) {
5966
+ const match = String(url ?? '').match(/^https?:\/\/(?:localhost|127(?:\.\d+){3}|\[::1\]):(\d+)/i);
5967
+ return match ? Number(match[1]) : 0;
5968
+ }
5969
+
5970
+ function setTunnelUi(tunnel) {
5971
+ const closeBtn = $('#preview-close');
5972
+ if (closeBtn) closeBtn.hidden = !tunnel;
5973
+ if (tunnel) {
5974
+ const why = tunnel.provider === 'tailscale' ? 'privado (tailscale)' : tunnel.provider ? tunnel.provider : '';
5975
+ setPreviewStatus(`acceso remoto activo: ${tunnel.url}${why ? ` · ${why}` : ''}`);
5976
+ }
5977
+ }
5978
+
5979
+ async function syncPreviewTunnel({ load = false } = {}) {
5980
+ const data = await api('/api/preview').catch(() => null);
5981
+ const tunnel = data?.tunnel || null;
5982
+ setTunnelUi(tunnel);
5983
+ if (tunnel?.url && load) loadPreview(tunnel.url);
5984
+ return tunnel;
5985
+ }
5986
+
5987
+ /** Espera a que el server confirme el tunel si la respuesta del POST se corto. */
5988
+ async function pollPreviewTunnel(tries = 5, delayMs = 1200) {
5989
+ for (let i = 0; i < tries; i++) {
5990
+ const data = await api('/api/preview').catch(() => null);
5991
+ if (data?.tunnel?.url) return data.tunnel;
5992
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
5993
+ }
5994
+ return null;
5995
+ }
5579
5996
 
5580
5997
  function loadPreview(url) {
5581
5998
  const value = String(url ?? '').trim();
5582
5999
  if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
5583
6000
  previewUrlInput.value = value;
5584
6001
  previewFrame.src = value;
6002
+ storage.set(PREVIEW_URL_KEY, value);
5585
6003
  setPreviewStatus(`cargando ${value} …`);
5586
6004
  }
5587
6005
 
6006
+ /** Abre acceso remoto al puerto (tailscale privado o tunel publico). */
6007
+ async function openRemoteTunnel(port) {
6008
+ const provider = $('#preview-provider')?.value || 'tailscale';
6009
+ setPreviewStatus(`abriendo acceso remoto (${provider})…`);
6010
+ const data = await api('/api/preview/tunnel', { method: 'POST', body: { port, provider } }).catch((e) => ({ error: e.message }));
6011
+ if (data?.error) {
6012
+ // La respuesta pudo cortarse (p. ej. la tablet en background): el server pudo
6013
+ // haber abierto igual el tunel, asi que lo confirmamos antes de fallar.
6014
+ const tunnel = await pollPreviewTunnel();
6015
+ if (!tunnel) return setPreviewStatus(`error: ${data.error}`, true);
6016
+ setTunnelUi(tunnel);
6017
+ return loadPreview(tunnel.url);
6018
+ }
6019
+ setTunnelUi(data.tunnel);
6020
+ loadPreview(data.tunnel.url);
6021
+ }
6022
+
5588
6023
  $('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
5589
6024
  previewUrlInput?.addEventListener('keydown', (event) => {
5590
6025
  if (event.key === 'Enter') {
@@ -5595,16 +6030,29 @@ previewUrlInput?.addEventListener('keydown', (event) => {
5595
6030
  previewFrame?.addEventListener('load', () => {
5596
6031
  if (previewFrame.src) setPreviewStatus(previewFrame.src);
5597
6032
  });
5598
- $('#preview-tunnel')?.addEventListener('click', async () => {
6033
+ $('#preview-tunnel')?.addEventListener('click', () => {
5599
6034
  const match = String(previewUrlInput.value).match(/:(\d+)/);
5600
6035
  const port = match ? Number(match[1]) : 0;
5601
6036
  if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
5602
- setPreviewStatus('abriendo túnel público…');
5603
- const data = await api('/api/preview/tunnel', { method: 'POST', body: { port } }).catch((e) => ({ error: e.message }));
5604
- if (data?.error) return setPreviewStatus(`error: ${data.error}`, true);
5605
- loadPreview(data.tunnel.url);
5606
- setPreviewStatus(`túnel público: ${data.tunnel.url}`);
6037
+ openRemoteTunnel(port);
6038
+ });
6039
+ $('#preview-close')?.addEventListener('click', async () => {
6040
+ await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null);
6041
+ setTunnelUi(null);
6042
+ setPreviewStatus('acceso remoto cerrado.');
5607
6043
  });
6044
+ $('#preview-device')?.addEventListener('change', (event) => storage.set(PREVIEW_DEVICE_KEY, event.target.value));
6045
+
6046
+ // Restaurar lo tipeado y el tamano elegido: en la tablet la pagina se recarga
6047
+ // sola (iOS la descarta en background) y antes se perdia todo el estado.
6048
+ (function restorePreview() {
6049
+ const savedDevice = storage.get(PREVIEW_DEVICE_KEY, '');
6050
+ if (savedDevice && $('#preview-device')) $('#preview-device').value = savedDevice;
6051
+ previewTools.applyDevice();
6052
+ const savedUrl = storage.get(PREVIEW_URL_KEY, '');
6053
+ if (savedUrl) previewUrlInput.value = savedUrl;
6054
+ syncPreviewTunnel({ load: true });
6055
+ })();
5608
6056
 
5609
6057
  /* procs: deteccion y dev servers */
5610
6058
 
@@ -5646,7 +6094,6 @@ async function refreshProcs() {
5646
6094
  }
5647
6095
  if (!data.procs.length) {
5648
6096
  procsListEl.appendChild(el('li', 'muted', 'sin procesos corriendo'));
5649
- return;
5650
6097
  }
5651
6098
  for (const proc of data.procs) {
5652
6099
  const li = el('li', proc.running ? 'active' : '');
@@ -5658,11 +6105,42 @@ async function refreshProcs() {
5658
6105
  li.appendChild(main);
5659
6106
  const actions = el('div', 'pactions');
5660
6107
  actions.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
5661
- if (proc.port) actions.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(`http://127.0.0.1:${proc.port}`); }));
6108
+ if (proc.port) actions.appendChild(button('preview', () => openProcPreview(proc.port)));
5662
6109
  if (proc.running) actions.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
5663
6110
  li.appendChild(actions);
5664
6111
  procsListEl.appendChild(li);
5665
6112
  }
6113
+ await renderTunnelRow();
6114
+ }
6115
+
6116
+ /** Fila del tunel activo: no es un proc (lo maneja la ruta preview), pero
6117
+ * conviene verlo y poder cerrarlo aca. */
6118
+ async function renderTunnelRow() {
6119
+ const data = await api('/api/preview').catch(() => null);
6120
+ const tunnel = data?.tunnel;
6121
+ if (!tunnel?.url) return;
6122
+ const li = el('li', 'active');
6123
+ const main = el('div', 'grow');
6124
+ main.append(
6125
+ el('div', '', `túnel → puerto ${tunnel.port}`),
6126
+ el('div', 'pmeta', `${tunnel.provider || ''} · ${tunnel.url}`)
6127
+ );
6128
+ li.appendChild(main);
6129
+ const actions = el('div', 'pactions');
6130
+ actions.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(tunnel.url); }));
6131
+ actions.appendChild(
6132
+ button('detener', async () => { await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null); refreshProcs(); }, 'btn danger')
6133
+ );
6134
+ li.appendChild(actions);
6135
+ procsListEl.appendChild(li);
6136
+ }
6137
+
6138
+ /** Preview de un proc: local si la consola corre en la misma maquina, remoto
6139
+ * (tailscale/tunel) si se accede desde otro dispositivo. */
6140
+ async function openProcPreview(port) {
6141
+ setExplorerMode('preview');
6142
+ if (isLocalHost()) return loadPreview(`http://127.0.0.1:${port}`);
6143
+ return openRemoteTunnel(port);
5666
6144
  }
5667
6145
 
5668
6146
  async function startProc(chip) {
@@ -5993,7 +6471,7 @@ let baseTitle = '';
5993
6471
  * tienen que marcar que hay un permiso esperando.
5994
6472
  */
5995
6473
  function renderAlertSignals() {
5996
- const pending = state.pendingAsks.size;
6474
+ const pending = state.pendingAsks.size + state.pendingQuestions.size;
5997
6475
  if (!baseTitle) baseTitle = document.title;
5998
6476
  const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
5999
6477
  document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;