@danieltmn/tamnora 0.17.0 → 0.19.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 (61) hide show
  1. package/CHANGELOG.md +45 -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 +95 -4
  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 +87 -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 +567 -33
  36. package/harness/packages/server/public/index.html +51 -7
  37. package/harness/packages/server/public/styles.css +124 -4
  38. package/harness/packages/server/src/lib/agentTools.js +45 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +10 -3
  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/lib/ttsSettings.js +11 -0
  45. package/harness/packages/server/src/routes/chat.js +31 -2
  46. package/harness/packages/server/src/routes/mcp.js +54 -2
  47. package/harness/packages/server/src/routes/preview.js +49 -28
  48. package/harness/packages/server/src/routes/providers.js +12 -0
  49. package/harness/packages/server/src/routes/sessions.js +3 -1
  50. package/harness/packages/server/src/routes/skills.js +48 -1
  51. package/harness/packages/server/src/routes/state.js +8 -1
  52. package/harness/packages/server/src/routes/tts.js +4 -2
  53. package/harness/packages/server/src/server.js +37 -1
  54. package/harness/packages/server/test/preview.test.js +111 -0
  55. package/harness/packages/server/test/server.test.js +186 -19
  56. package/harness/packages/server/test/telegram-features.test.js +2 -0
  57. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  58. package/harness/packages/server/test/telegram.test.js +2 -0
  59. package/harness/packages/server/test/tts.test.js +5 -1
  60. package/harness/tamnora.example.json +3 -1
  61. 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
 
@@ -1363,7 +1367,7 @@ async function loadFullToolResult(entry, message) {
1363
1367
  function speakControl(args) {
1364
1368
  const texto = String(args?.texto ?? '').trim();
1365
1369
  if (!texto) return null;
1366
- const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
1370
+ const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, pitch: args?.pitch, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
1367
1371
  btn.append(icon('play', 13), el('span', '', 'escuchar'));
1368
1372
  return btn;
1369
1373
  }
@@ -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);
@@ -2202,15 +2366,39 @@ function handleChatEvent(event, data, turn) {
2202
2366
  turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
2203
2367
  if (turn.live && data.name === 'speak') {
2204
2368
  const texto = String(data.args?.texto ?? '').trim();
2205
- if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
2369
+ if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, pitch: data.args?.pitch, label: 'voz' }).catch(() => {});
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');
@@ -3855,6 +4050,9 @@ const tgStatusEl = $('#tg-status');
3855
4050
  const vozEnabledEl = $('#voz-enabled');
3856
4051
  const vozVoiceEl = $('#voz-voice');
3857
4052
  const vozRateEl = $('#voz-rate');
4053
+ const vozRateValEl = $('#voz-rate-val');
4054
+ const vozPitchEl = $('#voz-pitch');
4055
+ const vozPitchValEl = $('#voz-pitch-val');
3858
4056
  const vozAnnStartEl = $('#voz-ann-start');
3859
4057
  const vozAnnToolsEl = $('#voz-ann-tools');
3860
4058
  const vozAnnEndEl = $('#voz-ann-end');
@@ -3927,6 +4125,16 @@ function setStatus(node, text, cls = '') {
3927
4125
  async function refreshMcp({ connect = false } = {}) {
3928
4126
  const data = await api(`/api/mcp${connect ? '?connect=1' : ''}`).catch(() => null);
3929
4127
  if (!data) return;
4128
+ if (mcpModeEl && data.mode) mcpModeEl.value = data.mode;
4129
+ if (mcpPinnedEl && Array.isArray(data.pinned) && document.activeElement !== mcpPinnedEl) {
4130
+ mcpPinnedEl.value = data.pinned.join(', ');
4131
+ }
4132
+ if (mcpCountEl) {
4133
+ mcpCountEl.textContent =
4134
+ data.toolCount == null
4135
+ ? ''
4136
+ : `${data.toolCount} herramientas · ${data.lazy ? 'diferidas (mcp_call)' : 'completas en contexto'}`;
4137
+ }
3930
4138
  clear(mcpListEl);
3931
4139
  if (!data.servers.length) {
3932
4140
  mcpListEl.appendChild(el('li', 'muted', 'sin servidores MCP'));
@@ -4440,6 +4648,7 @@ const voz = {
4440
4648
  enabled: false,
4441
4649
  voice: 'es-AR-ElenaNeural',
4442
4650
  rate: '+0%',
4651
+ pitch: '+0Hz',
4443
4652
  announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
4444
4653
  },
4445
4654
  };
@@ -4484,13 +4693,53 @@ function ensurePlayer() {
4484
4693
  });
4485
4694
  }
4486
4695
 
4696
+ /** Convierte la velocidad guardada ("+20%") al valor del slider (20). */
4697
+ function rateToSlider(rate) {
4698
+ const m = /^([+-]?)(\d+)%$/.exec(String(rate ?? '').trim());
4699
+ return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
4700
+ }
4701
+
4702
+ /** Convierte el valor del slider (-20) a la velocidad guardada. */
4703
+ function sliderToRate(n) {
4704
+ const v = Math.max(-100, Math.min(100, Math.round(Number(n) || 0)));
4705
+ return v === 0 ? '+0%' : `${v > 0 ? '+' : '-'}${Math.abs(v)}%`;
4706
+ }
4707
+
4708
+ /** Convierte el tono guardado ("-10Hz") al valor del slider (-10). */
4709
+ function pitchToSlider(pitch) {
4710
+ const m = /^([+-]?)(\d+)Hz$/.exec(String(pitch ?? '').trim());
4711
+ return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
4712
+ }
4713
+
4714
+ /** Convierte el valor del slider (-10) al tono guardado. */
4715
+ function sliderToPitch(n) {
4716
+ const v = Math.max(-50, Math.min(50, Math.round(Number(n) || 0)));
4717
+ return v === 0 ? '+0Hz' : `${v > 0 ? '+' : '-'}${Math.abs(v)}Hz`;
4718
+ }
4719
+
4720
+ function etiquetaRate(n) {
4721
+ const v = Math.round(Number(n) || 0);
4722
+ return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)}%`;
4723
+ }
4724
+
4725
+ function etiquetaPitch(n) {
4726
+ const v = Math.round(Number(n) || 0);
4727
+ return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)} Hz`;
4728
+ }
4729
+
4730
+ /** Refresca la etiqueta numérica de los sliders de voz. */
4731
+ function renderVozSliders() {
4732
+ if (vozRateValEl && vozRateEl) vozRateValEl.textContent = etiquetaRate(vozRateEl.value);
4733
+ if (vozPitchValEl && vozPitchEl) vozPitchValEl.textContent = etiquetaPitch(vozPitchEl.value);
4734
+ }
4735
+
4487
4736
  /** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
4488
- function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
4737
+ function enqueueSpeech({ texto, voice, rate, pitch, label = 'audio', maxAgeMs = 0 } = {}) {
4489
4738
  const frase = String(texto ?? '').trim();
4490
4739
  if (!frase) return Promise.resolve();
4491
4740
  ensurePlayer();
4492
4741
  return new Promise((resolve, reject) => {
4493
- ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
4742
+ ap.queue.push({ texto: frase, voice, rate, pitch, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
4494
4743
  if (ap.current) renderAudio();
4495
4744
  else playNextAudio();
4496
4745
  });
@@ -4512,6 +4761,7 @@ function playNextAudio() {
4512
4761
  texto: item.texto,
4513
4762
  voice: item.voice ?? voz.settings.voice,
4514
4763
  rate: item.rate ?? voz.settings.rate,
4764
+ pitch: item.pitch ?? voz.settings.pitch,
4515
4765
  });
4516
4766
  ap.audio.src = `/api/tts?${params}`;
4517
4767
  ap.audio.play().catch(() => {
@@ -4563,8 +4813,8 @@ function renderAudio() {
4563
4813
  }
4564
4814
 
4565
4815
  /** Reproduce una frase contra el endpoint de sintesis del server. */
4566
- function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
4567
- return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
4816
+ function reproducirVoz(frase, { voice, rate, pitch, label, maxAgeMs } = {}) {
4817
+ return enqueueSpeech({ texto: frase, voice, rate, pitch, label, maxAgeMs });
4568
4818
  }
4569
4819
 
4570
4820
  /**
@@ -4619,6 +4869,7 @@ async function aplicarVoz(data) {
4619
4869
  enabled: data.enabled === true,
4620
4870
  voice: data.voice || 'es-AR-ElenaNeural',
4621
4871
  rate: data.rate || '+0%',
4872
+ pitch: data.pitch || '+0Hz',
4622
4873
  announce: {
4623
4874
  start: data.announce?.start !== false,
4624
4875
  tools: data.announce?.tools !== false,
@@ -4639,7 +4890,9 @@ async function aplicarVoz(data) {
4639
4890
  }
4640
4891
  vozVoiceEl.value = voz.settings.voice;
4641
4892
  }
4642
- if (vozRateEl) vozRateEl.value = voz.settings.rate;
4893
+ if (vozRateEl) vozRateEl.value = rateToSlider(voz.settings.rate);
4894
+ if (vozPitchEl) vozPitchEl.value = pitchToSlider(voz.settings.pitch);
4895
+ renderVozSliders();
4643
4896
  if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
4644
4897
  if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
4645
4898
  if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
@@ -4664,7 +4917,8 @@ async function submitVoz() {
4664
4917
  const body = {
4665
4918
  enabled: vozEnabledEl?.checked === true,
4666
4919
  voice: vozVoiceEl?.value,
4667
- rate: vozRateEl?.value,
4920
+ rate: sliderToRate(vozRateEl?.value),
4921
+ pitch: sliderToPitch(vozPitchEl?.value),
4668
4922
  announce: {
4669
4923
  start: vozAnnStartEl?.checked !== false,
4670
4924
  tools: vozAnnToolsEl?.checked !== false,
@@ -4838,10 +5092,15 @@ trTestBtn?.addEventListener('click', toggleTrTest);
4838
5092
 
4839
5093
  async function testVoz() {
4840
5094
  setStatus('#voz-msg', 'sintetizando…');
5095
+ const trato = ($('#perfil-address')?.value || '').trim();
5096
+ const frase = trato
5097
+ ? `${trato}, esta sería la voz predeterminada para mí`
5098
+ : 'Esta sería la voz predeterminada para mí';
4841
5099
  try {
4842
- await reproducirVoz('Hola!, soy tu agente de IA, y así es la voz con la que cuento lo que estoy haciendo. ¿Qué te parece?', {
5100
+ await reproducirVoz(frase, {
4843
5101
  voice: vozVoiceEl?.value,
4844
- rate: vozRateEl?.value,
5102
+ rate: sliderToRate(vozRateEl?.value),
5103
+ pitch: sliderToPitch(vozPitchEl?.value),
4845
5104
  });
4846
5105
  setStatus('#voz-msg', 'sonó', 'ok');
4847
5106
  } catch (error) {
@@ -5020,6 +5279,137 @@ async function importSkill() {
5020
5279
  });
5021
5280
  }
5022
5281
 
5282
+ /* --- ajustes: biblioteca y buscador de MCP y skills ----------------------- */
5283
+
5284
+ async function refreshMcpCatalog() {
5285
+ if (!mcpCatalogEl) return;
5286
+ const data = await api('/api/mcp/catalog').catch(() => null);
5287
+ if (!data) return;
5288
+ clear(mcpCatalogEl);
5289
+ for (const entry of data.catalog) {
5290
+ const li = el('li', entry.installed ? 'active' : '');
5291
+ const main = el('div', 'grow');
5292
+ main.append(el('div', '', entry.name), el('div', 'pmeta', entry.description || ''));
5293
+ li.appendChild(main);
5294
+ const actions = el('div', 'pactions');
5295
+ if (entry.requiredEnv?.length) actions.appendChild(el('span', 'pmeta', `env: ${entry.requiredEnv.join(', ')}`));
5296
+ if (entry.installed) actions.appendChild(el('span', 'pmeta', 'instalado'));
5297
+ else actions.appendChild(button('agregar', (event) => { event.stopPropagation(); installCatalogMcp(entry.id); }));
5298
+ li.appendChild(actions);
5299
+ mcpCatalogEl.appendChild(li);
5300
+ }
5301
+ }
5302
+
5303
+ async function installCatalogMcp(id) {
5304
+ setStatus('#mcp-import-status', `agregando ${id}…`);
5305
+ const data = await api('/api/mcp/import', { method: 'POST', body: { catalog: id } }).catch((e) => ({ error: e.message }));
5306
+ if (data?.error) return setStatus('#mcp-import-status', data.error, 'err');
5307
+ setStatus('#mcp-import-status', `agregado ${id} (deshabilitado)`, 'ok');
5308
+ await refreshMcp();
5309
+ await refreshMcpCatalog();
5310
+ }
5311
+
5312
+ async function searchMcp() {
5313
+ const input = $('#mcp-search');
5314
+ const q = input?.value.trim();
5315
+ if (!q) return;
5316
+ setStatus('#mcp-search-status', 'buscando…');
5317
+ const data = await api(`/api/mcp/search?q=${encodeURIComponent(q)}`).catch((e) => ({ error: e.message }));
5318
+ if (data?.error) return setStatus('#mcp-search-status', data.error, 'err');
5319
+ clear(mcpSearchResultsEl);
5320
+ if (!data.results.length) {
5321
+ setStatus('#mcp-search-status', 'sin resultados');
5322
+ return;
5323
+ }
5324
+ setStatus('#mcp-search-status', `${data.results.length} resultado(s)`);
5325
+ for (const r of data.results) {
5326
+ const li = el('li', r.installed ? 'active' : '');
5327
+ const main = el('div', 'grow');
5328
+ main.append(el('div', '', r.name || r.id), el('div', 'pmeta', r.description || r.id));
5329
+ li.appendChild(main);
5330
+ const actions = el('div', 'pactions');
5331
+ if (r.requiredEnv?.length) actions.appendChild(el('span', 'pmeta', `env: ${r.requiredEnv.join(', ')}`));
5332
+ if (r.installed) actions.appendChild(el('span', 'pmeta', 'instalado'));
5333
+ else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installMcpFromRegistry(r.registry || r.name); }));
5334
+ li.appendChild(actions);
5335
+ mcpSearchResultsEl.appendChild(li);
5336
+ }
5337
+ }
5338
+
5339
+ async function installMcpFromRegistry(name) {
5340
+ setStatus('#mcp-search-status', `instalando ${name}…`);
5341
+ const data = await api('/api/mcp/import', { method: 'POST', body: { name } }).catch((e) => ({ error: e.message }));
5342
+ if (data?.error) return setStatus('#mcp-search-status', data.error, 'err');
5343
+ const required = (data.requiredEnv ?? []).length ? ` · requiere env: ${data.requiredEnv.join(', ')}` : '';
5344
+ setStatus('#mcp-search-status', `instalado ${data.imported}${required}`, 'ok');
5345
+ await refreshMcp();
5346
+ await refreshMcpCatalog();
5347
+ await searchMcp();
5348
+ }
5349
+
5350
+ async function refreshSkillCatalog() {
5351
+ if (!skillCatalogEl) return;
5352
+ const data = await api('/api/skills/catalog').catch(() => null);
5353
+ if (!data) return;
5354
+ clear(skillCatalogEl);
5355
+ for (const entry of data.catalog) {
5356
+ const li = el('li', entry.installed ? 'active' : '');
5357
+ const main = el('div', 'grow');
5358
+ main.append(el('div', '', entry.name), el('div', 'pmeta', entry.description || ''));
5359
+ li.appendChild(main);
5360
+ const actions = el('div', 'pactions');
5361
+ if (entry.installed) actions.appendChild(el('span', 'pmeta', 'instalada'));
5362
+ else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installCatalogSkill(entry.id); }));
5363
+ li.appendChild(actions);
5364
+ skillCatalogEl.appendChild(li);
5365
+ }
5366
+ }
5367
+
5368
+ async function installCatalogSkill(id) {
5369
+ setStatus('#skill-import-status', `instalando ${id}…`);
5370
+ const data = await api('/api/skills', { method: 'POST', body: { id } }).catch((e) => ({ error: e.message }));
5371
+ if (data?.error) return setStatus('#skill-import-status', data.error, 'err');
5372
+ setStatus('#skill-import-status', `instalada ${id} (deshabilitada)`, 'ok');
5373
+ await refreshSkills();
5374
+ await refreshSkillCatalog();
5375
+ }
5376
+
5377
+ async function searchSkills() {
5378
+ const input = $('#skill-search');
5379
+ const q = input?.value.trim();
5380
+ if (!q) return;
5381
+ setStatus('#skill-search-status', 'buscando…');
5382
+ const data = await api(`/api/skills/search?q=${encodeURIComponent(q)}`).catch((e) => ({ error: e.message }));
5383
+ if (data?.error) return setStatus('#skill-search-status', data.error, 'err');
5384
+ clear(skillSearchResultsEl);
5385
+ if (!data.results.length) {
5386
+ setStatus('#skill-search-status', 'sin resultados');
5387
+ return;
5388
+ }
5389
+ setStatus('#skill-search-status', `${data.results.length} resultado(s)`);
5390
+ for (const r of data.results) {
5391
+ const li = el('li', r.installed ? 'active' : '');
5392
+ const main = el('div', 'grow');
5393
+ main.append(el('div', '', r.source), el('div', 'pmeta', r.description || r.ref));
5394
+ li.appendChild(main);
5395
+ const actions = el('div', 'pactions');
5396
+ if (r.installed) actions.appendChild(el('span', 'pmeta', 'instalada'));
5397
+ else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installSkillRef(r.ref); }));
5398
+ li.appendChild(actions);
5399
+ skillSearchResultsEl.appendChild(li);
5400
+ }
5401
+ }
5402
+
5403
+ async function installSkillRef(ref) {
5404
+ setStatus('#skill-search-status', `instalando ${ref}…`);
5405
+ const data = await api('/api/skills/import', { method: 'POST', body: { ref } }).catch((e) => ({ error: e.message }));
5406
+ if (data?.error) return setStatus('#skill-search-status', data.error, 'err');
5407
+ setStatus('#skill-search-status', `instalada ${data.installed}`, 'ok');
5408
+ await refreshSkills();
5409
+ await refreshSkillCatalog();
5410
+ await searchSkills();
5411
+ }
5412
+
5023
5413
  if (mcpListEl) {
5024
5414
  $('#mcp-new').addEventListener('click', () => openMcpForm());
5025
5415
  $('#mcp-import-btn')?.addEventListener('click', importMcp);
@@ -5036,11 +5426,52 @@ if (mcpListEl) {
5036
5426
  importSkill();
5037
5427
  }
5038
5428
  });
5429
+ $('#mcp-search-btn')?.addEventListener('click', searchMcp);
5430
+ $('#mcp-search')?.addEventListener('keydown', (event) => {
5431
+ if (event.key === 'Enter') {
5432
+ event.preventDefault();
5433
+ searchMcp();
5434
+ }
5435
+ });
5436
+ $('#skill-search-btn')?.addEventListener('click', searchSkills);
5437
+ $('#skill-search')?.addEventListener('keydown', (event) => {
5438
+ if (event.key === 'Enter') {
5439
+ event.preventDefault();
5440
+ searchSkills();
5441
+ }
5442
+ });
5443
+ refreshMcpCatalog();
5444
+ refreshSkillCatalog();
5039
5445
  $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
5446
+ mcpModeEl?.addEventListener('change', async () => {
5447
+ try {
5448
+ await api('/api/settings', { method: 'PUT', body: { mcpMode: mcpModeEl.value } });
5449
+ await refreshMcp({ connect: true });
5450
+ } catch (error) {
5451
+ toast(error.message);
5452
+ }
5453
+ });
5454
+ const savePinnedMcp = async () => {
5455
+ try {
5456
+ await api('/api/settings', { method: 'PUT', body: { mcpPinned: String(mcpPinnedEl.value || '') } });
5457
+ await refreshMcp({ connect: true });
5458
+ } catch (error) {
5459
+ toast(error.message);
5460
+ }
5461
+ };
5462
+ $('#mcp-pinned-save')?.addEventListener('click', savePinnedMcp);
5463
+ mcpPinnedEl?.addEventListener('keydown', (event) => {
5464
+ if (event.key === 'Enter') {
5465
+ event.preventDefault();
5466
+ savePinnedMcp();
5467
+ }
5468
+ });
5040
5469
  $('#tg-save')?.addEventListener('click', submitChannel);
5041
5470
  $('#tg-test')?.addEventListener('click', testChannel);
5042
5471
  $('#voz-save')?.addEventListener('click', submitVoz);
5043
5472
  $('#voz-test')?.addEventListener('click', testVoz);
5473
+ vozRateEl?.addEventListener('input', renderVozSliders);
5474
+ vozPitchEl?.addEventListener('input', renderVozSliders);
5044
5475
  $('#asesor-save')?.addEventListener('click', submitAsesor);
5045
5476
  $('#asesor-test')?.addEventListener('click', testAsesor);
5046
5477
  $('#mcp-close').addEventListener('click', closeMcpForm);
@@ -5523,6 +5954,7 @@ function refreshExplorerMode() {
5523
5954
  if (explorerMode === 'files') refreshExplorer();
5524
5955
  else if (explorerMode === 'procs') detectProcs();
5525
5956
  else if (explorerMode === 'git') refreshGit();
5957
+ else if (explorerMode === 'preview') syncPreviewTunnel();
5526
5958
  }
5527
5959
 
5528
5960
  document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
@@ -5575,16 +6007,75 @@ const previewTools = setupPreviewTools({
5575
6007
  teleport: true,
5576
6008
  });
5577
6009
  restorePreviewMax = () => previewTools.setMax(false);
5578
- previewTools.applyDevice();
6010
+
6011
+ const PREVIEW_URL_KEY = 'tamnora-preview-url';
6012
+ const PREVIEW_DEVICE_KEY = 'tamnora-preview-device';
6013
+
6014
+ /** True si la consola corre en la misma maquina que el server (loopback). */
6015
+ function isLocalHost() {
6016
+ const host = String(location.hostname || '').toLowerCase();
6017
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]' || host.endsWith('.localhost');
6018
+ }
6019
+
6020
+ /** Puerto si la URL apunta a loopback (127.0.0.1/localhost/::1). */
6021
+ function localPortOf(url) {
6022
+ const match = String(url ?? '').match(/^https?:\/\/(?:localhost|127(?:\.\d+){3}|\[::1\]):(\d+)/i);
6023
+ return match ? Number(match[1]) : 0;
6024
+ }
6025
+
6026
+ function setTunnelUi(tunnel) {
6027
+ const closeBtn = $('#preview-close');
6028
+ if (closeBtn) closeBtn.hidden = !tunnel;
6029
+ if (tunnel) {
6030
+ const why = tunnel.provider === 'tailscale' ? 'privado (tailscale)' : tunnel.provider ? tunnel.provider : '';
6031
+ setPreviewStatus(`acceso remoto activo: ${tunnel.url}${why ? ` · ${why}` : ''}`);
6032
+ }
6033
+ }
6034
+
6035
+ async function syncPreviewTunnel({ load = false } = {}) {
6036
+ const data = await api('/api/preview').catch(() => null);
6037
+ const tunnel = data?.tunnel || null;
6038
+ setTunnelUi(tunnel);
6039
+ if (tunnel?.url && load) loadPreview(tunnel.url);
6040
+ return tunnel;
6041
+ }
6042
+
6043
+ /** Espera a que el server confirme el tunel si la respuesta del POST se corto. */
6044
+ async function pollPreviewTunnel(tries = 5, delayMs = 1200) {
6045
+ for (let i = 0; i < tries; i++) {
6046
+ const data = await api('/api/preview').catch(() => null);
6047
+ if (data?.tunnel?.url) return data.tunnel;
6048
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
6049
+ }
6050
+ return null;
6051
+ }
5579
6052
 
5580
6053
  function loadPreview(url) {
5581
6054
  const value = String(url ?? '').trim();
5582
6055
  if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
5583
6056
  previewUrlInput.value = value;
5584
6057
  previewFrame.src = value;
6058
+ storage.set(PREVIEW_URL_KEY, value);
5585
6059
  setPreviewStatus(`cargando ${value} …`);
5586
6060
  }
5587
6061
 
6062
+ /** Abre acceso remoto al puerto (tailscale privado o tunel publico). */
6063
+ async function openRemoteTunnel(port) {
6064
+ const provider = $('#preview-provider')?.value || 'tailscale';
6065
+ setPreviewStatus(`abriendo acceso remoto (${provider})…`);
6066
+ const data = await api('/api/preview/tunnel', { method: 'POST', body: { port, provider } }).catch((e) => ({ error: e.message }));
6067
+ if (data?.error) {
6068
+ // La respuesta pudo cortarse (p. ej. la tablet en background): el server pudo
6069
+ // haber abierto igual el tunel, asi que lo confirmamos antes de fallar.
6070
+ const tunnel = await pollPreviewTunnel();
6071
+ if (!tunnel) return setPreviewStatus(`error: ${data.error}`, true);
6072
+ setTunnelUi(tunnel);
6073
+ return loadPreview(tunnel.url);
6074
+ }
6075
+ setTunnelUi(data.tunnel);
6076
+ loadPreview(data.tunnel.url);
6077
+ }
6078
+
5588
6079
  $('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
5589
6080
  previewUrlInput?.addEventListener('keydown', (event) => {
5590
6081
  if (event.key === 'Enter') {
@@ -5595,16 +6086,29 @@ previewUrlInput?.addEventListener('keydown', (event) => {
5595
6086
  previewFrame?.addEventListener('load', () => {
5596
6087
  if (previewFrame.src) setPreviewStatus(previewFrame.src);
5597
6088
  });
5598
- $('#preview-tunnel')?.addEventListener('click', async () => {
6089
+ $('#preview-tunnel')?.addEventListener('click', () => {
5599
6090
  const match = String(previewUrlInput.value).match(/:(\d+)/);
5600
6091
  const port = match ? Number(match[1]) : 0;
5601
6092
  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}`);
6093
+ openRemoteTunnel(port);
6094
+ });
6095
+ $('#preview-close')?.addEventListener('click', async () => {
6096
+ await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null);
6097
+ setTunnelUi(null);
6098
+ setPreviewStatus('acceso remoto cerrado.');
5607
6099
  });
6100
+ $('#preview-device')?.addEventListener('change', (event) => storage.set(PREVIEW_DEVICE_KEY, event.target.value));
6101
+
6102
+ // Restaurar lo tipeado y el tamano elegido: en la tablet la pagina se recarga
6103
+ // sola (iOS la descarta en background) y antes se perdia todo el estado.
6104
+ (function restorePreview() {
6105
+ const savedDevice = storage.get(PREVIEW_DEVICE_KEY, '');
6106
+ if (savedDevice && $('#preview-device')) $('#preview-device').value = savedDevice;
6107
+ previewTools.applyDevice();
6108
+ const savedUrl = storage.get(PREVIEW_URL_KEY, '');
6109
+ if (savedUrl) previewUrlInput.value = savedUrl;
6110
+ syncPreviewTunnel({ load: true });
6111
+ })();
5608
6112
 
5609
6113
  /* procs: deteccion y dev servers */
5610
6114
 
@@ -5646,7 +6150,6 @@ async function refreshProcs() {
5646
6150
  }
5647
6151
  if (!data.procs.length) {
5648
6152
  procsListEl.appendChild(el('li', 'muted', 'sin procesos corriendo'));
5649
- return;
5650
6153
  }
5651
6154
  for (const proc of data.procs) {
5652
6155
  const li = el('li', proc.running ? 'active' : '');
@@ -5658,11 +6161,42 @@ async function refreshProcs() {
5658
6161
  li.appendChild(main);
5659
6162
  const actions = el('div', 'pactions');
5660
6163
  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}`); }));
6164
+ if (proc.port) actions.appendChild(button('preview', () => openProcPreview(proc.port)));
5662
6165
  if (proc.running) actions.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
5663
6166
  li.appendChild(actions);
5664
6167
  procsListEl.appendChild(li);
5665
6168
  }
6169
+ await renderTunnelRow();
6170
+ }
6171
+
6172
+ /** Fila del tunel activo: no es un proc (lo maneja la ruta preview), pero
6173
+ * conviene verlo y poder cerrarlo aca. */
6174
+ async function renderTunnelRow() {
6175
+ const data = await api('/api/preview').catch(() => null);
6176
+ const tunnel = data?.tunnel;
6177
+ if (!tunnel?.url) return;
6178
+ const li = el('li', 'active');
6179
+ const main = el('div', 'grow');
6180
+ main.append(
6181
+ el('div', '', `túnel → puerto ${tunnel.port}`),
6182
+ el('div', 'pmeta', `${tunnel.provider || ''} · ${tunnel.url}`)
6183
+ );
6184
+ li.appendChild(main);
6185
+ const actions = el('div', 'pactions');
6186
+ actions.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(tunnel.url); }));
6187
+ actions.appendChild(
6188
+ button('detener', async () => { await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null); refreshProcs(); }, 'btn danger')
6189
+ );
6190
+ li.appendChild(actions);
6191
+ procsListEl.appendChild(li);
6192
+ }
6193
+
6194
+ /** Preview de un proc: local si la consola corre en la misma maquina, remoto
6195
+ * (tailscale/tunel) si se accede desde otro dispositivo. */
6196
+ async function openProcPreview(port) {
6197
+ setExplorerMode('preview');
6198
+ if (isLocalHost()) return loadPreview(`http://127.0.0.1:${port}`);
6199
+ return openRemoteTunnel(port);
5666
6200
  }
5667
6201
 
5668
6202
  async function startProc(chip) {
@@ -5993,7 +6527,7 @@ let baseTitle = '';
5993
6527
  * tienen que marcar que hay un permiso esperando.
5994
6528
  */
5995
6529
  function renderAlertSignals() {
5996
- const pending = state.pendingAsks.size;
6530
+ const pending = state.pendingAsks.size + state.pendingQuestions.size;
5997
6531
  if (!baseTitle) baseTitle = document.title;
5998
6532
  const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
5999
6533
  document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;