@danieltmn/tamnora 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +58 -4
  3. package/SECURITY.md +8 -0
  4. package/harness/packages/cli/src/commands/mcp.js +31 -4
  5. package/harness/packages/cli/src/commands/repl.js +7 -2
  6. package/harness/packages/cli/src/commands/run.js +5 -2
  7. package/harness/packages/cli/src/commands/tui.js +9 -2
  8. package/harness/packages/cli/src/lib/runtime.js +23 -1
  9. package/harness/packages/cli/test/tunnel.test.js +13 -1
  10. package/harness/packages/core/src/agent/modes.js +3 -0
  11. package/harness/packages/core/src/config/index.js +3 -1
  12. package/harness/packages/core/src/index.js +17 -3
  13. package/harness/packages/core/src/mcp/catalog.js +79 -0
  14. package/harness/packages/core/src/mcp/index.js +14 -2
  15. package/harness/packages/core/src/mcp/registry.js +29 -1
  16. package/harness/packages/core/src/procs/detect.js +59 -8
  17. package/harness/packages/core/src/procs/kill.js +26 -5
  18. package/harness/packages/core/src/redact.js +25 -0
  19. package/harness/packages/core/src/skills/catalog.js +78 -0
  20. package/harness/packages/core/src/skills/remote.js +51 -0
  21. package/harness/packages/core/src/tools/executors.js +93 -2
  22. package/harness/packages/core/src/tools/index.js +27 -6
  23. package/harness/packages/core/src/tools/labels.js +2 -0
  24. package/harness/packages/core/src/tools/mcp.js +91 -0
  25. package/harness/packages/core/src/tools/permissions.js +53 -8
  26. package/harness/packages/core/src/tools/registry.js +14 -7
  27. package/harness/packages/core/src/tools/schema.js +86 -0
  28. package/harness/packages/core/src/tts/edge.js +28 -7
  29. package/harness/packages/core/src/tunnel.js +51 -5
  30. package/harness/packages/core/test/compact.test.js +2 -0
  31. package/harness/packages/core/test/labels.test.js +1 -0
  32. package/harness/packages/core/test/mcp-registry.test.js +37 -1
  33. package/harness/packages/core/test/mcp-routing.test.js +115 -0
  34. package/harness/packages/core/test/procs.test.js +70 -2
  35. package/harness/packages/core/test/redact.test.js +22 -0
  36. package/harness/packages/core/test/skills.test.js +11 -0
  37. package/harness/packages/core/test/tools.test.js +97 -3
  38. package/harness/packages/server/public/app.js +629 -28
  39. package/harness/packages/server/public/index.html +44 -2
  40. package/harness/packages/server/public/styles.css +106 -7
  41. package/harness/packages/server/src/lib/agentTools.js +45 -3
  42. package/harness/packages/server/src/lib/channels/telegram.js +2 -0
  43. package/harness/packages/server/src/lib/procs.js +9 -3
  44. package/harness/packages/server/src/lib/prompt.js +4 -1
  45. package/harness/packages/server/src/lib/questions.js +72 -0
  46. package/harness/packages/server/src/lib/runManager.js +29 -2
  47. package/harness/packages/server/src/lib/scheduler.js +12 -1
  48. package/harness/packages/server/src/lib/tasks.js +3 -1
  49. package/harness/packages/server/src/routes/chat.js +40 -3
  50. package/harness/packages/server/src/routes/mcp.js +54 -2
  51. package/harness/packages/server/src/routes/preview.js +49 -28
  52. package/harness/packages/server/src/routes/procs.js +20 -3
  53. package/harness/packages/server/src/routes/providers.js +12 -0
  54. package/harness/packages/server/src/routes/sessions.js +3 -1
  55. package/harness/packages/server/src/routes/skills.js +48 -1
  56. package/harness/packages/server/src/routes/state.js +8 -1
  57. package/harness/packages/server/src/server.js +37 -1
  58. package/harness/packages/server/test/preview.test.js +111 -0
  59. package/harness/packages/server/test/server.test.js +285 -34
  60. package/harness/packages/server/test/telegram-features.test.js +2 -0
  61. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  62. package/harness/packages/server/test/telegram.test.js +2 -0
  63. package/harness/tamnora.example.json +3 -1
  64. package/package.json +1 -1
@@ -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) =>
@@ -824,7 +827,13 @@ const chatForm = $('#chat-form');
824
827
  const chatInput = $('#chat-input');
825
828
  const submitBtn = chatForm.querySelector('button[type="submit"]');
826
829
  const stopBtn = $('#stop-btn');
830
+ const interruptBtn = $('#interrupt-btn');
827
831
  const controllers = new Map();
832
+ // `whenIdle` espera a que el turno de una sesion termine (lo usa "interrumpir").
833
+ const idleWaiters = new Map();
834
+ // Sesiones donde el turno actual se esta cortando a proposito: `drainQueue` no
835
+ // debe arrancar un mensaje al final de ese turno (lo arranca el interruptor).
836
+ const interrupting = new Set();
828
837
  const sessionsEl = $('#sessions');
829
838
 
830
839
  let activePane = blankPane;
@@ -1015,6 +1024,7 @@ async function renderPane(id) {
1015
1024
  // borra. Hay que repintarlos aca: sin esto una tarjeta de permiso se ve y
1016
1025
  // al primer re-render (run_start, sweep, reconexion) desaparece para siempre.
1017
1026
  for (const ask of detail.permissions ?? []) showStandalonePermission(record, ask);
1027
+ for (const ask of detail.questions ?? []) showStandaloneQuestion(record, ask);
1018
1028
  return detail;
1019
1029
  }
1020
1030
 
@@ -1515,6 +1525,152 @@ function resolvePermissionCard(entry, allow) {
1515
1525
  entry.verdict.textContent = allow ? 'aprobado desde otra ventana' : 'denegado desde otra ventana';
1516
1526
  }
1517
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
+
1518
1674
  function userText(content) {
1519
1675
  if (typeof content === 'string') return content;
1520
1676
  if (Array.isArray(content)) {
@@ -1534,11 +1690,15 @@ function userImages(content) {
1534
1690
  .filter(Boolean);
1535
1691
  }
1536
1692
 
1537
- function advisorGlyph(kind) {
1538
- if (kind === 'code') return '⚠';
1539
- if (kind === 'explain') return 'ℹ';
1540
- if (kind === 'summary') return '✎';
1541
- 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: '✦' };
1542
1702
  }
1543
1703
 
1544
1704
  /** Tarjeta del asesor: una capa de comentario, no un mensaje del agente. */
@@ -1547,9 +1707,15 @@ function addAdvisorCard(container, { text, kind } = {}) {
1547
1707
  if (!clean || !container) return null;
1548
1708
  const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
1549
1709
  if (exists) return null;
1550
- 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}`);
1551
1713
  const head = el('div', 'advisor-head');
1552
- 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
+ );
1553
1719
  card.append(head, el('div', 'advisor-text', clean));
1554
1720
  container.appendChild(card);
1555
1721
  return card;
@@ -1580,11 +1746,14 @@ function renderStored(message, pane) {
1580
1746
  }
1581
1747
  for (const call of message.tool_calls ?? []) {
1582
1748
  const name = call.function?.name ?? call.name;
1583
- 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 });
1584
1751
  tool.card.classList.remove('running');
1585
1752
  tool.card.classList.add('done');
1586
1753
  tool.glyph.textContent = '✓';
1587
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 });
1588
1757
  }
1589
1758
  } else if (message.role === 'tool') {
1590
1759
  const tool = toolCardIndex.get(message.tool_call_id);
@@ -1774,7 +1943,7 @@ async function removeProject(cwd, count) {
1774
1943
 
1775
1944
  /* --- session tabs --------------------------------------------------------- */
1776
1945
 
1777
- 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 = []) {
1778
1947
  const id = session.id;
1779
1948
  if (state.panes.has(id)) return state.panes.get(id);
1780
1949
  const pane = document.createElement('div');
@@ -1801,6 +1970,7 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, run
1801
1970
  // sesion. Sin esto, recargar con un permiso en el aire dejaba el run
1802
1971
  // bloqueado esperando una decision que no se podia tomar.
1803
1972
  for (const ask of permissions) showStandalonePermission(record, ask);
1973
+ for (const ask of questions) showStandaloneQuestion(record, ask);
1804
1974
  return record;
1805
1975
  }
1806
1976
 
@@ -1862,7 +2032,7 @@ async function openTab(id) {
1862
2032
  return null;
1863
2033
  });
1864
2034
  if (!data) return;
1865
- 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);
1866
2036
  activateSession(id);
1867
2037
  }
1868
2038
 
@@ -1874,6 +2044,7 @@ function activateSession(id) {
1874
2044
  showPane(id);
1875
2045
  if (record.cwd) setCwd(record.cwd);
1876
2046
  if (stopBtn) stopBtn.hidden = !record.working;
2047
+ syncInterruptBtn();
1877
2048
  auditStatus(record);
1878
2049
  updateStatus();
1879
2050
  renderTokens();
@@ -1891,6 +2062,39 @@ function updateSubmitLabel(working) {
1891
2062
  submitBtn.title = working ? 'El agente está trabajando: se envía al terminar' : 'enviar';
1892
2063
  }
1893
2064
 
2065
+ /** El boton de interrumpir solo tiene sentido con un turno en curso y texto que mandar. */
2066
+ function syncInterruptBtn() {
2067
+ if (!interruptBtn) return;
2068
+ const record = state.panes.get(state.sessionId);
2069
+ const working = Boolean(record?.working);
2070
+ const hasInput = Boolean(chatInput.value.trim()) || state.attachments.length > 0;
2071
+ interruptBtn.hidden = !working;
2072
+ interruptBtn.disabled = !working || !hasInput;
2073
+ }
2074
+
2075
+ /** Promesa que resuelve cuando el turno de la sesion deja de estar activo. */
2076
+ function whenIdle(sessionKey, timeoutMs = 8000) {
2077
+ const record = state.panes.get(sessionKey);
2078
+ if (!record?.working) return Promise.resolve();
2079
+ return new Promise((resolve) => {
2080
+ let timer = null;
2081
+ const settle = () => {
2082
+ if (timer) clearTimeout(timer);
2083
+ const list = idleWaiters.get(sessionKey);
2084
+ if (list) {
2085
+ const index = list.indexOf(settle);
2086
+ if (index >= 0) list.splice(index, 1);
2087
+ if (!list.length) idleWaiters.delete(sessionKey);
2088
+ }
2089
+ resolve();
2090
+ };
2091
+ timer = setTimeout(settle, timeoutMs);
2092
+ const list = idleWaiters.get(sessionKey) ?? [];
2093
+ list.push(settle);
2094
+ idleWaiters.set(sessionKey, list);
2095
+ });
2096
+ }
2097
+
1894
2098
  function auditStatus(record) {
1895
2099
  submitBtn.disabled = false;
1896
2100
  updateSubmitLabel(record.working);
@@ -1941,10 +2145,18 @@ function setWorking(id, working) {
1941
2145
  // solo se recupera recargando.
1942
2146
  if (record) record.working = record.attached = working;
1943
2147
  renderTabs();
2148
+ if (!working) {
2149
+ const waiters = idleWaiters.get(id);
2150
+ if (waiters) {
2151
+ idleWaiters.delete(id);
2152
+ for (const settle of [...waiters]) settle();
2153
+ }
2154
+ }
1944
2155
  if (id === state.sessionId) {
1945
2156
  submitBtn.disabled = false;
1946
2157
  updateSubmitLabel(working);
1947
2158
  if (stopBtn) stopBtn.hidden = !working;
2159
+ syncInterruptBtn();
1948
2160
  $('#sb-rounds').textContent = working ? '' : 'listo';
1949
2161
  $('#sb-rounds').classList.toggle('running', working);
1950
2162
  renderFooterActivity(working ? 'thinking' : null);
@@ -1957,7 +2169,7 @@ async function restoreTabs() {
1957
2169
  for (const id of ids) {
1958
2170
  if (state.panes.has(id)) continue;
1959
2171
  const data = await api(`/api/sessions/${id}`).catch(() => null);
1960
- 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);
1961
2173
  }
1962
2174
  const active = saved?.active && state.panes.has(saved.active) ? saved.active : state.tabOrder[state.tabOrder.length - 1];
1963
2175
  if (active) activateSession(active);
@@ -2158,11 +2370,35 @@ function handleChatEvent(event, data, turn) {
2158
2370
  }
2159
2371
  if (turn.live && data.name === 'preview_url' && data.args?.url) {
2160
2372
  setExplorerMode('preview');
2161
- 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 });
2162
2384
  }
2163
2385
  } else if (event === 'tool_result') {
2164
2386
  setActivity(turn.assistant(), 'thinking');
2165
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
+ }
2166
2402
  } else if (event === 'permission') {
2167
2403
  // Si la tarjeta ya esta en el DOM (la creo `renderPane` antes del replay, o
2168
2404
  // llego por el canal global) no hay que crear OTRO mensaje del asistente
@@ -2361,7 +2597,7 @@ function contextTokensForRequest() {
2361
2597
  return context ? Math.floor(context * 0.8) : undefined;
2362
2598
  }
2363
2599
 
2364
- async function startTurn(sessionKey, pane, input, images) {
2600
+ async function startTurn(sessionKey, pane, input, images, opts = {}) {
2365
2601
  const user = addMessage('user', input || `(${images.length} imagen${images.length > 1 ? 'es' : ''})`, pane);
2366
2602
  appendImages(user, images);
2367
2603
  const turn = createChatTurn(pane, sessionKey);
@@ -2386,6 +2622,7 @@ async function startTurn(sessionKey, pane, input, images) {
2386
2622
  mode: modeSelect.value || undefined,
2387
2623
  yes: yesInput.checked ? true : undefined,
2388
2624
  contextTokens: contextTokensForRequest(),
2625
+ interrupt: opts.interrupt ? true : undefined,
2389
2626
  },
2390
2627
  (event, data, id) => {
2391
2628
  if (id !== null) turn.cursor = Number(id) || turn.cursor;
@@ -2463,6 +2700,9 @@ function renderQueue(sessionKey = state.sessionId) {
2463
2700
  function drainQueue(sessionKey) {
2464
2701
  const record = state.panes.get(sessionKey);
2465
2702
  if (!record || record.working) return;
2703
+ // Mientras se interrumpe, el mensaje que manda el usuario lo arranca
2704
+ // `interruptAndSend`, no la cola.
2705
+ if (interrupting.has(sessionKey)) return;
2466
2706
  const list = queueFor(sessionKey);
2467
2707
  if (!list.length) return;
2468
2708
  const next = list.shift();
@@ -2530,11 +2770,13 @@ async function sendMessage() {
2530
2770
  }
2531
2771
 
2532
2772
  // Si el agente está trabajando, el mensaje queda en cola y se envía solo.
2773
+ // Para cortarlo y mandar el mensaje ya, está el botón/atajo de interrumpir.
2533
2774
  if (current?.working) {
2534
2775
  enqueueMessage(state.sessionId, input, images);
2535
2776
  chatInput.value = '';
2536
2777
  state.attachments = [];
2537
2778
  renderAttachments();
2779
+ syncInterruptBtn();
2538
2780
  return;
2539
2781
  }
2540
2782
 
@@ -2572,12 +2814,55 @@ if (stopBtn) {
2572
2814
  });
2573
2815
  }
2574
2816
 
2817
+ /**
2818
+ * Interrumpe el turno en curso y manda `input`/`images` ya mismo.
2819
+ *
2820
+ * Marcamos la sesion en `interrupting` para que el `finally` del turno viejo no
2821
+ * dispare la cola (lo arrancamos nosotros). Esperamos a que el turno anterior se
2822
+ * desenrolle antes de empezar el nuevo; el server, por su lado, cancela el run y
2823
+ * espera a que termine antes de aceptar el POST con `interrupt: true`.
2824
+ */
2825
+ async function interruptAndSend(sessionKey, input, images) {
2826
+ const record = state.panes.get(sessionKey);
2827
+ if (!record) return;
2828
+ interrupting.add(sessionKey);
2829
+ controllers.get(sessionKey)?.abort();
2830
+ await whenIdle(sessionKey);
2831
+ const promise = startTurn(sessionKey, record.el, input, images, { interrupt: true });
2832
+ // startTurn ya arranco (working=true y controller nuevo); soltamos el guardia
2833
+ // para que SU finally pueda drenar la cola.
2834
+ interrupting.delete(sessionKey);
2835
+ await promise;
2836
+ }
2837
+
2838
+ function sendInterrupt() {
2839
+ const record = state.panes.get(state.sessionId);
2840
+ if (!record?.working) return;
2841
+ const input = chatInput.value.trim();
2842
+ // Los comandos (`/…`, `!…`) se resuelven por su camino normal, no interrumpen.
2843
+ if (input.startsWith('/') || input.startsWith('!')) return sendMessage();
2844
+ const images = state.attachments.slice();
2845
+ if (!input && !images.length) return;
2846
+ chatInput.value = '';
2847
+ state.attachments = [];
2848
+ renderAttachments();
2849
+ syncInterruptBtn();
2850
+ interruptAndSend(state.sessionId, input, images);
2851
+ }
2852
+
2853
+ if (interruptBtn) interruptBtn.addEventListener('click', sendInterrupt);
2854
+
2575
2855
  chatForm.addEventListener('submit', (event) => {
2576
2856
  event.preventDefault();
2577
2857
  sendMessage();
2578
2858
  });
2579
2859
  chatInput.addEventListener('keydown', (event) => {
2580
2860
  if (handleSlashKey(event)) return;
2861
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
2862
+ event.preventDefault();
2863
+ sendInterrupt();
2864
+ return;
2865
+ }
2581
2866
  if (event.key === 'Enter' && !event.shiftKey) {
2582
2867
  event.preventDefault();
2583
2868
  sendMessage();
@@ -3041,7 +3326,8 @@ const COMMANDS = [
3041
3326
  ['/compact', 'resumir la conversación y liberar contexto'],
3042
3327
  ['/clear', 'borrar los mensajes de esta sesión'],
3043
3328
  ['/cwd', 'mostrar la carpeta de trabajo'],
3044
- ['/kill <puerto>', 'matar el proceso que escucha en un puerto'],
3329
+ ['/port <puerto>', 'ver si un puerto está activo (y qué PID lo usa)'],
3330
+ ['/portkill <puerto>', 'matar el proceso que escucha en un puerto'],
3045
3331
  ['/help', 'mostrar esta ayuda'],
3046
3332
  ];
3047
3333
 
@@ -3104,13 +3390,14 @@ async function runCommand(name, arg) {
3104
3390
  notify(`Carpeta de trabajo: \`${cwd}\``);
3105
3391
  return { handled: true };
3106
3392
  }
3393
+ case 'portkill':
3107
3394
  case 'kill': {
3108
3395
  const port = Number(arg);
3109
3396
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
3110
- notify('Uso: `/kill <puerto>`, por ejemplo `/kill 3000`.', 'notice err');
3397
+ notify('Uso: `/portkill <puerto>`, por ejemplo `/portkill 3000`.', 'notice err');
3111
3398
  return { handled: true };
3112
3399
  }
3113
- const out = await api('/api/procs/kill', { method: 'POST', body: { port } }).catch((error) => ({
3400
+ const out = await api('/api/ports/kill', { method: 'POST', body: { port } }).catch((error) => ({
3114
3401
  error: error.message,
3115
3402
  }));
3116
3403
  if (out.error) notify(`No se pudo matar el puerto ${port}: ${out.error}`, 'notice err');
@@ -3119,6 +3406,18 @@ async function runCommand(name, arg) {
3119
3406
  else notify(`Puerto ${port}: no se pudo matar (PID ${out.errors.join(', ')}).`, 'notice err');
3120
3407
  return { handled: true };
3121
3408
  }
3409
+ case 'port': {
3410
+ const port = Number(arg);
3411
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
3412
+ notify('Uso: `/port <puerto>`, por ejemplo `/port 3000`.', 'notice err');
3413
+ return { handled: true };
3414
+ }
3415
+ const out = await api(`/api/ports/${port}`).catch((error) => ({ error: error.message }));
3416
+ if (out.error) notify(`No se pudo consultar el puerto ${port}: ${out.error}`, 'notice err');
3417
+ else if (out.listening) notify(`Puerto ${port}: **activo** (PID ${out.pids.join(', ')}).`);
3418
+ else notify(`Puerto ${port}: nada escuchando.`);
3419
+ return { handled: true };
3420
+ }
3122
3421
  case 'clear':
3123
3422
  case 'limpiar': {
3124
3423
  if (!state.sessionId) return needSession();
@@ -3274,6 +3573,7 @@ function handleSlashKey(event) {
3274
3573
  function onChatInputChanged() {
3275
3574
  slashIndex = 0;
3276
3575
  updateSlashMenu();
3576
+ syncInterruptBtn();
3277
3577
  }
3278
3578
 
3279
3579
  chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
@@ -3718,9 +4018,16 @@ providerModal.addEventListener('click', (event) => {
3718
4018
  /* --- ajustes: MCP + Skills ----------------------------------------------- */
3719
4019
 
3720
4020
  const mcpListEl = $('#mcp-list');
4021
+ const mcpSearchResultsEl = $('#mcp-search-results');
4022
+ const mcpCatalogEl = $('#mcp-catalog');
3721
4023
  const mcpModal = $('#mcp-modal');
3722
4024
  const mcpForm = $('#mcp-form');
4025
+ const mcpModeEl = $('#mcp-mode');
4026
+ const mcpCountEl = $('#mcp-count');
4027
+ const mcpPinnedEl = $('#mcp-pinned');
3723
4028
  const skillListEl = $('#skill-list');
4029
+ const skillSearchResultsEl = $('#skill-search-results');
4030
+ const skillCatalogEl = $('#skill-catalog');
3724
4031
  const skillModal = $('#skill-modal');
3725
4032
  const skillForm = $('#skill-form');
3726
4033
  const memoryListEl = $('#memory-list');
@@ -3815,6 +4122,16 @@ function setStatus(node, text, cls = '') {
3815
4122
  async function refreshMcp({ connect = false } = {}) {
3816
4123
  const data = await api(`/api/mcp${connect ? '?connect=1' : ''}`).catch(() => null);
3817
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
+ }
3818
4135
  clear(mcpListEl);
3819
4136
  if (!data.servers.length) {
3820
4137
  mcpListEl.appendChild(el('li', 'muted', 'sin servidores MCP'));
@@ -4908,6 +5225,137 @@ async function importSkill() {
4908
5225
  });
4909
5226
  }
4910
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
+
4911
5359
  if (mcpListEl) {
4912
5360
  $('#mcp-new').addEventListener('click', () => openMcpForm());
4913
5361
  $('#mcp-import-btn')?.addEventListener('click', importMcp);
@@ -4924,7 +5372,46 @@ if (mcpListEl) {
4924
5372
  importSkill();
4925
5373
  }
4926
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();
4927
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
+ });
4928
5415
  $('#tg-save')?.addEventListener('click', submitChannel);
4929
5416
  $('#tg-test')?.addEventListener('click', testChannel);
4930
5417
  $('#voz-save')?.addEventListener('click', submitVoz);
@@ -5411,6 +5898,7 @@ function refreshExplorerMode() {
5411
5898
  if (explorerMode === 'files') refreshExplorer();
5412
5899
  else if (explorerMode === 'procs') detectProcs();
5413
5900
  else if (explorerMode === 'git') refreshGit();
5901
+ else if (explorerMode === 'preview') syncPreviewTunnel();
5414
5902
  }
5415
5903
 
5416
5904
  document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
@@ -5463,16 +5951,75 @@ const previewTools = setupPreviewTools({
5463
5951
  teleport: true,
5464
5952
  });
5465
5953
  restorePreviewMax = () => previewTools.setMax(false);
5466
- 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
+ }
5467
5996
 
5468
5997
  function loadPreview(url) {
5469
5998
  const value = String(url ?? '').trim();
5470
5999
  if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
5471
6000
  previewUrlInput.value = value;
5472
6001
  previewFrame.src = value;
6002
+ storage.set(PREVIEW_URL_KEY, value);
5473
6003
  setPreviewStatus(`cargando ${value} …`);
5474
6004
  }
5475
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
+
5476
6023
  $('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
5477
6024
  previewUrlInput?.addEventListener('keydown', (event) => {
5478
6025
  if (event.key === 'Enter') {
@@ -5483,16 +6030,29 @@ previewUrlInput?.addEventListener('keydown', (event) => {
5483
6030
  previewFrame?.addEventListener('load', () => {
5484
6031
  if (previewFrame.src) setPreviewStatus(previewFrame.src);
5485
6032
  });
5486
- $('#preview-tunnel')?.addEventListener('click', async () => {
6033
+ $('#preview-tunnel')?.addEventListener('click', () => {
5487
6034
  const match = String(previewUrlInput.value).match(/:(\d+)/);
5488
6035
  const port = match ? Number(match[1]) : 0;
5489
6036
  if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
5490
- setPreviewStatus('abriendo túnel público…');
5491
- const data = await api('/api/preview/tunnel', { method: 'POST', body: { port } }).catch((e) => ({ error: e.message }));
5492
- if (data?.error) return setPreviewStatus(`error: ${data.error}`, true);
5493
- loadPreview(data.tunnel.url);
5494
- setPreviewStatus(`túnel público: ${data.tunnel.url}`);
6037
+ openRemoteTunnel(port);
5495
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.');
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
+ })();
5496
6056
 
5497
6057
  /* procs: deteccion y dev servers */
5498
6058
 
@@ -5508,8 +6068,19 @@ async function detectProcs() {
5508
6068
  procsCwdEl.textContent = cwd ? `cwd: ${cwd}` : 'sin carpeta';
5509
6069
  const data = await api(`/api/procs/detect?cwd=${encodeURIComponent(cwd)}`).catch(() => null);
5510
6070
  if (!data) return;
5511
- for (const chip of data.chips) procsChipsEl.appendChild(button(chip.label, () => startProc(chip), 'xchip'));
6071
+ for (const chip of data.chips) {
6072
+ const node = button(chip.label, () => startProc(chip), 'xchip');
6073
+ if (chip.source === 'project') node.title = 'definido en .tamnora/procs.json';
6074
+ if (chip.allowed === false) {
6075
+ node.disabled = true;
6076
+ node.title = `"${chip.command}" no está en processes.allow`;
6077
+ }
6078
+ procsChipsEl.appendChild(node);
6079
+ }
5512
6080
  if (!data.chips.length) procsChipsEl.appendChild(el('span', 'muted', 'no detecté comandos de dev'));
6081
+ else if (data.chips.some((c) => c.source === 'project')) {
6082
+ procsChipsEl.appendChild(el('span', 'muted', '· desde .tamnora/procs.json'));
6083
+ }
5513
6084
  await refreshProcs();
5514
6085
  }
5515
6086
 
@@ -5523,7 +6094,6 @@ async function refreshProcs() {
5523
6094
  }
5524
6095
  if (!data.procs.length) {
5525
6096
  procsListEl.appendChild(el('li', 'muted', 'sin procesos corriendo'));
5526
- return;
5527
6097
  }
5528
6098
  for (const proc of data.procs) {
5529
6099
  const li = el('li', proc.running ? 'active' : '');
@@ -5535,11 +6105,42 @@ async function refreshProcs() {
5535
6105
  li.appendChild(main);
5536
6106
  const actions = el('div', 'pactions');
5537
6107
  actions.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
5538
- 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)));
5539
6109
  if (proc.running) actions.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
5540
6110
  li.appendChild(actions);
5541
6111
  procsListEl.appendChild(li);
5542
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);
5543
6144
  }
5544
6145
 
5545
6146
  async function startProc(chip) {
@@ -5870,7 +6471,7 @@ let baseTitle = '';
5870
6471
  * tienen que marcar que hay un permiso esperando.
5871
6472
  */
5872
6473
  function renderAlertSignals() {
5873
- const pending = state.pendingAsks.size;
6474
+ const pending = state.pendingAsks.size + state.pendingQuestions.size;
5874
6475
  if (!baseTitle) baseTitle = document.title;
5875
6476
  const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
5876
6477
  document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;