@danieltmn/tamnora 0.6.1 → 0.7.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 (34) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/SECURITY.md +6 -0
  3. package/harness/packages/cli/src/commands/repl.js +2 -0
  4. package/harness/packages/cli/src/commands/run.js +2 -0
  5. package/harness/packages/cli/src/commands/tui.js +2 -0
  6. package/harness/packages/cli/src/lib/runtime.js +4 -0
  7. package/harness/packages/cli/src/tui/app.js +4 -0
  8. package/harness/packages/core/src/agent/runAgent.js +131 -47
  9. package/harness/packages/core/src/config/index.js +3 -1
  10. package/harness/packages/core/src/config/secrets.js +36 -0
  11. package/harness/packages/core/src/eval/runTask.js +2 -11
  12. package/harness/packages/core/src/index.js +13 -1
  13. package/harness/packages/core/src/mcp/common.js +15 -11
  14. package/harness/packages/core/src/mcp/index.js +1 -1
  15. package/harness/packages/core/src/mcp/registry.js +1 -0
  16. package/harness/packages/core/src/mcp/stdio.js +41 -2
  17. package/harness/packages/core/src/redact.js +104 -0
  18. package/harness/packages/core/test/agent.test.js +93 -0
  19. package/harness/packages/core/test/mcp.test.js +67 -0
  20. package/harness/packages/core/test/redact.test.js +45 -0
  21. package/harness/packages/server/public/app.js +222 -18
  22. package/harness/packages/server/public/index.html +93 -26
  23. package/harness/packages/server/public/styles.css +61 -1
  24. package/harness/packages/server/src/lib/agentTools.js +9 -3
  25. package/harness/packages/server/src/lib/channels/index.js +11 -4
  26. package/harness/packages/server/src/lib/channels/telegram.js +28 -10
  27. package/harness/packages/server/src/routes/channels.js +1 -0
  28. package/harness/packages/server/src/routes/chat.js +8 -1
  29. package/harness/packages/server/src/routes/mcp.js +29 -5
  30. package/harness/packages/server/src/routes/providers.js +16 -15
  31. package/harness/packages/server/src/server.js +13 -0
  32. package/harness/packages/server/test/server.test.js +78 -2
  33. package/harness/tamnora.example.json +17 -1
  34. package/package.json +1 -1
@@ -178,7 +178,7 @@ function activateTab(name) {
178
178
  hideMoreMenu();
179
179
  if (name === 'runs') refreshRuns();
180
180
  if (name === 'providers') refreshProviders();
181
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); }
181
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); }
182
182
  }
183
183
 
184
184
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -203,27 +203,61 @@ document.addEventListener('keydown', (event) => {
203
203
  /* --- settings modal (modo / proveedor / modelo / auto) -------------------- */
204
204
 
205
205
  const settingsModal = $('#settings-modal');
206
+ const agentControls = $('#agent-settings-controls');
207
+ const agentControlsSlot = $('#agent-controls-slot');
208
+ const settingsControlsSlot = $('#settings-controls-slot');
209
+
210
+ /** Los controles modo/proveedor/modelo/auto se comparten entre el tab Agente
211
+ * y el modal: se mueven de un slot al otro para no duplicar IDs ni estado. */
212
+ function moveAgentControls(target) {
213
+ if (agentControls && target) target.appendChild(agentControls);
214
+ }
215
+
216
+ function openSettingsModal() {
217
+ if (!settingsModal) return;
218
+ moveAgentControls(settingsControlsSlot);
219
+ syncDefaultCwd();
220
+ settingsModal.showModal();
221
+ }
222
+
223
+ function closeSettingsModal() {
224
+ moveAgentControls(agentControlsSlot);
225
+ settingsModal?.close();
226
+ }
227
+
206
228
  if (settingsModal) {
207
- $('#settings-toggle')?.addEventListener('click', () => {
208
- syncDefaultCwd();
209
- settingsModal.showModal();
210
- });
211
- $('#settings-close')?.addEventListener('click', () => settingsModal.close());
212
- $('#settings-done')?.addEventListener('click', () => settingsModal.close());
229
+ $('#settings-toggle')?.addEventListener('click', openSettingsModal);
230
+ $('#settings-close')?.addEventListener('click', closeSettingsModal);
231
+ $('#settings-done')?.addEventListener('click', closeSettingsModal);
213
232
  $('#settings-providers')?.addEventListener('click', () => {
214
- settingsModal.close();
233
+ closeSettingsModal();
215
234
  activateTab('providers');
216
235
  });
217
- $('#default-cwd-pick')?.addEventListener('click', () => {
218
- settingsModal.close();
219
- openFolderPicker('default');
220
- });
236
+ $('#agent-settings-open')?.addEventListener('click', openSettingsModal);
237
+ $('#agent-providers')?.addEventListener('click', () => activateTab('providers'));
238
+ $('#default-cwd-pick')?.addEventListener('click', () => openFolderPicker('default'));
221
239
  $('#default-cwd-clear')?.addEventListener('click', () => {
222
240
  writeDefaultCwd('');
223
241
  syncDefaultCwd();
224
242
  });
225
243
  settingsModal.addEventListener('click', (event) => {
226
- if (event.target === settingsModal) settingsModal.close();
244
+ if (event.target === settingsModal) closeSettingsModal();
245
+ });
246
+ settingsModal.addEventListener('close', () => moveAgentControls(agentControlsSlot));
247
+ }
248
+
249
+ // El footer (modo · proveedor · modelo · estado) abre el modal de ajustes.
250
+ const statusbarEl = $('#statusbar');
251
+ if (statusbarEl) {
252
+ statusbarEl.addEventListener('click', (event) => {
253
+ if (event.target.closest('#sb-context')) return;
254
+ openSettingsModal();
255
+ });
256
+ statusbarEl.addEventListener('keydown', (event) => {
257
+ if (event.key === 'Enter' || event.key === ' ') {
258
+ event.preventDefault();
259
+ openSettingsModal();
260
+ }
227
261
  });
228
262
  }
229
263
 
@@ -235,7 +269,7 @@ const PALETTE_COMMANDS = [
235
269
  { id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
236
270
  { id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
237
271
  { id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
238
- { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => { syncDefaultCwd(); settingsModal?.showModal(); } },
272
+ { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => openSettingsModal() },
239
273
  { id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('providers') },
240
274
  { id: 'ajustes', label: 'MCP y Skills (ajustes)', run: () => activateTab('ajustes') },
241
275
  { id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
@@ -1545,11 +1579,25 @@ function renderCompletion(message, data) {
1545
1579
  const duration = formatDuration(data.durationMs);
1546
1580
  if (duration) parts.push(duration);
1547
1581
  if (data.tokens?.total_tokens) parts.push(`${Number(data.tokens.total_tokens).toLocaleString('es')} tokens`);
1548
- const maxed = data.stopReason === 'max_rounds';
1549
- message.tools.appendChild(el('div', maxed ? 'chip warn' : 'chip ok', `${maxed ? '⚠ máximo de rondas' : '✓ terminado'} · ${parts.join(' · ')}`));
1582
+ const cut = ['max_rounds', 'no_progress', 'budget'].includes(data.stopReason);
1583
+ const label = cut ? (data.wrappedUp ? '⚠ corte de seguridad · resumen' : '⚠ corte de seguridad') : '✓ terminado';
1584
+ message.tools.appendChild(el('div', cut ? 'chip warn' : 'chip ok', `${label} · ${parts.join(' · ')}`));
1550
1585
  scrollMessages(paneOf(message));
1551
1586
  }
1552
1587
 
1588
+ /** Retoma un turno cortado por límite/bucle sin tener que escribir "continúa". */
1589
+ function addContinueButton(turn, data) {
1590
+ const btn = el('button', 'chip', data.wrappedUp ? '↻ Continuar el trabajo' : '↻ Continuar');
1591
+ btn.type = 'button';
1592
+ btn.addEventListener('click', () => {
1593
+ if (state.panes.get(turn.sessionKey)?.working) return;
1594
+ chatInput.value = 'Continuá donde quedaste.';
1595
+ sendMessage();
1596
+ });
1597
+ turn.assistant().tools.appendChild(btn);
1598
+ scrollMessages(paneOf(turn.assistant()));
1599
+ }
1600
+
1553
1601
  function handleChatEvent(event, data, turn) {
1554
1602
  if (handleAgentEvent(event, data, turn)) return;
1555
1603
  if (event === 'agent_start') {
@@ -1615,6 +1663,8 @@ function handleChatEvent(event, data, turn) {
1615
1663
  if (!turn.sawText) setText(turn.assistant(), `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
1616
1664
  } else if (event === 'trim') {
1617
1665
  turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1666
+ } else if (event === 'repeat') {
1667
+ turn.assistant().tools.appendChild(el('div', 'chip warn', `↻ ${data.name} repetida sin cambios`));
1618
1668
  } else if (event === 'warning') {
1619
1669
  turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1620
1670
  } else if (event === 'error') {
@@ -1625,12 +1675,16 @@ function handleChatEvent(event, data, turn) {
1625
1675
  const finalText = (turn.active?.textEl?.textContent ?? '').trim();
1626
1676
  const tools = Number(data.toolCalls) || 0;
1627
1677
  if (!finalText) {
1628
- if (data.stopReason === 'max_rounds') {
1678
+ if (data.stopReason === 'no_progress') {
1679
+ setText(turn.assistant(), '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.');
1680
+ } else if (data.stopReason === 'budget') {
1681
+ setText(turn.assistant(), '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.');
1682
+ } else if (data.stopReason === 'max_rounds') {
1629
1683
  setText(
1630
1684
  turn.assistant(),
1631
1685
  `⚠ El agente alcanzó el máximo de rondas (${data.rounds ?? '?'}) sin dejar una respuesta final.${
1632
1686
  tools ? ` Hizo ${tools} llamadas a herramientas.` : ''
1633
- } Pedile que continúe o subí \`maxRounds\`.`
1687
+ }`
1634
1688
  );
1635
1689
  } else if (tools) {
1636
1690
  setText(turn.assistant(), '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.');
@@ -1639,6 +1693,7 @@ function handleChatEvent(event, data, turn) {
1639
1693
  }
1640
1694
  }
1641
1695
  renderCompletion(turn.active, data);
1696
+ if (data.continuable) addContinueButton(turn, data);
1642
1697
  updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
1643
1698
  addTokens(data.tokens);
1644
1699
  } else if (event === 'aborted') {
@@ -1679,6 +1734,7 @@ async function attachRun(sessionKey, pane) {
1679
1734
  async function sendMessage() {
1680
1735
  const current = state.panes.get(state.sessionId);
1681
1736
  if (current?.working) return;
1737
+ hideSlashMenu();
1682
1738
  let input = chatInput.value.trim();
1683
1739
  const images = state.attachments.slice();
1684
1740
  if (input.startsWith('/') && !images.length) {
@@ -1778,11 +1834,13 @@ chatForm.addEventListener('submit', (event) => {
1778
1834
  sendMessage();
1779
1835
  });
1780
1836
  chatInput.addEventListener('keydown', (event) => {
1837
+ if (handleSlashKey(event)) return;
1781
1838
  if (event.key === 'Enter' && !event.shiftKey) {
1782
1839
  event.preventDefault();
1783
1840
  sendMessage();
1784
1841
  }
1785
1842
  });
1843
+ chatInput.addEventListener('input', onChatInputChanged);
1786
1844
 
1787
1845
  /* --- image attachments ---------------------------------------------------- */
1788
1846
 
@@ -1956,6 +2014,99 @@ async function runCommand(name, arg) {
1956
2014
  }
1957
2015
  }
1958
2016
 
2017
+ /* --- menú de comandos "/" ------------------------------------------------- */
2018
+
2019
+ const slashMenuEl = $('#slash-menu');
2020
+ let slashMatches = [];
2021
+ let slashIndex = 0;
2022
+
2023
+ function slashCommandEntries() {
2024
+ return COMMANDS.map(([cmd, desc]) => {
2025
+ const name = cmd.split(' ')[0];
2026
+ const takesArgs = cmd.includes(' ');
2027
+ return { name, desc, insert: takesArgs ? `${name} ` : name, takesArgs };
2028
+ });
2029
+ }
2030
+
2031
+ function hideSlashMenu() {
2032
+ if (!slashMenuEl) return;
2033
+ slashMenuEl.hidden = true;
2034
+ slashMenuEl.replaceChildren();
2035
+ slashMatches = [];
2036
+ slashIndex = 0;
2037
+ }
2038
+
2039
+ function renderSlashMenu() {
2040
+ if (!slashMenuEl) return;
2041
+ slashMenuEl.replaceChildren();
2042
+ slashMatches.forEach((entry, index) => {
2043
+ const item = el('button', `slash-item${index === slashIndex ? ' active' : ''}`);
2044
+ item.type = 'button';
2045
+ item.append(el('span', 'slash-cmd', entry.name), el('span', 'slash-desc', entry.desc));
2046
+ // mousedown (no click) para no perder el foco del textarea antes de elegir.
2047
+ item.addEventListener('mousedown', (event) => {
2048
+ event.preventDefault();
2049
+ chooseSlash(index);
2050
+ });
2051
+ slashMenuEl.appendChild(item);
2052
+ });
2053
+ slashMenuEl.hidden = false;
2054
+ }
2055
+
2056
+ function updateSlashMenu() {
2057
+ if (!slashMenuEl) return;
2058
+ const value = chatInput.value;
2059
+ if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
2060
+ const query = value.slice(1).toLowerCase();
2061
+ slashMatches = slashCommandEntries().filter((entry) => entry.name.slice(1).toLowerCase().startsWith(query));
2062
+ if (!slashMatches.length) return hideSlashMenu();
2063
+ if (slashIndex >= slashMatches.length) slashIndex = 0;
2064
+ renderSlashMenu();
2065
+ }
2066
+
2067
+ function chooseSlash(index) {
2068
+ const entry = slashMatches[index];
2069
+ if (!entry) return;
2070
+ chatInput.value = entry.insert;
2071
+ chatInput.focus();
2072
+ hideSlashMenu();
2073
+ if (!entry.takesArgs) sendMessage();
2074
+ }
2075
+
2076
+ function handleSlashKey(event) {
2077
+ if (!slashMenuEl || slashMenuEl.hidden || !slashMatches.length) return false;
2078
+ if (event.key === 'ArrowDown') {
2079
+ event.preventDefault();
2080
+ slashIndex = (slashIndex + 1) % slashMatches.length;
2081
+ renderSlashMenu();
2082
+ return true;
2083
+ }
2084
+ if (event.key === 'ArrowUp') {
2085
+ event.preventDefault();
2086
+ slashIndex = (slashIndex - 1 + slashMatches.length) % slashMatches.length;
2087
+ renderSlashMenu();
2088
+ return true;
2089
+ }
2090
+ if (event.key === 'Escape') {
2091
+ event.preventDefault();
2092
+ hideSlashMenu();
2093
+ return true;
2094
+ }
2095
+ if (event.key === 'Enter' || event.key === 'Tab') {
2096
+ event.preventDefault();
2097
+ chooseSlash(slashIndex);
2098
+ return true;
2099
+ }
2100
+ return false;
2101
+ }
2102
+
2103
+ function onChatInputChanged() {
2104
+ slashIndex = 0;
2105
+ updateSlashMenu();
2106
+ }
2107
+
2108
+ chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
2109
+
1959
2110
  /* --- folder picker -------------------------------------------------------- */
1960
2111
 
1961
2112
  const folderModal = $('#folder-modal');
@@ -2399,8 +2550,53 @@ const skillForm = $('#skill-form');
2399
2550
  const tgTokenEl = $('#tg-token');
2400
2551
  const tgChatsEl = $('#tg-chats');
2401
2552
  const tgTranscribeEl = $('#tg-transcribe');
2553
+ const tgTranscribeProviderEl = $('#tg-transcribe-provider');
2402
2554
  const tgStatusEl = $('#tg-status');
2403
2555
 
2556
+ /* --- ajustes: pestañas internas + límites del agente --------------------- */
2557
+
2558
+ const ajustesTabs = document.querySelectorAll('.ajustes-tab');
2559
+
2560
+ function activateAjustesPanel(name) {
2561
+ ajustesTabs.forEach((tab) => tab.classList.toggle('active', tab.dataset.panel === name));
2562
+ document
2563
+ .querySelectorAll('.ajustes-panel')
2564
+ .forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === name));
2565
+ if (name === 'agente') moveAgentControls(agentControlsSlot);
2566
+ }
2567
+
2568
+ ajustesTabs.forEach((tab) =>
2569
+ tab.addEventListener('click', () => activateAjustesPanel(tab.dataset.panel))
2570
+ );
2571
+
2572
+ const limitsRoundsEl = $('#limits-rounds');
2573
+ const limitsTokensEl = $('#limits-tokens');
2574
+ const limitsDurationEl = $('#limits-duration');
2575
+
2576
+ async function refreshLimits() {
2577
+ const data = await api('/api/settings').catch(() => null);
2578
+ const settings = data?.settings;
2579
+ if (!settings) return;
2580
+ if (limitsRoundsEl) limitsRoundsEl.value = settings.maxRounds ?? 0;
2581
+ if (limitsTokensEl) limitsTokensEl.value = settings.maxTokens ?? 0;
2582
+ if (limitsDurationEl) limitsDurationEl.value = Math.round((settings.maxDurationMs ?? 0) / 1000);
2583
+ }
2584
+
2585
+ $('#limits-save')?.addEventListener('click', async () => {
2586
+ const body = {
2587
+ maxRounds: Math.max(0, Number(limitsRoundsEl?.value) || 0),
2588
+ maxTokens: Math.max(0, Number(limitsTokensEl?.value) || 0),
2589
+ maxDurationMs: Math.max(0, Math.round((Number(limitsDurationEl?.value) || 0) * 1000)),
2590
+ };
2591
+ try {
2592
+ await api('/api/settings', { method: 'PUT', body });
2593
+ refreshLimits();
2594
+ setStatus('#limits-msg', 'guardado', 'ok');
2595
+ } catch (error) {
2596
+ setStatus('#limits-msg', error.message, 'err');
2597
+ }
2598
+ });
2599
+
2404
2600
  function setStatus(node, text, cls = '') {
2405
2601
  const elNode = $(node);
2406
2602
  if (!elNode) return;
@@ -2442,6 +2638,8 @@ function syncMcpFormFields() {
2442
2638
  const http = $('#mf-transport').value === 'http';
2443
2639
  $('#mf-command-field').hidden = http;
2444
2640
  $('#mf-args-field').hidden = http;
2641
+ $('#mf-env-field').hidden = http;
2642
+ $('#mf-inherit-field').hidden = http;
2445
2643
  $('#mf-url-field').hidden = !http;
2446
2644
  }
2447
2645
 
@@ -2457,6 +2655,7 @@ function openMcpForm(server = null) {
2457
2655
  $('#mf-env').value = Object.entries(server?.env ?? {})
2458
2656
  .map(([key, value]) => `${key}=${value ?? ''}`)
2459
2657
  .join('\n');
2658
+ $('#mf-inherit-env').checked = server?.inheritEnv === true;
2460
2659
  $('#mf-enabled').checked = server ? server.enabled !== false : true;
2461
2660
  syncMcpFormFields();
2462
2661
  setStatus('#mf-status', '');
@@ -2516,6 +2715,7 @@ async function submitMcp(event) {
2516
2715
  args: $('#mf-args').value.trim(),
2517
2716
  url: $('#mf-url').value.trim(),
2518
2717
  env: parseEnv($('#mf-env').value),
2718
+ inheritEnv: $('#mf-inherit-env').checked,
2519
2719
  enabled: $('#mf-enabled').checked,
2520
2720
  };
2521
2721
  try {
@@ -2631,6 +2831,9 @@ async function refreshChannels() {
2631
2831
  if (tgTranscribeEl && document.activeElement !== tgTranscribeEl) {
2632
2832
  tgTranscribeEl.value = tg.transcribeModel ?? 'whisper-1';
2633
2833
  }
2834
+ if (tgTranscribeProviderEl && document.activeElement !== tgTranscribeProviderEl) {
2835
+ tgTranscribeProviderEl.value = tg.transcribeProvider ?? '';
2836
+ }
2634
2837
  if (tgTokenEl && document.activeElement !== tgTokenEl) {
2635
2838
  tgTokenEl.value = '';
2636
2839
  tgTokenEl.placeholder = tg.tokenHint ? `${tg.tokenHint} (dejar vacío para no cambiar)` : '123456:ABC…';
@@ -2643,6 +2846,7 @@ async function submitChannel() {
2643
2846
  const body = {
2644
2847
  allowedChatIds: tgChatsEl?.value ?? '',
2645
2848
  transcribeModel: tgTranscribeEl?.value.trim() || 'whisper-1',
2849
+ transcribeProvider: tgTranscribeProviderEl?.value.trim() || '',
2646
2850
  };
2647
2851
  const token = tgTokenEl.value.trim();
2648
2852
  if (token) body.token = token;
@@ -33,7 +33,7 @@
33
33
  </head>
34
34
  <body>
35
35
  <header class="topbar">
36
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.6.1</span></div>
36
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.7.0</span></div>
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>
@@ -118,6 +118,7 @@
118
118
  </div>
119
119
  </div>
120
120
  <form id="chat-form" class="composer">
121
+ <div id="slash-menu" class="slash-menu" hidden></div>
121
122
  <div id="attach-preview" class="attach-preview" hidden></div>
122
123
  <div class="composer-row">
123
124
  <span class="prompt-mark">❯</span>
@@ -134,7 +135,7 @@
134
135
  <button class="btn" type="button" id="stop-btn" title="Detener la respuesta" hidden>parar</button>
135
136
  </div>
136
137
  </form>
137
- <div class="statusbar">
138
+ <div id="statusbar" class="statusbar" title="cambiar modo, proveedor y modelo" role="button" tabindex="0">
138
139
  <span id="sb-mode">agent</span>
139
140
  <span class="dot">·</span>
140
141
  <span id="sb-provider">—</span>
@@ -343,7 +344,62 @@
343
344
  </section>
344
345
 
345
346
  <section id="view-ajustes" class="view">
346
- <div class="panel">
347
+ <div class="ajustes-wrap">
348
+ <nav class="ajustes-tabs" role="tablist" aria-label="secciones de ajustes">
349
+ <button type="button" class="ajustes-tab active" data-panel="agente" role="tab">Agente</button>
350
+ <button type="button" class="ajustes-tab" data-panel="sesiones" role="tab">Sesiones</button>
351
+ <button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
352
+ <button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
353
+ <button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
354
+ <button type="button" class="ajustes-tab" data-panel="limites" role="tab">Límites</button>
355
+ </nav>
356
+
357
+ <div class="panel ajustes-panel active" data-panel="agente">
358
+ <div class="row between">
359
+ <h2>Agente</h2>
360
+ </div>
361
+ <p class="muted verify-line">
362
+ Modo, proveedor, modelo y aprobación automática del agente.
363
+ </p>
364
+ <div id="agent-controls-slot">
365
+ <div id="agent-settings-controls">
366
+ <label for="mode">Modo</label>
367
+ <select id="mode" title="modo de agente"></select>
368
+ <label for="provider">Proveedor</label>
369
+ <select id="provider" title="proveedor"></select>
370
+ <label for="model">Modelo</label>
371
+ <select id="model" title="modelo"></select>
372
+ <label class="check" title="aprobar automáticamente tools con permiso ask">
373
+ <input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
374
+ </label>
375
+ </div>
376
+ </div>
377
+ <div class="row">
378
+ <button type="button" id="agent-settings-open" class="btn">abrir en ventana</button>
379
+ <button type="button" id="agent-providers" class="btn">gestionar proveedores</button>
380
+ </div>
381
+ </div>
382
+
383
+ <div class="panel ajustes-panel" data-panel="sesiones">
384
+ <div class="row between">
385
+ <h2>Sesiones</h2>
386
+ </div>
387
+ <p class="muted verify-line">
388
+ Carpeta donde se abre el selector de carpetas y <code>/new</code> cuando no hay sesión.
389
+ </p>
390
+ <label for="default-cwd">Carpeta predeterminada para nuevas sesiones</label>
391
+ <div class="row default-cwd-row">
392
+ <input id="default-cwd" type="text" readonly placeholder="(la casa del usuario)" title="carpeta donde se abre el selector y /new sin sesión" />
393
+ <button type="button" id="default-cwd-pick" class="btn">elegir</button>
394
+ <button type="button" id="default-cwd-clear" class="btn icon" title="quitar la carpeta predeterminada" aria-label="quitar la carpeta predeterminada">
395
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
396
+ <path d="M18 6 6 18M6 6l12 12" />
397
+ </svg>
398
+ </button>
399
+ </div>
400
+ </div>
401
+
402
+ <div class="panel ajustes-panel" data-panel="mcp">
347
403
  <div class="row between">
348
404
  <h2>Servidores MCP</h2>
349
405
  <div class="row">
@@ -362,7 +418,7 @@
362
418
  <ul id="mcp-list" class="providers"></ul>
363
419
  </div>
364
420
 
365
- <div class="panel">
421
+ <div class="panel ajustes-panel" data-panel="skills">
366
422
  <div class="row between">
367
423
  <h2>Skills</h2>
368
424
  <button id="skill-new" class="btn">+ nueva</button>
@@ -378,7 +434,7 @@
378
434
  <ul id="skill-list" class="providers"></ul>
379
435
  </div>
380
436
 
381
- <div class="panel">
437
+ <div class="panel ajustes-panel" data-panel="telegram">
382
438
  <div class="row between">
383
439
  <h2>Telegram</h2>
384
440
  <span id="tg-status" class="summary">cargando…</span>
@@ -394,6 +450,10 @@
394
450
  <label class="field"><span>chats autorizados (ids separados por coma)</span>
395
451
  <input id="tg-chats" spellcheck="false" placeholder="123456789, -1001234567890" />
396
452
  </label>
453
+ <label class="field"><span>proveedor de transcripción (voz, opcional)</span>
454
+ <input id="tg-transcribe-provider" spellcheck="false"
455
+ placeholder="openai · groq — vacío = proveedor activo" />
456
+ </label>
397
457
  <label class="field"><span>modelo de transcripción (voz)</span>
398
458
  <input id="tg-transcribe" spellcheck="false" placeholder="whisper-1" />
399
459
  </label>
@@ -412,6 +472,30 @@
412
472
  <span id="tg-msg" class="summary"></span>
413
473
  </div>
414
474
  </div>
475
+
476
+ <div class="panel ajustes-panel" data-panel="limites">
477
+ <div class="row between">
478
+ <h2>Límites del agente</h2>
479
+ </div>
480
+ <p class="muted verify-line">
481
+ El agente trabaja hasta terminar solo (<code>end_turn</code>). Estos topes son
482
+ opcionales: <code>0</code> = sin límite. Al cortarse, cierra con un resumen.
483
+ </p>
484
+ <label class="field"><span>máximo de rondas de herramientas (0 = sin límite)</span>
485
+ <input id="limits-rounds" type="number" min="0" step="1" placeholder="0" />
486
+ </label>
487
+ <label class="field"><span>presupuesto de tokens (0 = sin límite)</span>
488
+ <input id="limits-tokens" type="number" min="0" step="1000" placeholder="0" />
489
+ </label>
490
+ <label class="field"><span>presupuesto de tiempo en segundos (0 = sin límite)</span>
491
+ <input id="limits-duration" type="number" min="0" step="1" placeholder="0" />
492
+ </label>
493
+ <div class="row">
494
+ <button id="limits-save" class="btn primary" type="button">guardar</button>
495
+ <span id="limits-msg" class="summary"></span>
496
+ </div>
497
+ </div>
498
+ </div>
415
499
  </section>
416
500
 
417
501
  <dialog id="folder-modal" class="modal folder-modal">
@@ -481,25 +565,7 @@
481
565
  </svg>
482
566
  </button>
483
567
  </div>
484
- <label for="mode">Modo</label>
485
- <select id="mode" title="modo de agente"></select>
486
- <label for="provider">Proveedor</label>
487
- <select id="provider" title="proveedor"></select>
488
- <label for="model">Modelo</label>
489
- <select id="model" title="modelo"></select>
490
- <label for="default-cwd">Carpeta predeterminada para nuevas sesiones</label>
491
- <div class="row default-cwd-row">
492
- <input id="default-cwd" type="text" readonly placeholder="(la casa del usuario)" title="carpeta donde se abre el selector y /new sin sesión" />
493
- <button type="button" id="default-cwd-pick" class="btn">elegir</button>
494
- <button type="button" id="default-cwd-clear" class="btn icon" title="quitar la carpeta predeterminada" aria-label="quitar la carpeta predeterminada">
495
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
496
- <path d="M18 6 6 18M6 6l12 12" />
497
- </svg>
498
- </button>
499
- </div>
500
- <label class="check" title="aprobar automáticamente tools con permiso ask">
501
- <input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
502
- </label>
568
+ <div id="settings-controls-slot"></div>
503
569
  <div class="row between settings-actions">
504
570
  <button type="button" id="settings-providers" class="btn">gestionar proveedores</button>
505
571
  <button type="button" id="settings-done" class="btn primary">listo</button>
@@ -534,9 +600,10 @@
534
600
  <div class="row">
535
601
  <label class="field grow" id="mf-args-field"><span>args (separados por espacio)</span><input id="mf-args" spellcheck="false" placeholder="harness/scripts/mcp-echo-server.mjs" /></label>
536
602
  </div>
537
- <label class="field"><span>env (KEY=valor por línea)</span>
538
- <textarea id="mf-env" rows="3" spellcheck="false" placeholder="API_KEY=..."></textarea>
603
+ <label class="field" id="mf-env-field"><span>env (KEY=valor por línea · usá <code>env:VAR</code> para no hardcodear secretos)</span>
604
+ <textarea id="mf-env" rows="3" spellcheck="false" placeholder="API_KEY=env:MY_API_KEY"></textarea>
539
605
  </label>
606
+ <label class="check" id="mf-inherit-field"><input id="mf-inherit-env" type="checkbox" /> heredar todo el entorno (<code>inheritEnv</code>, solo MCPs de confianza)</label>
540
607
  <label class="check"><input id="mf-enabled" type="checkbox" checked /> habilitado</label>
541
608
  <div class="row between settings-actions">
542
609
  <button type="button" id="mf-cancel" class="btn">cancelar</button>
@@ -621,7 +621,7 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
621
621
 
622
622
  @keyframes pulse { 50% { opacity: 0.35; } }
623
623
 
624
- .composer { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); }
624
+ .composer { position: relative; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); }
625
625
  .composer.dragging { outline: 1px dashed var(--accent); outline-offset: -4px; }
626
626
  .composer-row { display: flex; align-items: flex-end; gap: 8px; }
627
627
  .prompt-mark { color: var(--mine); font-weight: 700; padding-bottom: 8px; }
@@ -707,6 +707,46 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
707
707
  }
708
708
  .statusbar .dot { opacity: 0.5; }
709
709
  .statusbar .spacer { flex: 1; }
710
+ .statusbar[role="button"] { cursor: pointer; }
711
+ .statusbar[role="button"]:hover { color: var(--fg); }
712
+
713
+ /* menú de comandos "/" en el composer */
714
+ .slash-menu {
715
+ position: absolute;
716
+ left: 14px;
717
+ right: 14px;
718
+ bottom: calc(100% - 6px);
719
+ max-height: 260px;
720
+ overflow-y: auto;
721
+ display: flex;
722
+ flex-direction: column;
723
+ gap: 2px;
724
+ padding: 6px;
725
+ border: 1px solid var(--border);
726
+ border-radius: 12px;
727
+ background: var(--panel);
728
+ box-shadow: 0 12px 32px rgb(0 0 0 / 0.22);
729
+ z-index: 30;
730
+ }
731
+ .slash-menu[hidden] { display: none; }
732
+ .slash-item {
733
+ display: flex;
734
+ align-items: baseline;
735
+ gap: 10px;
736
+ width: 100%;
737
+ padding: 7px 9px;
738
+ border: 0;
739
+ border-radius: 8px;
740
+ background: none;
741
+ color: var(--fg);
742
+ text-align: left;
743
+ cursor: pointer;
744
+ font: 500 13px var(--mono);
745
+ }
746
+ .slash-item.active,
747
+ .slash-item:hover { background: var(--theirs); }
748
+ .slash-cmd { color: var(--accent); font-weight: 700; }
749
+ .slash-desc { color: var(--muted); font: 400 12px var(--serif); }
710
750
  .ctx { display: inline-flex; align-items: center; gap: 8px; }
711
751
  .ctx-bar { width: 76px; height: 6px; border-radius: 999px; background: var(--border); overflow: hidden; }
712
752
  .ctx-fill { display: block; height: 100%; width: 0; border-radius: 999px; background: currentColor; transition: width 0.35s ease; }
@@ -724,6 +764,26 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
724
764
 
725
765
  .card { border: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); border-radius: 14px; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
726
766
 
767
+ /* ajustes: pestañas internas ---------------------------------------------- */
768
+ #view-ajustes.active { display: block; overflow-y: auto; }
769
+ .ajustes-wrap { width: 100%; max-width: 780px; margin: 0 auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; min-height: 100%; }
770
+ .ajustes-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
771
+ .ajustes-tab {
772
+ appearance: none; background: none; border: 0; border-bottom: 2px solid transparent;
773
+ padding: 8px 14px; margin-bottom: -1px; color: var(--muted); cursor: pointer;
774
+ font: 600 13px var(--serif); letter-spacing: 0.01em;
775
+ }
776
+ .ajustes-tab:hover { color: var(--fg); }
777
+ .ajustes-tab.active { color: var(--fg); border-bottom-color: var(--accent); }
778
+ .ajustes-panel { display: none; }
779
+ .ajustes-panel.active { display: flex; }
780
+ .ajustes-panel.panel { flex: none; max-width: none; overflow: visible; padding: 6px 2px 18px; }
781
+ .ajustes-panel .verify-line code { font-size: 11px; }
782
+ #agent-settings-controls { display: flex; flex-direction: column; gap: 7px; }
783
+ #agent-settings-controls label { color: var(--muted); font-size: 12px; }
784
+ #agent-settings-controls select { width: 100%; }
785
+ #agent-controls-slot:empty { display: none; }
786
+
727
787
  .providers { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
728
788
  .providers li {
729
789
  display: flex;