@danieltmn/tamnora 0.9.4 → 0.9.6

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 (41) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +25 -10
  3. package/harness/packages/cli/src/commands/autostart.js +5 -1
  4. package/harness/packages/cli/src/commands/doctor.js +14 -0
  5. package/harness/packages/cli/src/commands/update.js +91 -21
  6. package/harness/packages/core/src/index.js +7 -1
  7. package/harness/packages/core/src/version.js +81 -7
  8. package/harness/packages/core/test/version.test.js +34 -0
  9. package/harness/packages/server/public/app.js +337 -24
  10. package/harness/packages/server/public/favicon.ico +0 -0
  11. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  12. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  13. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  14. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  15. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  16. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  17. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  18. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  19. package/harness/packages/server/public/icons/logo.png +0 -0
  20. package/harness/packages/server/public/icons/tamnora.ico +0 -0
  21. package/harness/packages/server/public/index.html +119 -30
  22. package/harness/packages/server/public/landing/index.html +8 -4
  23. package/harness/packages/server/public/logo.png +0 -0
  24. package/harness/packages/server/public/manifest.webmanifest +20 -0
  25. package/harness/packages/server/public/markdown.js +54 -0
  26. package/harness/packages/server/public/styles.css +1775 -1633
  27. package/harness/packages/server/src/lib/advisor.js +177 -0
  28. package/harness/packages/server/src/lib/agentRun.js +2 -1
  29. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  30. package/harness/packages/server/src/lib/update.js +82 -0
  31. package/harness/packages/server/src/routes/advisor.js +76 -0
  32. package/harness/packages/server/src/routes/chat.js +30 -0
  33. package/harness/packages/server/src/routes/tts.js +1 -1
  34. package/harness/packages/server/src/routes/update.js +43 -0
  35. package/harness/packages/server/src/server.js +26 -2
  36. package/harness/packages/server/src/static.js +1 -0
  37. package/harness/packages/server/test/advisor.test.js +205 -0
  38. package/harness/packages/server/test/markdown.test.js +43 -1
  39. package/harness/packages/server/test/update.test.js +94 -0
  40. package/harness/scripts/self-update.mjs +123 -0
  41. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  // Tamnora console — dependency-free client for the harness server (terminal theme).
2
2
 
3
- import { renderMarkdown } from './markdown.js';
3
+ import { renderMarkdown, speechText } from './markdown.js';
4
4
  import { streamSse, followRun } from './runStream.js';
5
5
  import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
6
6
  import { installRelayTransport, showRelayTokenGate } from './relayClient.js';
@@ -39,6 +39,7 @@ function setCwd(value) {
39
39
  if (!value) return;
40
40
  state.cwd = value;
41
41
  $('#rail-cwd').textContent = value;
42
+ renderCwdPill();
42
43
  if (state.explorerOpen) refreshExplorerMode();
43
44
  }
44
45
 
@@ -217,7 +218,7 @@ function activateTab(name) {
217
218
  hideMoreMenu();
218
219
  if (name === 'runs') refreshRuns();
219
220
  if (name === 'providers') refreshProviders();
220
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshTranscribe(); refreshProfile(); }
221
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
221
222
  }
222
223
 
223
224
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -478,11 +479,6 @@ async function loadModes() {
478
479
  updateStatus();
479
480
  }
480
481
 
481
- function modeLabel(id) {
482
- const option = [...modeSelect.options].find((o) => o.value === id);
483
- return option ? option.textContent : id;
484
- }
485
-
486
482
  async function setMode(id, { persist = true } = {}) {
487
483
  if (![...modeSelect.options].some((o) => o.value === id)) return;
488
484
  modeSelect.value = id;
@@ -504,11 +500,82 @@ document.addEventListener('keydown', (event) => {
504
500
  }
505
501
  });
506
502
 
503
+ /* --- pills del composer (carpeta, permisos, modelo) ----------------------- */
504
+
505
+ const cwdPill = $('#cwd-pill');
506
+ const cwdPillLabel = $('#cwd-pill-label');
507
+ const modePill = $('#mode-pill');
508
+ const modePillLabel = $('#mode-pill-label');
509
+ const modeMenu = $('#mode-menu');
510
+ const modelPill = $('#model-pill');
511
+ const modelPillLabel = $('#model-pill-label');
512
+
513
+ function renderCwdPill() {
514
+ if (!cwdPillLabel) return;
515
+ cwdPillLabel.textContent = state.cwd ? basename(state.cwd) : 'sin carpeta';
516
+ if (cwdPill) cwdPill.title = state.cwd || 'sin carpeta de trabajo';
517
+ }
518
+
519
+ // El pill de permisos resume modo + auto-aprobación en uno de tres niveles
520
+ // estilo Codex: Read Only, Workspace Write y Full access.
521
+ function renderModePill() {
522
+ const plan = modeSelect.value === 'plan';
523
+ const full = !plan && Boolean(yesInput?.checked);
524
+ if (modePillLabel) modePillLabel.textContent = plan ? 'Read Only' : full ? 'Full access' : 'Workspace Write';
525
+ if (!modeMenu) return;
526
+ for (const item of modeMenu.querySelectorAll('.ctrl-menu-item')) {
527
+ const kind = item.dataset.kind;
528
+ const active = kind === 'read' ? plan : kind === 'full' ? full : !plan && !full;
529
+ item.classList.toggle('active', active);
530
+ item.setAttribute('aria-checked', active ? 'true' : 'false');
531
+ }
532
+ }
533
+
534
+ function renderModelPill() {
535
+ if (!modelPillLabel) return;
536
+ modelPillLabel.textContent = state.active.model || 'elegí un modelo';
537
+ if (modelPill) modelPill.title = `${state.active.provider || '—'} / ${state.active.model || '—'}`;
538
+ }
539
+
540
+ function closeModeMenu() {
541
+ if (!modeMenu || modeMenu.hidden) return;
542
+ modeMenu.hidden = true;
543
+ modePill?.setAttribute('aria-expanded', 'false');
544
+ }
545
+
546
+ cwdPill?.addEventListener('click', () => openFolderPicker('session'));
547
+ modePill?.addEventListener('click', (event) => {
548
+ event.stopPropagation();
549
+ if (!modeMenu) return;
550
+ modeMenu.hidden = !modeMenu.hidden;
551
+ modePill.setAttribute('aria-expanded', modeMenu.hidden ? 'false' : 'true');
552
+ });
553
+ modeMenu?.addEventListener('click', async (event) => {
554
+ const item = event.target.closest('.ctrl-menu-item');
555
+ if (!item) return;
556
+ const kind = item.dataset.kind;
557
+ closeModeMenu();
558
+ if (kind === 'full') {
559
+ await setMode('agent');
560
+ if (yesInput) yesInput.checked = true;
561
+ } else {
562
+ if (yesInput) yesInput.checked = false;
563
+ await setMode(kind === 'read' ? 'plan' : 'agent');
564
+ }
565
+ renderModePill();
566
+ });
567
+ document.addEventListener('click', (event) => {
568
+ if (modeMenu && !modeMenu.hidden && !event.target.closest('.ctrl-wrap')) closeModeMenu();
569
+ });
570
+ document.addEventListener('keydown', (event) => {
571
+ if (event.key === 'Escape') closeModeMenu();
572
+ });
573
+ modelPill?.addEventListener('click', () => openSettingsModal());
574
+ yesInput?.addEventListener('change', renderModePill);
575
+
507
576
  function updateStatus(extra = {}) {
508
- const p = state.providers.find((x) => x.id === state.active.provider);
509
- $('#sb-mode').textContent = modeLabel(modeSelect.value) || 'Build';
510
- $('#sb-provider').textContent = p ? `${p.id}` : state.active.provider || '—';
511
- $('#sb-model').textContent = state.active.model || '—';
577
+ renderModePill();
578
+ renderModelPill();
512
579
  if (extra.rounds !== undefined) $('#sb-rounds').textContent = `${extra.rounds} rondas`;
513
580
  if (extra.stopReason) $('#sb-rounds').textContent = extra.stopReason;
514
581
  if (extra.running) $('#sb-rounds').textContent = 'pensando…';
@@ -1464,8 +1531,35 @@ function userImages(content) {
1464
1531
  .filter(Boolean);
1465
1532
  }
1466
1533
 
1534
+ function advisorGlyph(kind) {
1535
+ if (kind === 'code') return '⚠';
1536
+ if (kind === 'explain') return 'ℹ';
1537
+ return '✦';
1538
+ }
1539
+
1540
+ /** Tarjeta del asesor: una capa de comentario, no un mensaje del agente. */
1541
+ function addAdvisorCard(container, { text, kind } = {}) {
1542
+ const clean = String(text ?? '').trim();
1543
+ if (!clean || !container) return null;
1544
+ const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
1545
+ if (exists) return null;
1546
+ const card = el('div', `advisor-card${kind ? ` advisor-${kind}` : ''}`);
1547
+ const head = el('div', 'advisor-head');
1548
+ head.append(el('span', 'advisor-glyph', advisorGlyph(kind)), el('span', 'advisor-title', 'asesor'));
1549
+ card.append(head, el('div', 'advisor-text', clean));
1550
+ container.appendChild(card);
1551
+ return card;
1552
+ }
1553
+
1467
1554
  function renderStored(message, pane) {
1468
1555
  if (message.agent_id) return;
1556
+ if (message.role === 'assistant' && message.name === 'advisor') {
1557
+ addAdvisorCard(pane, {
1558
+ text: typeof message.content === 'string' ? message.content : '',
1559
+ kind: message.mode,
1560
+ });
1561
+ return;
1562
+ }
1469
1563
  if (message.role === 'user') {
1470
1564
  const bubble = addMessage('user', userText(message.content), pane);
1471
1565
  if (message.id) {
@@ -1787,8 +1881,9 @@ function activateSession(id) {
1787
1881
 
1788
1882
  function updateSubmitLabel(working) {
1789
1883
  if (!submitBtn) return;
1790
- submitBtn.textContent = working ? 'encolar' : 'enviar';
1791
- submitBtn.title = working ? 'El agente está trabajando: se envía al terminar' : '';
1884
+ submitBtn.dataset.working = working ? '1' : '0';
1885
+ submitBtn.setAttribute('aria-label', working ? 'encolar' : 'enviar');
1886
+ submitBtn.title = working ? 'El agente está trabajando: se envía al terminar' : 'enviar';
1792
1887
  }
1793
1888
 
1794
1889
  function auditStatus(record) {
@@ -2006,6 +2101,11 @@ function addContinueButton(turn, data) {
2006
2101
  function handleChatEvent(event, data, turn) {
2007
2102
  if (handleAgentEvent(event, data, turn)) return;
2008
2103
  narrar(event, data, turn);
2104
+ if (event === 'advice') {
2105
+ addAdvisorCard(turn.pane, { text: data?.text, kind: data?.kind });
2106
+ scrollMessages(turn.pane);
2107
+ return;
2108
+ }
2009
2109
  if (event === 'agent_start') {
2010
2110
  turn.agents.set(data.agentId, addAgentCard(turn.assistant(), data));
2011
2111
  } else if (event === 'agent_end') {
@@ -3622,7 +3722,15 @@ const vozRateEl = $('#voz-rate');
3622
3722
  const vozAnnStartEl = $('#voz-ann-start');
3623
3723
  const vozAnnToolsEl = $('#voz-ann-tools');
3624
3724
  const vozAnnEndEl = $('#voz-ann-end');
3725
+ const vozAnnAdviceEl = $('#voz-ann-advice');
3726
+ const vozAnnAnswerEl = $('#voz-ann-answer');
3625
3727
  const vozFfmpegEl = $('#voz-ffmpeg');
3728
+ const asesorEnabledEl = $('#asesor-enabled');
3729
+ const asesorCodeEl = $('#asesor-code');
3730
+ const asesorExplainEl = $('#asesor-explain');
3731
+ const asesorAdviceEl = $('#asesor-advice');
3732
+ const asesorWordsEl = $('#asesor-words');
3733
+ const asesorNotesEl = $('#asesor-notes');
3626
3734
  const trProviderEl = $('#tr-provider');
3627
3735
  const trModelEl = $('#tr-model');
3628
3736
 
@@ -3955,7 +4063,7 @@ const voz = {
3955
4063
  enabled: false,
3956
4064
  voice: 'es-AR-ElenaNeural',
3957
4065
  rate: '+0%',
3958
- announce: { start: true, tools: true, end: true },
4066
+ announce: { start: true, tools: true, end: true, advice: true, answer: false },
3959
4067
  },
3960
4068
  };
3961
4069
 
@@ -4086,10 +4194,10 @@ function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
4086
4194
  * Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
4087
4195
  * cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
4088
4196
  */
4089
- function hablar(texto) {
4197
+ function hablar(texto, { label = 'narrando', maxAgeMs = 45000 } = {}) {
4090
4198
  const frase = String(texto ?? '').trim();
4091
4199
  if (!frase || !voz.settings.enabled) return;
4092
- reproducirVoz(frase, { label: 'narrando', maxAgeMs: 45000 }).catch(() => {});
4200
+ reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
4093
4201
  }
4094
4202
 
4095
4203
  /** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
@@ -4099,7 +4207,15 @@ function narrar(event, data, turn) {
4099
4207
  if (!enabled) return;
4100
4208
  if (event === 'start' && announce.start) hablar(data?.say);
4101
4209
  else if (event === 'tool_call' && announce.tools) hablar(data?.say);
4102
- else if ((event === 'done' || event === 'aborted' || event === 'error') && announce.end) hablar(data?.say);
4210
+ // El asesor y la respuesta final son el cierre del turno: si quedan en cola
4211
+ // detras de un audio largo, igual hay que leerlos (sin vencimiento).
4212
+ else if (event === 'advice' && announce.advice) hablar(data?.say, { label: 'asesor', maxAgeMs: 0 });
4213
+ else if (event === 'done' && announce.answer) {
4214
+ // Lee la respuesta final limpia; si era solo codigo, cae al cierre.
4215
+ const spoken = speechText(data?.content);
4216
+ if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0 });
4217
+ else if (announce.end) hablar(data?.say);
4218
+ } else if ((event === 'done' || event === 'aborted' || event === 'error') && announce.end) hablar(data?.say);
4103
4219
  }
4104
4220
 
4105
4221
  async function cargarVoces() {
@@ -4125,6 +4241,8 @@ async function aplicarVoz(data) {
4125
4241
  start: data.announce?.start !== false,
4126
4242
  tools: data.announce?.tools !== false,
4127
4243
  end: data.announce?.end !== false,
4244
+ advice: data.announce?.advice !== false,
4245
+ answer: data.announce?.answer === true,
4128
4246
  },
4129
4247
  };
4130
4248
  if (vozEnabledEl) vozEnabledEl.checked = voz.settings.enabled;
@@ -4142,6 +4260,8 @@ async function aplicarVoz(data) {
4142
4260
  if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
4143
4261
  if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
4144
4262
  if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
4263
+ if (vozAnnAdviceEl) vozAnnAdviceEl.checked = voz.settings.announce.advice;
4264
+ if (vozAnnAnswerEl) vozAnnAnswerEl.checked = voz.settings.announce.answer;
4145
4265
  const estado = $('#voz-status');
4146
4266
  if (estado) {
4147
4267
  estado.className = `summary${voz.settings.enabled ? ' ok' : ''}`;
@@ -4165,6 +4285,8 @@ async function submitVoz() {
4165
4285
  start: vozAnnStartEl?.checked !== false,
4166
4286
  tools: vozAnnToolsEl?.checked !== false,
4167
4287
  end: vozAnnEndEl?.checked !== false,
4288
+ advice: vozAnnAdviceEl?.checked !== false,
4289
+ answer: vozAnnAnswerEl?.checked === true,
4168
4290
  },
4169
4291
  };
4170
4292
  const data = await api('/api/tts/settings', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
@@ -4342,6 +4464,57 @@ async function testVoz() {
4342
4464
  }
4343
4465
  }
4344
4466
 
4467
+ /* --- ajustes: asesor ------------------------------------------------------- */
4468
+
4469
+ function aplicarAsesor(data) {
4470
+ if (!data) return;
4471
+ if (asesorEnabledEl) asesorEnabledEl.checked = data.enabled === true;
4472
+ if (asesorCodeEl) asesorCodeEl.checked = data.code !== false;
4473
+ if (asesorExplainEl) asesorExplainEl.checked = data.explain !== false;
4474
+ if (asesorAdviceEl) asesorAdviceEl.checked = data.advice !== false;
4475
+ if (asesorWordsEl) asesorWordsEl.value = data.maxWords ?? 40;
4476
+ if (asesorNotesEl) asesorNotesEl.value = data.notes ?? '';
4477
+ const estado = $('#asesor-status');
4478
+ if (estado) {
4479
+ estado.className = `summary${data.enabled ? ' ok' : ''}`;
4480
+ estado.textContent = data.enabled ? 'activo' : 'apagado';
4481
+ }
4482
+ }
4483
+
4484
+ async function refreshAsesor() {
4485
+ if (!asesorEnabledEl) return;
4486
+ const data = await api('/api/advisor/settings').catch(() => null);
4487
+ if (data) aplicarAsesor(data);
4488
+ }
4489
+
4490
+ function asesorBody() {
4491
+ return {
4492
+ enabled: asesorEnabledEl?.checked === true,
4493
+ code: asesorCodeEl?.checked !== false,
4494
+ explain: asesorExplainEl?.checked !== false,
4495
+ advice: asesorAdviceEl?.checked !== false,
4496
+ maxWords: Number(asesorWordsEl?.value) || 40,
4497
+ notes: asesorNotesEl?.value ?? '',
4498
+ };
4499
+ }
4500
+
4501
+ async function submitAsesor() {
4502
+ setStatus('#asesor-msg', 'guardando…');
4503
+ const data = await api('/api/advisor/settings', { method: 'PUT', body: asesorBody() }).catch((e) => ({
4504
+ error: e.message,
4505
+ }));
4506
+ if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
4507
+ aplicarAsesor(data);
4508
+ setStatus('#asesor-msg', 'guardado', 'ok');
4509
+ }
4510
+
4511
+ async function testAsesor() {
4512
+ setStatus('#asesor-msg', 'consultando…');
4513
+ const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
4514
+ if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
4515
+ setStatus('#asesor-msg', data.speak ? data.text : '(no diría nada en este caso)', 'ok');
4516
+ }
4517
+
4345
4518
  /* --- ajustes: perfil del usuario ------------------------------------------ */
4346
4519
 
4347
4520
  const perfil = {
@@ -4474,8 +4647,10 @@ if (mcpListEl) {
4474
4647
  $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
4475
4648
  $('#tg-save')?.addEventListener('click', submitChannel);
4476
4649
  $('#tg-test')?.addEventListener('click', testChannel);
4477
- $('#voz-save')?.addEventListener('click', submitVoz);
4478
- $('#voz-test')?.addEventListener('click', testVoz);
4650
+ $('#voz-save')?.addEventListener('click', submitVoz);
4651
+ $('#voz-test')?.addEventListener('click', testVoz);
4652
+ $('#asesor-save')?.addEventListener('click', submitAsesor);
4653
+ $('#asesor-test')?.addEventListener('click', testAsesor);
4479
4654
  $('#mcp-close').addEventListener('click', closeMcpForm);
4480
4655
  $('#mf-cancel').addEventListener('click', closeMcpForm);
4481
4656
  $('#mf-transport').addEventListener('change', syncMcpFormFields);
@@ -5312,10 +5487,45 @@ function applyState(diff) {
5312
5487
 
5313
5488
  /* --- señales de alerta: que se note aun mirando otra cosa ----------------- */
5314
5489
 
5315
- const FAVICON_IDLE =
5316
- "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23231a17'/%3E%3Ctext x='16' y='23' font-family='Georgia,serif' font-size='18' font-weight='700' fill='%23e8dfd6' text-anchor='middle'%3ET%3C/text%3E%3C/svg%3E";
5317
- const FAVICON_ALERT =
5318
- "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23231a17'/%3E%3Ctext x='16' y='23' font-family='Georgia,serif' font-size='18' font-weight='700' fill='%23e8dfd6' text-anchor='middle'%3ET%3C/text%3E%3Ccircle cx='24' cy='9' r='5' fill='%23d9704a'/%3E%3C/svg%3E";
5490
+ const FAVICON_IDLE = '/favicon.ico';
5491
+ const FAVICON_SOURCE = '/icons/icon-192x192.png';
5492
+ let faviconAlert = '';
5493
+ let faviconAlertPending = null;
5494
+
5495
+ /**
5496
+ * El favicon de alerta se dibuja sobre una copia del logo (mismo origen), para
5497
+ * que el punto rojo combine con el icono real y no con un SVG desincronizado.
5498
+ */
5499
+ function alertFavicon() {
5500
+ if (faviconAlert) return Promise.resolve(faviconAlert);
5501
+ if (faviconAlertPending) return faviconAlertPending;
5502
+ faviconAlertPending = new Promise((resolve) => {
5503
+ const img = new Image();
5504
+ img.onload = () => {
5505
+ try {
5506
+ const canvas = document.createElement('canvas');
5507
+ canvas.width = 64;
5508
+ canvas.height = 64;
5509
+ const ctx = canvas.getContext('2d');
5510
+ ctx.drawImage(img, 0, 0, 64, 64);
5511
+ ctx.beginPath();
5512
+ ctx.arc(64 - 16, 16, 13, 0, Math.PI * 2);
5513
+ ctx.fillStyle = '#d9704a';
5514
+ ctx.fill();
5515
+ ctx.lineWidth = 3;
5516
+ ctx.strokeStyle = 'rgba(35,26,23,0.9)';
5517
+ ctx.stroke();
5518
+ faviconAlert = canvas.toDataURL('image/png');
5519
+ resolve(faviconAlert);
5520
+ } catch {
5521
+ resolve(FAVICON_IDLE);
5522
+ }
5523
+ };
5524
+ img.onerror = () => resolve(FAVICON_IDLE);
5525
+ img.src = FAVICON_SOURCE;
5526
+ });
5527
+ return faviconAlertPending;
5528
+ }
5319
5529
 
5320
5530
  let baseTitle = '';
5321
5531
 
@@ -5330,7 +5540,10 @@ function renderAlertSignals() {
5330
5540
  const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
5331
5541
  document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
5332
5542
  const link = document.querySelector('link[rel="icon"]');
5333
- if (link) link.href = pending > 0 ? FAVICON_ALERT : FAVICON_IDLE;
5543
+ if (link) {
5544
+ if (pending > 0) alertFavicon().then((href) => { link.href = href; });
5545
+ else link.href = FAVICON_IDLE;
5546
+ }
5334
5547
  }
5335
5548
 
5336
5549
  /* --- canal global: solo latencia ------------------------------------------ */
@@ -5436,6 +5649,105 @@ function watchLifecycle() {
5436
5649
  window.addEventListener('pageshow', wake);
5437
5650
  window.addEventListener('online', wake);
5438
5651
  window.addEventListener('focus', wake);
5652
+ // La cache del server dura 10 min: revisar al volver a la pestaña es barato.
5653
+ refreshUpdate();
5654
+ }
5655
+
5656
+ /* --- actualizacion de version --------------------------------------------- */
5657
+
5658
+ const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
5659
+ const updateBanner = $('#update-banner');
5660
+ const updateBannerText = $('#update-banner-text');
5661
+ let updateInfo = null;
5662
+
5663
+ function showUpdateBanner(info, { force = false } = {}) {
5664
+ if (!updateBanner || !info?.updateAvailable) return;
5665
+ if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
5666
+ updateInfo = info;
5667
+ updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
5668
+ updateBanner.hidden = false;
5669
+ const ver = document.querySelector('.brand .ver');
5670
+ if (ver) {
5671
+ ver.classList.add('has-update');
5672
+ ver.title = `Actualización disponible: v${info.latest}`;
5673
+ }
5674
+ }
5675
+
5676
+ async function refreshUpdate() {
5677
+ if (!updateBanner) return;
5678
+ const info = await api('/api/update').catch(() => null);
5679
+ if (info?.updateAvailable) showUpdateBanner(info);
5680
+ }
5681
+
5682
+ function dismissUpdate() {
5683
+ if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
5684
+ if (updateBanner) updateBanner.hidden = true;
5685
+ }
5686
+
5687
+ /** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
5688
+ async function forceUpdateCheck() {
5689
+ const ver = document.querySelector('.brand .ver');
5690
+ const original = ver?.textContent;
5691
+ if (ver) ver.textContent = '…';
5692
+ const info = await api('/api/update?force=1').catch(() => null);
5693
+ if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
5694
+ if (!info) return toast('No pude consultar la versión.');
5695
+ if (info.updateAvailable) {
5696
+ showUpdateBanner(info, { force: true });
5697
+ toast(`Hay una versión nueva: v${info.latest}`);
5698
+ } else if (info.error) {
5699
+ toast(`No pude consultar npm: ${info.error}`);
5700
+ } else {
5701
+ toast(`Estás al día (v${info.current}).`);
5702
+ }
5703
+ }
5704
+
5705
+ /** Espera a que el server reiniciado vuelva a responder `/api/health`. */
5706
+ async function waitServerVersion(tries = 45) {
5707
+ for (let i = 0; i < tries; i++) {
5708
+ await new Promise((r) => setTimeout(r, 2000));
5709
+ const health = await fetch('/api/health', { cache: 'no-store' })
5710
+ .then((r) => (r.ok ? r.json() : null))
5711
+ .catch(() => null);
5712
+ if (health?.version) return health.version;
5713
+ }
5714
+ return '';
5715
+ }
5716
+
5717
+ async function runUpdate() {
5718
+ if (!updateInfo) return;
5719
+ if (!confirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`)) return;
5720
+ const runBtn = $('#update-run');
5721
+ const laterBtn = $('#update-later');
5722
+ if (runBtn) runBtn.disabled = true;
5723
+ if (laterBtn) laterBtn.disabled = true;
5724
+ updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
5725
+ const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
5726
+ if (res?.error) {
5727
+ updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
5728
+ if (runBtn) runBtn.disabled = false;
5729
+ if (laterBtn) laterBtn.disabled = false;
5730
+ return;
5731
+ }
5732
+ updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
5733
+ const version = await waitServerVersion();
5734
+ if (version) {
5735
+ updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
5736
+ setTimeout(() => location.reload(), 900);
5737
+ } else {
5738
+ updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
5739
+ if (runBtn) runBtn.disabled = false;
5740
+ if (laterBtn) laterBtn.disabled = false;
5741
+ }
5742
+ }
5743
+
5744
+ $('#update-run')?.addEventListener('click', runUpdate);
5745
+ $('#update-later')?.addEventListener('click', dismissUpdate);
5746
+ const brandVerEl = document.querySelector('.brand .ver');
5747
+ if (brandVerEl) {
5748
+ brandVerEl.title = 'buscar actualización';
5749
+ brandVerEl.classList.add('ver-action');
5750
+ brandVerEl.addEventListener('click', forceUpdateCheck);
5439
5751
  }
5440
5752
 
5441
5753
  /* --- boot ---------------------------------------------------------------- */
@@ -5459,6 +5771,7 @@ async function boot() {
5459
5771
  const ver = document.querySelector('.brand .ver');
5460
5772
  if (ver) ver.textContent = `v${health.version}`;
5461
5773
  }
5774
+ refreshUpdate();
5462
5775
  await refreshProviders();
5463
5776
  await loadModes();
5464
5777
  await loadModels();