@danieltmn/tamnora 0.21.2 → 0.23.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 (77) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/harness/packages/cli/bin/tamnora.mjs +7 -1
  5. package/harness/packages/cli/src/commands/doctor.js +42 -4
  6. package/harness/packages/cli/src/commands/repl.js +1 -0
  7. package/harness/packages/cli/src/commands/run.js +1 -0
  8. package/harness/packages/cli/src/commands/tui.js +1 -0
  9. package/harness/packages/cli/src/lib/runtime.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +2 -0
  11. package/harness/packages/core/src/agent/runAgent.js +9 -5
  12. package/harness/packages/core/src/browser/index.js +27 -13
  13. package/harness/packages/core/src/index.js +1 -0
  14. package/harness/packages/core/src/mcp/stdio.js +15 -0
  15. package/harness/packages/core/src/net/hosts.js +70 -0
  16. package/harness/packages/core/src/redact.js +2 -0
  17. package/harness/packages/core/src/tools/executors.js +19 -24
  18. package/harness/packages/core/src/tools/index.js +1 -1
  19. package/harness/packages/core/src/tools/paths.js +35 -5
  20. package/harness/packages/core/src/tools/permissions.js +41 -2
  21. package/harness/packages/core/src/tools/registry.js +8 -3
  22. package/harness/packages/core/src/tts/edge.js +34 -4
  23. package/harness/packages/core/src/tunnel.js +15 -5
  24. package/harness/packages/core/test/agent.test.js +18 -0
  25. package/harness/packages/core/test/servicios-vivos.test.js +71 -0
  26. package/harness/packages/core/test/tools.test.js +44 -1
  27. package/harness/packages/core/test/tts.test.js +81 -0
  28. package/harness/packages/server/bin/tamnora-server.mjs +7 -1
  29. package/harness/packages/server/public/app.js +347 -6757
  30. package/harness/packages/server/public/cards.js +470 -0
  31. package/harness/packages/server/public/chat.js +1343 -0
  32. package/harness/packages/server/public/chatFormat.js +156 -0
  33. package/harness/packages/server/public/chrome.js +813 -0
  34. package/harness/packages/server/public/commands.js +403 -0
  35. package/harness/packages/server/public/core.js +209 -0
  36. package/harness/packages/server/public/explorer.js +625 -0
  37. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/index.html +40 -41
  40. package/harness/packages/server/public/sessions.js +274 -0
  41. package/harness/packages/server/public/settings.js +1080 -0
  42. package/harness/packages/server/public/styles.css +45 -52
  43. package/harness/packages/server/public/system.js +495 -0
  44. package/harness/packages/server/public/voice.js +1058 -0
  45. package/harness/packages/server/src/auth.js +12 -10
  46. package/harness/packages/server/src/lib/agentRun.js +6 -1
  47. package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
  48. package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
  49. package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
  50. package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
  51. package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
  52. package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
  53. package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
  54. package/harness/packages/server/src/lib/channels/telegram.js +163 -932
  55. package/harness/packages/server/src/lib/presence.js +12 -1
  56. package/harness/packages/server/src/lib/scheduler.js +12 -2
  57. package/harness/packages/server/src/lib/tasks.js +21 -3
  58. package/harness/packages/server/src/lib/workspace.js +90 -2
  59. package/harness/packages/server/src/routes/auth.js +8 -4
  60. package/harness/packages/server/src/routes/chat.js +16 -5
  61. package/harness/packages/server/src/routes/preview.js +10 -2
  62. package/harness/packages/server/src/routes/providers.js +14 -9
  63. package/harness/packages/server/src/server.js +25 -0
  64. package/harness/packages/server/test/chat.test.js +586 -0
  65. package/harness/packages/server/test/chatFormat.test.js +119 -0
  66. package/harness/packages/server/test/consoleModules.test.js +301 -0
  67. package/harness/packages/server/test/consoleSmoke.test.js +34 -0
  68. package/harness/packages/server/test/helpers.js +121 -0
  69. package/harness/packages/server/test/ops.test.js +515 -0
  70. package/harness/packages/server/test/runs.test.js +816 -0
  71. package/harness/packages/server/test/security.test.js +112 -9
  72. package/harness/packages/server/test/sessions.test.js +437 -0
  73. package/harness/packages/server/test/settings.test.js +482 -0
  74. package/harness/scripts/canary.mjs +24 -0
  75. package/harness/tamnora.example.json +1 -1
  76. package/package.json +3 -2
  77. package/harness/packages/server/test/server.test.js +0 -2885
@@ -509,7 +509,14 @@ html, body {
509
509
  font: 13.5px/1.5 var(--font);
510
510
  }
511
511
 
512
- body { display: flex; flex-direction: column; background: var(--page-bg); }
512
+ body {
513
+ display: flex;
514
+ flex-direction: column;
515
+ background: var(--page-bg);
516
+ /* Safe-area (notch / barra home en movil): reserva el inset para que no tape
517
+ el composer ni el final del chat. En desktop `env()` es 0. */
518
+ padding-bottom: env(safe-area-inset-bottom, 0px);
519
+ }
513
520
  pre, code, kbd, samp { font-family: var(--mono); }
514
521
  .muted { color: var(--muted); }
515
522
 
@@ -518,7 +525,7 @@ pre, code, kbd, samp { font-family: var(--mono); }
518
525
  position: relative;
519
526
  z-index: 40;
520
527
  display: grid;
521
- grid-template-columns: 1fr auto 1fr;
528
+ grid-template-columns: 1fr auto;
522
529
  align-items: center;
523
530
  gap: 16px;
524
531
  padding: 10px 16px;
@@ -548,28 +555,23 @@ pre, code, kbd, samp { font-family: var(--mono); }
548
555
  flex: 0 0 auto;
549
556
  }
550
557
  .brand .logo img { width: 100%; height: 100%; display: block; object-fit: cover; }
558
+ /* El logo sigue el esquema del tema: el claro es el dibujo oscuro y viceversa
559
+ (`logo_dark`/`logo_light` nombran el tema donde se usan, no su color). */
560
+ .brand .logo img { content: url('/icons/logo_dark.png'); }
561
+ html[data-scheme='light'] .brand .logo img { content: url('/icons/logo_light.png'); }
551
562
  .brand .ver { color: var(--muted); font-family: var(--mono); font-weight: 400; font-size: 10px; letter-spacing: 0.04em; }
552
- .brand .topbar-section { display: none; }
553
- .tabs { grid-column: 2; justify-self: center; display: flex; gap: 2px; }
554
- .tab {
555
- background: transparent;
556
- color: var(--muted);
557
- border: 1px solid transparent;
563
+ .brand .topbar-section {
564
+ padding: 2px 8px;
558
565
  border-radius: 999px;
559
- padding: 6px 14px;
560
- cursor: pointer;
561
- font: 600 12.5px var(--sans);
566
+ border: 1px solid var(--border);
567
+ background: var(--accent-soft);
568
+ color: var(--accent);
569
+ font: 700 10px var(--sans);
570
+ text-transform: uppercase;
571
+ letter-spacing: 0.08em;
572
+ white-space: nowrap;
562
573
  }
563
- .tab:hover { color: var(--text); }
564
- .tab.active { color: var(--text); background: var(--theirs); border-color: var(--border); }
565
-
566
574
  .menu { position: relative; display: block; }
567
- #more-tab { display: inline-flex; align-items: center; gap: 6px; }
568
- #more-tab svg { width: 18px; height: 18px; display: block; }
569
- #more-tab-label { display: none; font: 600 12.5px var(--sans); }
570
- @media (min-width: 821px) {
571
- #more-menu button.menu-primary { display: none; }
572
- }
573
575
 
574
576
  /* El icono muestra el tema al que se va a cambiar: claro -> luna, oscuro -> sol. */
575
577
  #theme-toggle .ico-sun { display: none; }
@@ -579,9 +581,9 @@ html[data-scheme='dark'] #theme-toggle .ico-moon { display: none; }
579
581
  .menu-pop {
580
582
  position: absolute;
581
583
  top: calc(100% + 4px);
582
- left: 0;
584
+ right: 0;
583
585
  z-index: 60;
584
- min-width: 130px;
586
+ min-width: 160px;
585
587
  display: flex;
586
588
  flex-direction: column;
587
589
  gap: 2px;
@@ -604,7 +606,9 @@ html[data-scheme='dark'] #theme-toggle .ico-moon { display: none; }
604
606
  }
605
607
  .menu-pop button:hover { background: var(--theirs); }
606
608
  .menu-pop button.active { background: var(--theirs); color: var(--accent); }
607
- .settings { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: 8px; }
609
+ .menu-pop .menu-divider { height: 1px; margin: 3px 4px; background: var(--border); }
610
+ .menu > .btn.icon.active { color: var(--accent); border-color: var(--accent); }
611
+ .settings { grid-column: 2; justify-self: end; display: flex; align-items: center; gap: 8px; }
608
612
  .settings select { max-width: 220px; }
609
613
 
610
614
  /* Nueva sesión en el topbar: texto en desktop, solo el icono en móvil, donde
@@ -636,6 +640,12 @@ select, input, textarea {
636
640
  }
637
641
  select:focus, input:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
638
642
 
643
+ /* Foco de teclado visible (los inputs tienen su estilo propio arriba). */
644
+ button:focus-visible, a:focus-visible, summary:focus-visible, [tabindex]:focus-visible {
645
+ outline: 2px solid var(--accent);
646
+ outline-offset: 2px;
647
+ }
648
+
639
649
  /* layout ------------------------------------------------------------------ */
640
650
  main { flex: 1; min-height: 0; min-width: 0; display: flex; }
641
651
  .view { display: none; flex: 1; min-height: 0; min-width: 0; position: relative; }
@@ -2073,12 +2083,14 @@ dialog.file-modal.preview-maximized {
2073
2083
  width: 100vw;
2074
2084
  max-width: 100vw;
2075
2085
  height: 100vh;
2086
+ height: 100dvh;
2076
2087
  max-height: 100vh;
2088
+ max-height: 100dvh;
2077
2089
  margin: 0;
2078
2090
  border: 0;
2079
2091
  border-radius: 0;
2080
2092
  }
2081
- .file-modal.preview-maximized .modal-card { height: 100vh; }
2093
+ .file-modal.preview-maximized .modal-card { height: 100vh; height: 100dvh; }
2082
2094
  .file-modal.preview-maximized .file-stage,
2083
2095
  .file-modal.preview-maximized .file-render,
2084
2096
  .file-modal.preview-maximized .code-view,
@@ -2158,32 +2170,8 @@ dialog.file-modal.preview-maximized {
2158
2170
  .brand .ver {
2159
2171
  display: none;
2160
2172
  }
2161
- .brand .topbar-section {
2162
- display: inline-block;
2163
- padding: 2px 8px;
2164
- border-radius: 999px;
2165
- border: 1px solid var(--border);
2166
- background: var(--accent-soft);
2167
- color: var(--accent);
2168
- font: 700 10px var(--sans);
2169
- text-transform: uppercase;
2170
- letter-spacing: 0.08em;
2171
- white-space: nowrap;
2172
- }
2173
- .tabs {
2174
- margin-left: auto;
2175
- max-width: 100%;
2176
- }
2177
- .tabs > .tab[data-tab] {
2178
- display: none;
2179
- }
2180
- #more-tab-label {
2181
- display: none;
2182
- }
2183
2173
  .menu-pop {
2184
2174
  top: calc(100% + 2px);
2185
- left: auto;
2186
- right: 0;
2187
2175
  min-width: 200px;
2188
2176
  padding: 6px;
2189
2177
  }
@@ -2192,7 +2180,7 @@ dialog.file-modal.preview-maximized {
2192
2180
  }
2193
2181
  .settings {
2194
2182
  grid-column: auto;
2195
- margin-left: 0;
2183
+ margin-left: auto;
2196
2184
  /* Antes `1 1 100%`: eso ocupaba toda la fila y mandaba los botones a una
2197
2185
  segunda linea. Ahora se quedan en la misma linea que TH. */
2198
2186
  flex: 0 0 auto;
@@ -2338,9 +2326,6 @@ dialog.file-modal.preview-maximized {
2338
2326
  .topbar {
2339
2327
  gap: 8px 8px;
2340
2328
  }
2341
- #more-tab-label {
2342
- display: none;
2343
- }
2344
2329
  .settings {
2345
2330
  gap: 5px;
2346
2331
  }
@@ -2350,6 +2335,10 @@ dialog.file-modal.preview-maximized {
2350
2335
  .messages {
2351
2336
  padding: 12px 11px 18px;
2352
2337
  }
2338
+ .confirm-actions .btn {
2339
+ flex: 1 1 auto;
2340
+ min-height: 40px;
2341
+ }
2353
2342
  }
2354
2343
 
2355
2344
  /* settings modal ---------------------------------------------------------- */
@@ -2424,6 +2413,7 @@ dialog.file-modal.preview-maximized {
2424
2413
  dialog.modal {
2425
2414
  width: min(760px, calc(100vw - 40px));
2426
2415
  max-height: calc(100vh - 60px);
2416
+ max-height: calc(100dvh - 60px);
2427
2417
  padding: 0;
2428
2418
  border: 1px solid var(--border);
2429
2419
  border-radius: 18px;
@@ -2436,6 +2426,9 @@ dialog.modal {
2436
2426
  dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: blur(3px); }
2437
2427
  .modal-card { display: flex; flex-direction: column; gap: 12px; padding: 20px; }
2438
2428
  .modal-card h3 { margin: 0; font: 700 17px var(--serif); letter-spacing: -0.02em; }
2429
+ .confirm-modal .modal-card { max-width: min(420px, 92vw); }
2430
+ .confirm-text { margin: 0; color: var(--muted, inherit); line-height: 1.5; }
2431
+ .confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
2439
2432
  .row .spacer { flex: 1; }
2440
2433
 
2441
2434
  /* update banner ----------------------------------------------------------- */
@@ -0,0 +1,495 @@
1
+ import { livePermission, reloadPane, renderSessions, resolvePermissionCard, sendPresence } from './chat.js';
2
+ import { providerSelect, setSummaryText, yesInput } from './chrome.js';
3
+ import { toast } from './commands.js';
4
+ import { $, api, clear, el, openStream, showConfirm, state, storage, streamPost } from './core.js';
5
+ import { diffState, localState, pollDelayMs } from './reconcile.js';
6
+ import { followRun } from './runStream.js';
7
+ import { closeTab, renderTabs, showStandalonePermission } from './sessions.js';
8
+
9
+ /* --- eval ---------------------------------------------------------------- */
10
+
11
+ export const evalTasks = $('#eval-tasks');
12
+ export const evalResults = $('#eval-results');
13
+ export const evalSummary = $('#eval-summary');
14
+ export const evalRunBtn = $('#eval-run');
15
+
16
+ export function parseTasks(text) {
17
+ const trimmed = text.trim();
18
+ if (!trimmed) return [];
19
+ try {
20
+ const parsed = JSON.parse(trimmed);
21
+ return Array.isArray(parsed) ? parsed : [parsed];
22
+ } catch {
23
+ // fall through to JSONL
24
+ }
25
+ return trimmed
26
+ .split('\n')
27
+ .map((l) => l.trim())
28
+ .filter(Boolean)
29
+ .map((l) => JSON.parse(l));
30
+ }
31
+
32
+ export function setEvalSummary(text, cls = '') {
33
+ setSummaryText(evalSummary, text, cls);
34
+ }
35
+
36
+ export function detailOf(entry) {
37
+ if (entry.error) return entry.error;
38
+ return (entry.trace?.scorers ?? []).map((s) => `${s.scorer}:${s.passed ? 'ok' : 'no'}`).join(' ');
39
+ }
40
+
41
+ export function addResultRow(tbody, entry) {
42
+ const tr = el('tr');
43
+ const pass = entry.passed;
44
+ tr.append(
45
+ el('td', pass ? 'pass' : 'fail', pass ? '✓' : '·'),
46
+ el('td', '', entry.taskId ?? '—'),
47
+ el('td', '', entry.model ?? '—'),
48
+ el('td', '', entry.score === undefined || entry.score === null ? '' : String(entry.score)),
49
+ el('td', '', entry.durationMs === undefined ? '' : String(entry.durationMs)),
50
+ el('td', 'detail', detailOf(entry))
51
+ );
52
+ tbody.appendChild(tr);
53
+ }
54
+
55
+ export async function runEval() {
56
+ let tasks;
57
+ try {
58
+ tasks = parseTasks(evalTasks.value);
59
+ } catch (error) {
60
+ return setEvalSummary(`Dataset inválido: ${error.message}`, 'err');
61
+ }
62
+ const models = $('#eval-models')
63
+ .value.split(',')
64
+ .map((s) => s.trim())
65
+ .filter(Boolean);
66
+ if (!tasks.length) return setEvalSummary('Ingresá al menos una tarea.', 'err');
67
+ if (!models.length) return setEvalSummary('Ingresá al menos un modelo.', 'err');
68
+
69
+ clear(evalResults);
70
+ evalRunBtn.disabled = true;
71
+ setEvalSummary('corriendo…');
72
+ try {
73
+ await streamPost(
74
+ '/api/eval',
75
+ {
76
+ tasks,
77
+ models,
78
+ provider: providerSelect.value || undefined,
79
+ trials: Number($('#eval-trials').value) || 1,
80
+ concurrency: Number($('#eval-concurrency').value) || 1,
81
+ judgeModel: $('#eval-judge').value.trim() || undefined,
82
+ yes: yesInput.checked ? true : undefined,
83
+ },
84
+ (event, data) => {
85
+ if (event === 'run') setEvalSummary(`Run ${data.runId} · ${data.total} ejecuciones`);
86
+ else if (event === 'result') addResultRow(evalResults, data);
87
+ else if (event === 'summary') {
88
+ const s = data.summary;
89
+ setEvalSummary(`total ${s.total} · ${s.passed} ok · ${s.errors} errores · ${(s.durationMs / 1000).toFixed(1)}s`);
90
+ } else if (event === 'error') setEvalSummary(`Error: ${data.message}`, 'err');
91
+ }
92
+ );
93
+ } catch (error) {
94
+ setEvalSummary(`Error: ${error.message}`, 'err');
95
+ } finally {
96
+ evalRunBtn.disabled = false;
97
+ }
98
+ }
99
+
100
+ /* --- runs ---------------------------------------------------------------- */
101
+
102
+ export const runsList = $('#runs-list');
103
+ export const runDetail = $('#run-detail');
104
+
105
+ export async function refreshRuns() {
106
+ const data = await api('/api/runs').catch(() => ({ runs: [] }));
107
+ clear(runsList);
108
+ clear(runDetail);
109
+ for (const run of data.runs) {
110
+ const li = el('li');
111
+ li.append(el('span', '', run.dataset || '(inline)'), el('span', 'muted', `${run.status} · ${new Date(run.created_at).toLocaleString()}`));
112
+ li.addEventListener('click', () => {
113
+ runsList.querySelectorAll('li').forEach((n) => n.classList.remove('active'));
114
+ li.classList.add('active');
115
+ openRun(run.id);
116
+ });
117
+ runsList.appendChild(li);
118
+ }
119
+ if (!data.runs.length) runsList.appendChild(el('li', 'muted', 'Sin runs.'));
120
+ }
121
+
122
+ export async function openRun(id) {
123
+ const data = await api(`/api/runs/${id}`);
124
+ clear(runDetail);
125
+ const summary = data.run.summary ?? {};
126
+ runDetail.appendChild(el('p', 'muted', `total ${summary.total ?? 0} · ${summary.passed ?? 0} ok · ${summary.errors ?? 0} errores`));
127
+ const table = el('table', 'results');
128
+ const thead = el('thead');
129
+ const head = el('tr');
130
+ for (const label of ['ok', 'tarea', 'modelo', 'score', 'ms', 'detalle']) head.appendChild(el('th', '', label));
131
+ thead.appendChild(head);
132
+ const tbody = el('tbody');
133
+ table.append(thead, tbody);
134
+ runDetail.appendChild(table);
135
+ for (const row of data.results) {
136
+ addResultRow(tbody, {
137
+ taskId: row.task_id,
138
+ model: row.model,
139
+ passed: Boolean(row.passed),
140
+ score: row.score,
141
+ durationMs: row.duration_ms,
142
+ trace: row.trace,
143
+ });
144
+ }
145
+ }
146
+
147
+ /* --- reconciliacion: la verdad no depende del canal ------------------------ */
148
+
149
+ // El canal global lleva el heartbeat de 15 s del server, asi que 30 s son tres
150
+ // pings: menos espera cuando lo que se pierde tiempo es que aparezca un permiso
151
+ // que te esta esperando.
152
+ export const CHANNEL_SILENCE_MS = 30000;
153
+
154
+ export let lastState = null;
155
+ export let reconciling = false;
156
+ export let reconcileTimer = null;
157
+ export let syncStale = false;
158
+
159
+ /**
160
+ * Un solo lugar decide que hacer con el estado remoto.
161
+ *
162
+ * Antes esto estaba repartido entre el canal global y un sweep: dos caminos que
163
+ * se contradijian, lo que hacia que un SSE caido se tradujera en "no me entero
164
+ * de nada". La logica de diff vive en `reconcile.js`, que es puro y testeado.
165
+ *
166
+ * La verdad es `/api/state`: un request corto. Los SSE son atajos de latencia.
167
+ */
168
+ export async function reconcile({ immediate = false } = {}) {
169
+ if (reconciling) {
170
+ // Si hay una en curso, encadenamos otra: si no, una mutacion que cae justo
171
+ // en medio se pierde.
172
+ if (immediate) state.reconcileAgain = true;
173
+ return;
174
+ }
175
+ reconciling = true;
176
+ try {
177
+ const remote = await api('/api/state');
178
+ syncStale = false;
179
+ renderSyncState();
180
+ applyState(diffState(localState({ panes: state.panes, asks: state.pendingAsks.keys() }), remote));
181
+ lastState = remote;
182
+ scheduleReconcile();
183
+ } catch (error) {
184
+ // Esto antes era un `.catch(() => null)` silencioso: un fallo de red se via
185
+ // exactamente igual que "no hay nada pendiente", y por eso no se podia
186
+ // diagnosticar nada.
187
+ syncStale = true;
188
+ renderSyncState(error);
189
+ scheduleReconcile();
190
+ } finally {
191
+ reconciling = false;
192
+ if (state.reconcileAgain) {
193
+ state.reconcileAgain = false;
194
+ reconcile();
195
+ }
196
+ }
197
+ }
198
+
199
+ export function scheduleReconcile() {
200
+ if (reconcileTimer) clearTimeout(reconcileTimer);
201
+ // Con un permiso pendiente hay algo esperandonos: la latencia se nota.
202
+ reconcileTimer = setTimeout(() => reconcile(), pollDelayMs(lastState));
203
+ }
204
+
205
+ export function applyState(diff) {
206
+ for (const id of diff.gone) closeTab(id);
207
+ for (const id of diff.toDetach) reloadPane(id);
208
+ for (const id of diff.toAttach) reloadPane(id);
209
+ for (const askId of diff.resolvedAsks) {
210
+ state.pendingAsks.delete(askId);
211
+ // `null` = alguien lo resolvio en otro lado: la tarjeta ya se cerro alla y
212
+ // solo hay que apagarla.
213
+ resolvePermissionCard(state.permissionCards.get(askId), null);
214
+ }
215
+ for (const ask of diff.newAsks) {
216
+ if (state.pendingAsks.has(ask.askId)) continue;
217
+ state.pendingAsks.set(ask.askId, ask);
218
+ toast(`Permiso pendiente: ${ask.name || 'una tool'}${state.panes.has(ask.sessionId) ? '' : ' en otra sesión'}.`);
219
+ }
220
+ // Los permisos pendientes se pintan siempre, sin importar por donde llegaron:
221
+ // asi tambien se ven cuando el canal estaba caido y llego por el snapshot.
222
+ for (const [askId, ask] of state.pendingAsks) {
223
+ const record = state.panes.get(ask.sessionId);
224
+ if (record && !livePermission(askId)) showStandalonePermission(record, ask);
225
+ }
226
+ state.remoteActive = new Set(diff.remoteActive);
227
+ renderSessions(diff.sessions);
228
+ renderTabs();
229
+ renderAlertSignals();
230
+ }
231
+
232
+ /* --- señales de alerta: que se note aun mirando otra cosa ----------------- */
233
+
234
+ export const FAVICON_IDLE = '/favicon.ico';
235
+ export const FAVICON_SOURCE = '/icons/icon-192x192.png';
236
+ export let faviconAlert = '';
237
+ export let faviconAlertPending = null;
238
+
239
+ /**
240
+ * El favicon de alerta se dibuja sobre una copia del logo (mismo origen), para
241
+ * que el punto rojo combine con el icono real y no con un SVG desincronizado.
242
+ */
243
+ export function alertFavicon() {
244
+ if (faviconAlert) return Promise.resolve(faviconAlert);
245
+ if (faviconAlertPending) return faviconAlertPending;
246
+ faviconAlertPending = new Promise((resolve) => {
247
+ const img = new Image();
248
+ img.onload = () => {
249
+ try {
250
+ const canvas = document.createElement('canvas');
251
+ canvas.width = 64;
252
+ canvas.height = 64;
253
+ const ctx = canvas.getContext('2d');
254
+ ctx.drawImage(img, 0, 0, 64, 64);
255
+ ctx.beginPath();
256
+ ctx.arc(64 - 16, 16, 13, 0, Math.PI * 2);
257
+ ctx.fillStyle = '#d9704a';
258
+ ctx.fill();
259
+ ctx.lineWidth = 3;
260
+ ctx.strokeStyle = 'rgba(35,26,23,0.9)';
261
+ ctx.stroke();
262
+ faviconAlert = canvas.toDataURL('image/png');
263
+ resolve(faviconAlert);
264
+ } catch {
265
+ resolve(FAVICON_IDLE);
266
+ }
267
+ };
268
+ img.onerror = () => resolve(FAVICON_IDLE);
269
+ img.src = FAVICON_SOURCE;
270
+ });
271
+ return faviconAlertPending;
272
+ }
273
+
274
+ export let baseTitle = '';
275
+
276
+ /**
277
+ * Cuando iOS manda la pestana a background mata los timers y los SSE. El
278
+ * titulo y el favicon son las **unicas** señales que quedan, asi que los dos
279
+ * tienen que marcar que hay un permiso esperando.
280
+ */
281
+ export function renderAlertSignals() {
282
+ const pending = state.pendingAsks.size + state.pendingQuestions.size;
283
+ if (!baseTitle) baseTitle = document.title;
284
+ const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
285
+ document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
286
+ const link = document.querySelector('link[rel="icon"]');
287
+ if (link) {
288
+ if (pending > 0) alertFavicon().then((href) => { link.href = href; });
289
+ else link.href = FAVICON_IDLE;
290
+ }
291
+ }
292
+
293
+ /* --- canal global: solo latencia ------------------------------------------ */
294
+
295
+ /**
296
+ * Un SSE por pestana para enterarse al instante. **No decide nada**: dispara
297
+ * `reconcile()`. Si se cae -que es lo normal en un tunel, y lo que hace iOS al
298
+ * mandar la pestana a background- solo perdemos inmediatez, no correctitud.
299
+ */
300
+ export function handleGlobalEvent(event) {
301
+ // No decide nada: solo pide el estado fresco. El diff es el unico que sabe
302
+ // que hay que hacer.
303
+ if (event === 'run_start' || event === 'run_end' || event === 'permission' || event === 'permission_resolved' || event === 'snapshot') {
304
+ reconcile({ immediate: true });
305
+ }
306
+ }
307
+
308
+ export let eventsController = null;
309
+ export let channelState = 'off';
310
+ export let channelReason = '';
311
+
312
+ export function renderChannelState() {
313
+ const node = $('#sb-channel');
314
+ if (!node) return;
315
+ if (channelState === 'live') {
316
+ node.hidden = true;
317
+ return;
318
+ }
319
+ node.hidden = false;
320
+ node.className = `sb-channel ${channelState}`;
321
+ node.textContent = channelState === 'off' ? 'sin canal' : 'reconectando…';
322
+ node.title = channelReason
323
+ ? `Canal de eventos caido (${channelReason}). Los cambios llegan igual por sondeo cada ${Math.round(pollDelayMs(lastState) / 1000)} s, pero con retardo.`
324
+ : 'Canal de eventos: sin novedades instantaneas.';
325
+ }
326
+
327
+ /** El sondeo de /api/state tambien puede caerse; eso si hay que verlo. */
328
+ export function renderSyncState(error) {
329
+ const node = $('#sb-sync');
330
+ if (!node) return;
331
+ node.hidden = !syncStale;
332
+ node.className = 'sb-channel retrying';
333
+ node.textContent = 'sin sincronía';
334
+ node.title = error
335
+ ? `No pude leer el estado del server: ${error.message}. Reintentando cada ${Math.round(pollDelayMs(lastState) / 1000)} s.`
336
+ : 'No pude leer el estado del server. Reintentando.';
337
+ }
338
+
339
+ export async function followEvents() {
340
+ if (eventsController && !eventsController.signal.aborted) return;
341
+ const controller = new AbortController();
342
+ eventsController = controller;
343
+ channelState = 'waiting';
344
+ channelReason = '';
345
+ renderChannelState();
346
+ await followRun({
347
+ signal: controller.signal,
348
+ attempts: Infinity,
349
+ backoff: [1000, 2000, 5000, 10000, 15000],
350
+ onRetry: ({ attempt, error }) => {
351
+ channelState = 'retrying';
352
+ channelReason = error?.message ? `${error.message} (intento ${attempt})` : `sin datos (intento ${attempt})`;
353
+ renderChannelState();
354
+ },
355
+ open: (cursor, signal) =>
356
+ openStream('/api/events', { signal, silenceMs: CHANNEL_SILENCE_MS }, (event, data) => {
357
+ channelState = 'live';
358
+ channelReason = '';
359
+ renderChannelState();
360
+ handleGlobalEvent(event, data);
361
+ }),
362
+ })
363
+ .catch((error) => {
364
+ // Un AbortError es que estamos cerrando. Cualquier otra cosa es un bug de
365
+ // la consola: se avisa en vez de dejar un "reconectando" que no dice nada.
366
+ if (error?.name === 'AbortError') return;
367
+ channelState = 'off';
368
+ channelReason = error?.message ?? String(error);
369
+ renderChannelState();
370
+ toast(`Canal de eventos roto: ${error?.message ?? error}`);
371
+ })
372
+ .finally(() => {
373
+ if (eventsController === controller) {
374
+ channelState = 'off';
375
+ renderChannelState();
376
+ }
377
+ });
378
+ }
379
+
380
+ /* --- ciclo de vida --------------------------------------------------------- */
381
+
382
+ export function watchLifecycle() {
383
+ // Volver a `visible` es el momento de reconciliar: iOS congela los timers en
384
+ // background, asi que esperar al proximo tick puede ser Mucho tiempo.
385
+ const wake = () => {
386
+ reconcile({ immediate: true });
387
+ sendPresence();
388
+ if (!eventsController || eventsController.signal.aborted) followEvents();
389
+ };
390
+ document.addEventListener('visibilitychange', () => {
391
+ if (document.visibilityState === 'visible') wake();
392
+ });
393
+ window.addEventListener('pageshow', wake);
394
+ window.addEventListener('online', wake);
395
+ window.addEventListener('focus', wake);
396
+ // La cache del server dura 10 min: revisar al volver a la pestaña es barato.
397
+ refreshUpdate();
398
+ }
399
+
400
+ /* --- actualizacion de version --------------------------------------------- */
401
+
402
+ export const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
403
+ export const updateBanner = $('#update-banner');
404
+ export const updateBannerText = $('#update-banner-text');
405
+ export let updateInfo = null;
406
+
407
+ export function showUpdateBanner(info, { force = false } = {}) {
408
+ if (!updateBanner || !info?.updateAvailable) return;
409
+ if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
410
+ updateInfo = info;
411
+ updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
412
+ updateBanner.hidden = false;
413
+ const ver = document.querySelector('.brand .ver');
414
+ if (ver) {
415
+ ver.classList.add('has-update');
416
+ ver.title = `Actualización disponible: v${info.latest}`;
417
+ }
418
+ }
419
+
420
+ export async function refreshUpdate() {
421
+ if (!updateBanner) return;
422
+ const info = await api('/api/update').catch(() => null);
423
+ if (info?.updateAvailable) showUpdateBanner(info);
424
+ }
425
+
426
+ export function dismissUpdate() {
427
+ if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
428
+ if (updateBanner) updateBanner.hidden = true;
429
+ }
430
+
431
+ /** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
432
+ export async function forceUpdateCheck() {
433
+ const ver = document.querySelector('.brand .ver');
434
+ const original = ver?.textContent;
435
+ if (ver) ver.textContent = '…';
436
+ const info = await api('/api/update?force=1').catch(() => null);
437
+ if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
438
+ if (!info) return toast('No pude consultar la versión.');
439
+ if (info.updateAvailable) {
440
+ showUpdateBanner(info, { force: true });
441
+ toast(`Hay una versión nueva: v${info.latest}`);
442
+ } else if (info.error) {
443
+ toast(`No pude consultar npm: ${info.error}`);
444
+ } else {
445
+ toast(`Estás al día (v${info.current}).`);
446
+ }
447
+ }
448
+
449
+ /** Espera a que el server reiniciado vuelva a responder `/api/health`. */
450
+ export async function waitServerVersion(tries = 45) {
451
+ for (let i = 0; i < tries; i++) {
452
+ await new Promise((r) => setTimeout(r, 2000));
453
+ const health = await fetch('/api/health', { cache: 'no-store' })
454
+ .then((r) => (r.ok ? r.json() : null))
455
+ .catch(() => null);
456
+ if (health?.version) return health.version;
457
+ }
458
+ return '';
459
+ }
460
+
461
+ export async function runUpdate() {
462
+ if (!updateInfo) return;
463
+ if (
464
+ !(await showConfirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`, {
465
+ title: 'Actualizar Tamnora',
466
+ confirmLabel: 'actualizar',
467
+ }))
468
+ ) {
469
+ return;
470
+ }
471
+ const runBtn = $('#update-run');
472
+ const laterBtn = $('#update-later');
473
+ if (runBtn) runBtn.disabled = true;
474
+ if (laterBtn) laterBtn.disabled = true;
475
+ updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
476
+ const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
477
+ if (res?.error) {
478
+ updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
479
+ if (runBtn) runBtn.disabled = false;
480
+ if (laterBtn) laterBtn.disabled = false;
481
+ return;
482
+ }
483
+ updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
484
+ const version = await waitServerVersion();
485
+ if (version) {
486
+ updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
487
+ setTimeout(() => location.reload(), 900);
488
+ } else {
489
+ updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
490
+ if (runBtn) runBtn.disabled = false;
491
+ if (laterBtn) laterBtn.disabled = false;
492
+ }
493
+ }
494
+
495
+ export const brandVerEl = document.querySelector('.brand .ver');