@danieltmn/tamnora 0.25.0 → 0.26.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 (110) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +13 -25
  3. package/SECURITY.md +3 -4
  4. package/harness/packages/cli/src/cli.js +0 -3
  5. package/harness/packages/cli/src/commands/bridge.js +0 -1
  6. package/harness/packages/cli/src/commands/repl.js +20 -4
  7. package/harness/packages/cli/src/commands/run.js +6 -2
  8. package/harness/packages/cli/src/commands/server.js +1 -1
  9. package/harness/packages/cli/src/commands/tui.js +6 -2
  10. package/harness/packages/cli/src/lib/runtime.js +2 -0
  11. package/harness/packages/cli/src/lib/util.js +2 -2
  12. package/harness/packages/cli/src/tui/app.js +24 -4
  13. package/harness/packages/cli/src/tui/layout.js +1 -1
  14. package/harness/packages/cli/test/tunnel.test.js +5 -32
  15. package/harness/packages/core/src/agent/runAgent.js +5 -2
  16. package/harness/packages/core/src/agents/index.js +113 -0
  17. package/harness/packages/core/src/config/app.js +0 -4
  18. package/harness/packages/core/src/config/home.js +1 -20
  19. package/harness/packages/core/src/config/index.js +6 -8
  20. package/harness/packages/core/src/extensions/load.js +22 -0
  21. package/harness/packages/core/src/frontmatter.js +32 -0
  22. package/harness/packages/core/src/hooks/index.js +2 -16
  23. package/harness/packages/core/src/index.js +12 -14
  24. package/harness/packages/core/src/plugins/index.js +2 -16
  25. package/harness/packages/core/src/procs/project.js +68 -0
  26. package/harness/packages/core/src/procs/static.js +17 -0
  27. package/harness/packages/core/src/providers/index.js +3 -1
  28. package/harness/packages/core/src/providers/ollama.js +4 -1
  29. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  30. package/harness/packages/core/src/providers/openrouter.js +5 -1
  31. package/harness/packages/core/src/providers/presets.js +4 -3
  32. package/harness/packages/core/src/providers/reasoning.js +58 -0
  33. package/harness/packages/core/src/rules/index.js +8 -27
  34. package/harness/packages/core/src/skills/index.js +8 -19
  35. package/harness/packages/core/src/storage/sqlite.js +595 -690
  36. package/harness/packages/core/src/tunnel.js +9 -147
  37. package/harness/packages/core/test/agents.test.js +111 -0
  38. package/harness/packages/core/test/home.test.js +2 -35
  39. package/harness/packages/core/test/procs.test.js +3 -64
  40. package/harness/packages/core/test/reasoning.test.js +43 -0
  41. package/harness/packages/core/test/storage.test.js +1 -12
  42. package/harness/packages/server/public/app.js +39 -19
  43. package/harness/packages/server/public/appearance.js +2 -56
  44. package/harness/packages/server/public/boot.js +2 -2
  45. package/harness/packages/server/public/cards.js +2 -21
  46. package/harness/packages/server/public/chat.js +1 -16
  47. package/harness/packages/server/public/chatFormat.js +0 -11
  48. package/harness/packages/server/public/chrome.js +95 -5
  49. package/harness/packages/server/public/commands.js +1 -2
  50. package/harness/packages/server/public/core.js +1 -1
  51. package/harness/packages/server/public/explorer.js +7 -10
  52. package/harness/packages/server/public/icons (1).zip +0 -0
  53. package/harness/packages/server/public/index.html +1228 -1237
  54. package/harness/packages/server/public/landing/boot.js +2 -3
  55. package/harness/packages/server/public/landing/index.html +3 -367
  56. package/harness/packages/server/public/sessions.js +1 -0
  57. package/harness/packages/server/public/settings.js +1255 -1155
  58. package/harness/packages/server/public/settingsNav.js +1 -1
  59. package/harness/packages/server/public/styles.css +2341 -2867
  60. package/harness/packages/server/public/system.js +356 -495
  61. package/harness/packages/server/public/voice.js +6 -22
  62. package/harness/packages/server/src/auth.js +4 -15
  63. package/harness/packages/server/src/cli.js +1 -1
  64. package/harness/packages/server/src/lib/agents.js +60 -0
  65. package/harness/packages/server/src/lib/appearance.js +3 -22
  66. package/harness/packages/server/src/lib/channels/telegram/commands.js +1 -1
  67. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  68. package/harness/packages/server/src/lib/definitions.js +40 -0
  69. package/harness/packages/server/src/lib/prompt.js +11 -3
  70. package/harness/packages/server/src/lib/scheduler.js +4 -0
  71. package/harness/packages/server/src/lib/sessionView.js +1 -0
  72. package/harness/packages/server/src/lib/skills.js +27 -21
  73. package/harness/packages/server/src/lib/tasks.js +71 -23
  74. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  75. package/harness/packages/server/src/lib/workspace.js +9 -11
  76. package/harness/packages/server/src/routes/agents.js +88 -0
  77. package/harness/packages/server/src/routes/chat.js +9 -44
  78. package/harness/packages/server/src/routes/preview.js +5 -22
  79. package/harness/packages/server/src/routes/procs.js +2 -4
  80. package/harness/packages/server/src/routes/providers.js +7 -0
  81. package/harness/packages/server/src/routes/sessions.js +3 -1
  82. package/harness/packages/server/src/routes/tts.js +1 -1
  83. package/harness/packages/server/src/server.js +8 -5
  84. package/harness/packages/server/test/agents.test.js +194 -0
  85. package/harness/packages/server/test/appearance-route.test.js +3 -3
  86. package/harness/packages/server/test/appearance.test.js +12 -38
  87. package/harness/packages/server/test/chatFormat.test.js +0 -8
  88. package/harness/packages/server/test/consoleTabs.test.js +2 -2
  89. package/harness/packages/server/test/ops.test.js +8 -56
  90. package/harness/packages/server/test/preview.test.js +2 -42
  91. package/harness/packages/server/test/security.test.js +194 -370
  92. package/harness/packages/server/test/settingsNav.test.js +2 -2
  93. package/harness/packages/server/test/tts.test.js +4 -4
  94. package/harness/tamnora.example.json +2 -1
  95. package/package.json +2 -2
  96. package/harness/packages/cli/src/commands/eval.js +0 -186
  97. package/harness/packages/core/src/eval/dataset.js +0 -47
  98. package/harness/packages/core/src/eval/judge.js +0 -39
  99. package/harness/packages/core/src/eval/report.js +0 -48
  100. package/harness/packages/core/src/eval/runTask.js +0 -62
  101. package/harness/packages/core/src/eval/runner.js +0 -153
  102. package/harness/packages/core/src/eval/scorers.js +0 -217
  103. package/harness/packages/core/src/eval/task.js +0 -59
  104. package/harness/packages/core/src/procs/detect.js +0 -149
  105. package/harness/packages/core/test/eval.test.js +0 -144
  106. package/harness/packages/server/src/lib/advisor.js +0 -198
  107. package/harness/packages/server/src/routes/advisor.js +0 -79
  108. package/harness/packages/server/src/routes/eval.js +0 -100
  109. package/harness/packages/server/test/advisor.test.js +0 -249
  110. package/harness/scripts/example-dataset.jsonl +0 -4
@@ -1,495 +1,356 @@
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, emptyState, 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(emptyState('Sin runs todavía', 'Las evaluaciones que corras desde la pestaña eval quedan guardadas acá.'));
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');
1
+ import { livePermission, reloadPane, renderSessions, resolvePermissionCard, sendPresence } from './chat.js';
2
+ import { toast } from './commands.js';
3
+ import { $, api, openStream, showConfirm, state, storage } from './core.js';
4
+ import { diffState, localState, pollDelayMs } from './reconcile.js';
5
+ import { followRun } from './runStream.js';
6
+ import { closeTab, renderTabs, showStandalonePermission } from './sessions.js';
7
+
8
+ /* --- reconciliacion: la verdad no depende del canal ------------------------ */
9
+
10
+ // El canal global lleva el heartbeat de 15 s del server, asi que 30 s son tres
11
+ // pings: menos espera cuando lo que se pierde tiempo es que aparezca un permiso
12
+ // que te esta esperando.
13
+ export const CHANNEL_SILENCE_MS = 30000;
14
+
15
+ export let lastState = null;
16
+ export let reconciling = false;
17
+ export let reconcileTimer = null;
18
+ export let syncStale = false;
19
+
20
+ /**
21
+ * Un solo lugar decide que hacer con el estado remoto.
22
+ *
23
+ * Antes esto estaba repartido entre el canal global y un sweep: dos caminos que
24
+ * se contradijian, lo que hacia que un SSE caido se tradujera en "no me entero
25
+ * de nada". La logica de diff vive en `reconcile.js`, que es puro y testeado.
26
+ *
27
+ * La verdad es `/api/state`: un request corto. Los SSE son atajos de latencia.
28
+ */
29
+ export async function reconcile({ immediate = false } = {}) {
30
+ if (reconciling) {
31
+ // Si hay una en curso, encadenamos otra: si no, una mutacion que cae justo
32
+ // en medio se pierde.
33
+ if (immediate) state.reconcileAgain = true;
34
+ return;
35
+ }
36
+ reconciling = true;
37
+ try {
38
+ const remote = await api('/api/state');
39
+ syncStale = false;
40
+ renderSyncState();
41
+ applyState(diffState(localState({ panes: state.panes, asks: state.pendingAsks.keys() }), remote));
42
+ lastState = remote;
43
+ scheduleReconcile();
44
+ } catch (error) {
45
+ // Esto antes era un `.catch(() => null)` silencioso: un fallo de red se via
46
+ // exactamente igual que "no hay nada pendiente", y por eso no se podia
47
+ // diagnosticar nada.
48
+ syncStale = true;
49
+ renderSyncState(error);
50
+ scheduleReconcile();
51
+ } finally {
52
+ reconciling = false;
53
+ if (state.reconcileAgain) {
54
+ state.reconcileAgain = false;
55
+ reconcile();
56
+ }
57
+ }
58
+ }
59
+
60
+ export function scheduleReconcile() {
61
+ if (reconcileTimer) clearTimeout(reconcileTimer);
62
+ // Con un permiso pendiente hay algo esperandonos: la latencia se nota.
63
+ reconcileTimer = setTimeout(() => reconcile(), pollDelayMs(lastState));
64
+ }
65
+
66
+ export function applyState(diff) {
67
+ for (const id of diff.gone) closeTab(id);
68
+ for (const id of diff.toDetach) reloadPane(id);
69
+ for (const id of diff.toAttach) reloadPane(id);
70
+ for (const askId of diff.resolvedAsks) {
71
+ state.pendingAsks.delete(askId);
72
+ // `null` = alguien lo resolvio en otro lado: la tarjeta ya se cerro alla y
73
+ // solo hay que apagarla.
74
+ resolvePermissionCard(state.permissionCards.get(askId), null);
75
+ }
76
+ for (const ask of diff.newAsks) {
77
+ if (state.pendingAsks.has(ask.askId)) continue;
78
+ state.pendingAsks.set(ask.askId, ask);
79
+ toast(`Permiso pendiente: ${ask.name || 'una tool'}${state.panes.has(ask.sessionId) ? '' : ' en otra sesión'}.`);
80
+ }
81
+ // Los permisos pendientes se pintan siempre, sin importar por donde llegaron:
82
+ // asi tambien se ven cuando el canal estaba caido y llego por el snapshot.
83
+ for (const [askId, ask] of state.pendingAsks) {
84
+ const record = state.panes.get(ask.sessionId);
85
+ if (record && !livePermission(askId)) showStandalonePermission(record, ask);
86
+ }
87
+ state.remoteActive = new Set(diff.remoteActive);
88
+ renderSessions(diff.sessions);
89
+ renderTabs();
90
+ renderAlertSignals();
91
+ }
92
+
93
+ /* --- señales de alerta: que se note aun mirando otra cosa ----------------- */
94
+
95
+ export const FAVICON_IDLE = '/favicon.ico';
96
+ export const FAVICON_SOURCE = '/icons/icon-192x192.png';
97
+ export let faviconAlert = '';
98
+ export let faviconAlertPending = null;
99
+
100
+ /**
101
+ * El favicon de alerta se dibuja sobre una copia del logo (mismo origen), para
102
+ * que el punto rojo combine con el icono real y no con un SVG desincronizado.
103
+ */
104
+ export function alertFavicon() {
105
+ if (faviconAlert) return Promise.resolve(faviconAlert);
106
+ if (faviconAlertPending) return faviconAlertPending;
107
+ faviconAlertPending = new Promise((resolve) => {
108
+ const img = new Image();
109
+ img.onload = () => {
110
+ try {
111
+ const canvas = document.createElement('canvas');
112
+ canvas.width = 64;
113
+ canvas.height = 64;
114
+ const ctx = canvas.getContext('2d');
115
+ ctx.drawImage(img, 0, 0, 64, 64);
116
+ ctx.beginPath();
117
+ ctx.arc(64 - 16, 16, 13, 0, Math.PI * 2);
118
+ ctx.fillStyle = '#d9704a';
119
+ ctx.fill();
120
+ ctx.lineWidth = 3;
121
+ ctx.strokeStyle = 'rgba(35,26,23,0.9)';
122
+ ctx.stroke();
123
+ faviconAlert = canvas.toDataURL('image/png');
124
+ resolve(faviconAlert);
125
+ } catch {
126
+ resolve(FAVICON_IDLE);
127
+ }
128
+ };
129
+ img.onerror = () => resolve(FAVICON_IDLE);
130
+ img.src = FAVICON_SOURCE;
131
+ });
132
+ return faviconAlertPending;
133
+ }
134
+
135
+ export let baseTitle = '';
136
+
137
+ /**
138
+ * Cuando iOS manda la pestana a background mata los timers y los SSE. El
139
+ * titulo y el favicon son las **unicas** señales que quedan, asi que los dos
140
+ * tienen que marcar que hay un permiso esperando.
141
+ */
142
+ export function renderAlertSignals() {
143
+ const pending = state.pendingAsks.size + state.pendingQuestions.size;
144
+ if (!baseTitle) baseTitle = document.title;
145
+ const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
146
+ document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
147
+ const link = document.querySelector('link[rel="icon"]');
148
+ if (link) {
149
+ if (pending > 0) alertFavicon().then((href) => { link.href = href; });
150
+ else link.href = FAVICON_IDLE;
151
+ }
152
+ }
153
+
154
+ /* --- canal global: solo latencia ------------------------------------------ */
155
+
156
+ /**
157
+ * Un SSE por pestana para enterarse al instante. **No decide nada**: dispara
158
+ * `reconcile()`. Si se cae -que es lo normal en un tunel, y lo que hace iOS al
159
+ * mandar la pestana a background- solo perdemos inmediatez, no correctitud.
160
+ */
161
+ export function handleGlobalEvent(event) {
162
+ // No decide nada: solo pide el estado fresco. El diff es el unico que sabe
163
+ // que hay que hacer.
164
+ if (event === 'run_start' || event === 'run_end' || event === 'permission' || event === 'permission_resolved' || event === 'snapshot') {
165
+ reconcile({ immediate: true });
166
+ }
167
+ }
168
+
169
+ export let eventsController = null;
170
+ export let channelState = 'off';
171
+ export let channelReason = '';
172
+
173
+ export function renderChannelState() {
174
+ const node = $('#sb-channel');
175
+ if (!node) return;
176
+ if (channelState === 'live') {
177
+ node.hidden = true;
178
+ return;
179
+ }
180
+ node.hidden = false;
181
+ node.className = `sb-channel ${channelState}`;
182
+ node.textContent = channelState === 'off' ? 'sin canal' : 'reconectando…';
183
+ node.title = channelReason
184
+ ? `Canal de eventos caido (${channelReason}). Los cambios llegan igual por sondeo cada ${Math.round(pollDelayMs(lastState) / 1000)} s, pero con retardo.`
185
+ : 'Canal de eventos: sin novedades instantaneas.';
186
+ }
187
+
188
+ /** El sondeo de /api/state tambien puede caerse; eso si hay que verlo. */
189
+ export function renderSyncState(error) {
190
+ const node = $('#sb-sync');
191
+ if (!node) return;
192
+ node.hidden = !syncStale;
193
+ node.className = 'sb-channel retrying';
194
+ node.textContent = 'sin sincronía';
195
+ node.title = error
196
+ ? `No pude leer el estado del server: ${error.message}. Reintentando cada ${Math.round(pollDelayMs(lastState) / 1000)} s.`
197
+ : 'No pude leer el estado del server. Reintentando.';
198
+ }
199
+
200
+ export async function followEvents() {
201
+ if (eventsController && !eventsController.signal.aborted) return;
202
+ const controller = new AbortController();
203
+ eventsController = controller;
204
+ channelState = 'waiting';
205
+ channelReason = '';
206
+ renderChannelState();
207
+ await followRun({
208
+ signal: controller.signal,
209
+ attempts: Infinity,
210
+ backoff: [1000, 2000, 5000, 10000, 15000],
211
+ onRetry: ({ attempt, error }) => {
212
+ channelState = 'retrying';
213
+ channelReason = error?.message ? `${error.message} (intento ${attempt})` : `sin datos (intento ${attempt})`;
214
+ renderChannelState();
215
+ },
216
+ open: (cursor, signal) =>
217
+ openStream('/api/events', { signal, silenceMs: CHANNEL_SILENCE_MS }, (event, data) => {
218
+ channelState = 'live';
219
+ channelReason = '';
220
+ renderChannelState();
221
+ handleGlobalEvent(event, data);
222
+ }),
223
+ })
224
+ .catch((error) => {
225
+ // Un AbortError es que estamos cerrando. Cualquier otra cosa es un bug de
226
+ // la consola: se avisa en vez de dejar un "reconectando" que no dice nada.
227
+ if (error?.name === 'AbortError') return;
228
+ channelState = 'off';
229
+ channelReason = error?.message ?? String(error);
230
+ renderChannelState();
231
+ toast(`Canal de eventos roto: ${error?.message ?? error}`);
232
+ })
233
+ .finally(() => {
234
+ if (eventsController === controller) {
235
+ channelState = 'off';
236
+ renderChannelState();
237
+ }
238
+ });
239
+ }
240
+
241
+ /* --- ciclo de vida --------------------------------------------------------- */
242
+
243
+ export function watchLifecycle() {
244
+ // Volver a `visible` es el momento de reconciliar: iOS congela los timers en
245
+ // background, asi que esperar al proximo tick puede ser Mucho tiempo.
246
+ const wake = () => {
247
+ reconcile({ immediate: true });
248
+ sendPresence();
249
+ if (!eventsController || eventsController.signal.aborted) followEvents();
250
+ };
251
+ document.addEventListener('visibilitychange', () => {
252
+ if (document.visibilityState === 'visible') wake();
253
+ });
254
+ window.addEventListener('pageshow', wake);
255
+ window.addEventListener('online', wake);
256
+ window.addEventListener('focus', wake);
257
+ // La cache del server dura 10 min: revisar al volver a la pestaña es barato.
258
+ refreshUpdate();
259
+ }
260
+
261
+ /* --- actualizacion de version --------------------------------------------- */
262
+
263
+ export const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
264
+ export const updateBanner = $('#update-banner');
265
+ export const updateBannerText = $('#update-banner-text');
266
+ export let updateInfo = null;
267
+
268
+ export function showUpdateBanner(info, { force = false } = {}) {
269
+ if (!updateBanner || !info?.updateAvailable) return;
270
+ if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
271
+ updateInfo = info;
272
+ updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
273
+ updateBanner.hidden = false;
274
+ const ver = document.querySelector('.brand .ver');
275
+ if (ver) {
276
+ ver.classList.add('has-update');
277
+ ver.title = `Actualización disponible: v${info.latest}`;
278
+ }
279
+ }
280
+
281
+ export async function refreshUpdate() {
282
+ if (!updateBanner) return;
283
+ const info = await api('/api/update').catch(() => null);
284
+ if (info?.updateAvailable) showUpdateBanner(info);
285
+ }
286
+
287
+ export function dismissUpdate() {
288
+ if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
289
+ if (updateBanner) updateBanner.hidden = true;
290
+ }
291
+
292
+ /** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
293
+ export async function forceUpdateCheck() {
294
+ const ver = document.querySelector('.brand .ver');
295
+ const original = ver?.textContent;
296
+ if (ver) ver.textContent = '…';
297
+ const info = await api('/api/update?force=1').catch(() => null);
298
+ if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
299
+ if (!info) return toast('No pude consultar la versión.');
300
+ if (info.updateAvailable) {
301
+ showUpdateBanner(info, { force: true });
302
+ toast(`Hay una versión nueva: v${info.latest}`);
303
+ } else if (info.error) {
304
+ toast(`No pude consultar npm: ${info.error}`);
305
+ } else {
306
+ toast(`Estás al día (v${info.current}).`);
307
+ }
308
+ }
309
+
310
+ /** Espera a que el server reiniciado vuelva a responder `/api/health`. */
311
+ export async function waitServerVersion(tries = 45) {
312
+ for (let i = 0; i < tries; i++) {
313
+ await new Promise((r) => setTimeout(r, 2000));
314
+ const health = await fetch('/api/health', { cache: 'no-store' })
315
+ .then((r) => (r.ok ? r.json() : null))
316
+ .catch(() => null);
317
+ if (health?.version) return health.version;
318
+ }
319
+ return '';
320
+ }
321
+
322
+ export async function runUpdate() {
323
+ if (!updateInfo) return;
324
+ if (
325
+ !(await showConfirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`, {
326
+ title: 'Actualizar Tamnora',
327
+ confirmLabel: 'actualizar',
328
+ }))
329
+ ) {
330
+ return;
331
+ }
332
+ const runBtn = $('#update-run');
333
+ const laterBtn = $('#update-later');
334
+ if (runBtn) runBtn.disabled = true;
335
+ if (laterBtn) laterBtn.disabled = true;
336
+ updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
337
+ const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
338
+ if (res?.error) {
339
+ updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
340
+ if (runBtn) runBtn.disabled = false;
341
+ if (laterBtn) laterBtn.disabled = false;
342
+ return;
343
+ }
344
+ updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
345
+ const version = await waitServerVersion();
346
+ if (version) {
347
+ updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
348
+ setTimeout(() => location.reload(), 900);
349
+ } else {
350
+ updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
351
+ if (runBtn) runBtn.disabled = false;
352
+ if (laterBtn) laterBtn.disabled = false;
353
+ }
354
+ }
355
+
356
+ export const brandVerEl = document.querySelector('.brand .ver');