@danieltmn/tamnora 0.24.2 → 0.25.1

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 (83) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/harness/packages/cli/src/commands/repl.js +18 -2
  3. package/harness/packages/cli/src/commands/run.js +5 -1
  4. package/harness/packages/cli/src/commands/tui.js +5 -1
  5. package/harness/packages/cli/src/lib/runtime.js +2 -0
  6. package/harness/packages/cli/src/tui/app.js +22 -2
  7. package/harness/packages/cli/src/tui/layout.js +1 -1
  8. package/harness/packages/core/src/agent/runAgent.js +5 -2
  9. package/harness/packages/core/src/agents/index.js +141 -0
  10. package/harness/packages/core/src/config/index.js +1 -0
  11. package/harness/packages/core/src/index.js +10 -0
  12. package/harness/packages/core/src/providers/index.js +3 -1
  13. package/harness/packages/core/src/providers/ollama.js +4 -1
  14. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  15. package/harness/packages/core/src/providers/openrouter.js +5 -1
  16. package/harness/packages/core/src/providers/presets.js +4 -3
  17. package/harness/packages/core/src/providers/reasoning.js +58 -0
  18. package/harness/packages/core/test/agents.test.js +111 -0
  19. package/harness/packages/core/test/reasoning.test.js +43 -0
  20. package/harness/packages/server/public/app.js +868 -834
  21. package/harness/packages/server/public/appearance.js +4 -0
  22. package/harness/packages/server/public/boot.js +49 -0
  23. package/harness/packages/server/public/chat.js +1 -0
  24. package/harness/packages/server/public/chrome.js +946 -852
  25. package/harness/packages/server/public/commands.js +448 -405
  26. package/harness/packages/server/public/core.js +229 -216
  27. package/harness/packages/server/public/favicon.ico +0 -0
  28. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  29. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  30. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  31. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  32. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  33. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  34. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  35. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  36. package/harness/packages/server/public/icons/logo.png +0 -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/icons/tamnora.ico +0 -0
  40. package/harness/packages/server/public/icons (1).zip +0 -0
  41. package/harness/packages/server/public/index.html +161 -101
  42. package/harness/packages/server/public/landing/boot.js +16 -0
  43. package/harness/packages/server/public/landing/index.html +123 -59
  44. package/harness/packages/server/public/landing/landing.js +11 -9
  45. package/harness/packages/server/public/logo.png +0 -0
  46. package/harness/packages/server/public/markdown.js +1 -1
  47. package/harness/packages/server/public/sessions.js +1 -0
  48. package/harness/packages/server/public/settings.js +1322 -1080
  49. package/harness/packages/server/public/settingsNav.js +82 -0
  50. package/harness/packages/server/public/styles.css +2807 -2525
  51. package/harness/packages/server/public/system.js +2 -2
  52. package/harness/packages/server/src/lib/agents.js +74 -0
  53. package/harness/packages/server/src/lib/appearance.js +4 -0
  54. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  55. package/harness/packages/server/src/lib/prompt.js +11 -3
  56. package/harness/packages/server/src/lib/restart.js +20 -0
  57. package/harness/packages/server/src/lib/scheduler.js +4 -0
  58. package/harness/packages/server/src/lib/securityHeaders.js +53 -0
  59. package/harness/packages/server/src/lib/sessionView.js +1 -0
  60. package/harness/packages/server/src/lib/skills.js +13 -0
  61. package/harness/packages/server/src/lib/tasks.js +71 -23
  62. package/harness/packages/server/src/routes/agents.js +88 -0
  63. package/harness/packages/server/src/routes/chat.js +9 -1
  64. package/harness/packages/server/src/routes/providers.js +7 -0
  65. package/harness/packages/server/src/routes/reload.js +46 -0
  66. package/harness/packages/server/src/routes/sessions.js +3 -1
  67. package/harness/packages/server/src/server.js +13 -1
  68. package/harness/packages/server/test/accessibility.test.js +72 -0
  69. package/harness/packages/server/test/agents.test.js +194 -0
  70. package/harness/packages/server/test/appearance.test.js +4 -0
  71. package/harness/packages/server/test/consoleModules.test.js +133 -57
  72. package/harness/packages/server/test/consoleTabs.test.js +228 -0
  73. package/harness/packages/server/test/docsSync.test.js +73 -0
  74. package/harness/packages/server/test/landingLogin.test.js +89 -0
  75. package/harness/packages/server/test/markdownSecurity.test.js +80 -0
  76. package/harness/packages/server/test/reload.test.js +95 -0
  77. package/harness/packages/server/test/securityHeaders.test.js +109 -0
  78. package/harness/packages/server/test/settingsNav.test.js +90 -0
  79. package/harness/packages/server/test/themeCss.js +110 -0
  80. package/harness/packages/server/test/themes.test.js +72 -0
  81. package/harness/scripts/restart.mjs +101 -0
  82. package/harness/tamnora.example.json +1 -0
  83. package/package.json +1 -1
@@ -1,838 +1,872 @@
1
- // Tamnora console — dependency-free client for the harness server (terminal theme).
2
-
3
- import {
4
- activePane,
5
- addImageFiles,
6
- attachInput,
7
- chatForm,
8
- chatInput,
9
- closeMessageMenus,
10
- controllers,
11
- interruptBtn,
12
- openMessageMenu,
13
- panesEl,
14
- positionMessageMenu,
15
- renderFooterActivity,
16
- scheduleScrollNav,
17
- scrollDownBtn,
18
- scrollTopBtn,
19
- sendInterrupt,
20
- sendMessage,
21
- sendPresence,
22
- stopBtn,
23
- } from './chat.js';
24
- import {
25
- activateTab,
26
- agentControlsSlot,
27
- buildAppearanceControls,
28
- closeCreateFolder,
29
- closeFolderPicker,
30
- closeModeMenu,
31
- closeSettingsModal,
32
- createFolder,
33
- cwdPill,
34
- cycleTheme,
35
- filterFolders,
36
- folderGoto,
37
- folderList,
38
- folderModal,
39
- folderNewName,
40
- folderParent,
41
- gotoFolder,
42
- hideMoreMenu,
43
- initLayout,
44
- loadFolders,
45
- loadModels,
46
- loadModes,
47
- logout,
48
- modeMenu,
49
- modePill,
50
- modeSelect,
51
- modelPill,
52
- modelSelect,
53
- moreMenu,
54
- moreTab,
55
- moveAgentControls,
56
- openCreateFolder,
57
- openFolderPicker,
58
- openSettingsModal,
59
- persistAgent,
60
- providerSelect,
61
- renderModePill,
62
- setCwd,
63
- setMode,
64
- settingsModal,
65
- statusbarEl,
66
- strictInput,
67
- syncDefaultCwd,
68
- themeToggle,
69
- toggleMode,
70
- useFolder,
71
- writeDefaultCwd,
72
- yesInput,
73
- } from './chrome.js';
74
- import {
75
- handleSlashKey,
76
- hideSlashMenu,
77
- onChatInputChanged,
78
- openPalette,
79
- palette,
80
- renderPalette,
81
- runPaletteItem,
82
- setPaletteIndex,
83
- toast,
84
- } from './commands.js';
85
- import { $, api, setCsrfToken, state, storage } from './core.js';
86
- import {
87
- PREVIEW_DEVICE_KEY,
88
- PREVIEW_URL_KEY,
89
- applyTreeFilter,
90
- detectProcs,
91
- explorerFilter,
92
- explorerMode,
93
- filePreviewState,
94
- filePreviewTools,
95
- gitEntries,
96
- gitFilterEl,
97
- gitModalEl,
98
- loadPreview,
99
- openRemoteTunnel,
100
- previewFrame,
101
- previewTools,
102
- previewUrlInput,
103
- refreshExplorerMode,
104
- renderGitList,
105
- setExplorerMode,
106
- setExplorerOpen,
107
- setExplorerStatus,
108
- setPreviewStatus,
109
- setTunnelUi,
110
- syncPreviewTunnel,
111
- } from './explorer.js';
1
+ // Tamnora console — dependency-free client for the harness server (terminal theme).
2
+
3
+ import {
4
+ activePane,
5
+ addImageFiles,
6
+ attachInput,
7
+ chatForm,
8
+ chatInput,
9
+ closeMessageMenus,
10
+ controllers,
11
+ interruptBtn,
12
+ openMessageMenu,
13
+ panesEl,
14
+ positionMessageMenu,
15
+ renderFooterActivity,
16
+ scheduleScrollNav,
17
+ scrollDownBtn,
18
+ scrollTopBtn,
19
+ sendInterrupt,
20
+ sendMessage,
21
+ sendPresence,
22
+ stopBtn,
23
+ } from './chat.js';
24
+ import {
25
+ activateTab,
26
+ agentControlsSlot,
27
+ buildAppearanceControls,
28
+ closeCreateFolder,
29
+ closeEffortMenu,
30
+ closeFolderPicker,
31
+ closeModeMenu,
32
+ closeSettingsModal,
33
+ createFolder,
34
+ cwdPill,
35
+ cycleTheme,
36
+ effortMenu,
37
+ effortPill,
38
+ effortSelect,
39
+ filterFolders,
40
+ folderGoto,
41
+ folderList,
42
+ folderModal,
43
+ folderNewName,
44
+ folderParent,
45
+ gotoFolder,
46
+ hideMoreMenu,
47
+ initLayout,
48
+ loadFolders,
49
+ loadModels,
50
+ loadModes,
51
+ logout,
52
+ modeMenu,
53
+ modePill,
54
+ modeSelect,
55
+ modelPill,
56
+ modelSelect,
57
+ moreMenu,
58
+ moreTab,
59
+ moveAgentControls,
60
+ openCreateFolder,
61
+ openFolderPicker,
62
+ openSettingsModal,
63
+ persistAgent,
64
+ providerSelect,
65
+ renderModePill,
66
+ setCwd,
67
+ setEffort,
68
+ setMode,
69
+ settingsModal,
70
+ statusbarEl,
71
+ strictInput,
72
+ syncDefaultCwd,
73
+ themeToggle,
74
+ toggleMode,
75
+ useFolder,
76
+ writeDefaultCwd,
77
+ yesInput,
78
+ } from './chrome.js';
79
+ import {
80
+ handleSlashKey,
81
+ hideSlashMenu,
82
+ onChatInputChanged,
83
+ openPalette,
84
+ palette,
85
+ renderPalette,
86
+ runPaletteItem,
87
+ setPaletteIndex,
88
+ toast,
89
+ } from './commands.js';
90
+ import { $, api, scrollBehavior, setCsrfToken, state, storage } from './core.js';
91
+ import {
92
+ PREVIEW_DEVICE_KEY,
93
+ PREVIEW_URL_KEY,
94
+ applyTreeFilter,
95
+ detectProcs,
96
+ explorerFilter,
97
+ explorerMode,
98
+ filePreviewState,
99
+ filePreviewTools,
100
+ gitEntries,
101
+ gitFilterEl,
102
+ gitModalEl,
103
+ loadPreview,
104
+ openRemoteTunnel,
105
+ previewFrame,
106
+ previewTools,
107
+ previewUrlInput,
108
+ refreshExplorerMode,
109
+ renderGitList,
110
+ setExplorerMode,
111
+ setExplorerOpen,
112
+ setExplorerStatus,
113
+ setPreviewStatus,
114
+ setTunnelUi,
115
+ syncPreviewTunnel,
116
+ } from './explorer.js';
112
117
  import { renderMarkdown } from './markdown.js';
113
- import { wireModelPicker } from './models.js';
114
- import { restoreTabs, startNewSession } from './sessions.js';
115
- import {
116
- activateAjustesPanel,
117
- addPerfilFieldRow,
118
- ajustesTabs,
119
- closeMcpForm,
120
- closeMemoryForm,
121
- closeProviderForm,
122
- closeScheduleForm,
123
- closeSkillForm,
124
- importMcp,
125
- importSkill,
126
- limitsCachingEl,
127
- limitsDurationEl,
128
- limitsOutputEl,
129
- limitsRoundsEl,
130
- limitsTokensEl,
131
- mcpForm,
132
- mcpListEl,
133
- mcpModal,
134
- mcpModeEl,
135
- mcpPinnedEl,
136
- memoryForm,
137
- memoryModal,
138
- openMcpForm,
139
- openMemoryForm,
140
- openProviderForm,
141
- openScheduleForm,
142
- openSkillForm,
143
- perfil,
144
- providerForm,
145
- providerModal,
146
- refreshLimits,
147
- refreshMcp,
148
- refreshMcpCatalog,
149
- refreshProviders,
150
- refreshSkillCatalog,
151
- saveMemoryIndex,
152
- scheduleForm,
153
- scheduleModal,
154
- scheduleTypeFields,
155
- searchMcp,
156
- searchSkills,
157
- setStatus,
158
- skillForm,
159
- skillModal,
160
- submitAsesor,
161
- submitChannel,
162
- submitMcp,
163
- submitMemory,
164
- submitProfile,
165
- submitProvider,
166
- submitSchedule,
167
- submitSkill,
168
- syncMcpFormFields,
169
- testAsesor,
170
- testChannel,
171
- testProvider,
172
- vozPitchEl,
173
- vozRateEl,
174
- } from './settings.js';
175
- import {
176
- brandVerEl,
177
- dismissUpdate,
178
- evalRunBtn,
179
- evalTasks,
180
- followEvents,
181
- forceUpdateCheck,
182
- reconcile,
183
- refreshRuns,
184
- refreshUpdate,
185
- renderAlertSignals,
186
- runEval,
187
- runUpdate,
188
- watchLifecycle,
189
- } from './system.js';
190
- import {
191
- handsFreeBtn,
192
- micBtn,
193
- refreshTranscribe,
194
- refreshVoz,
195
- renderVozSliders,
196
- submitTranscribe,
197
- submitVoz,
198
- syncTranscribeMode,
199
- testVoz,
200
- toggleHandsFree,
201
- toggleMic,
202
- toggleTrTest,
203
- trModeEl,
204
- trTestBtn,
205
- } from './voice.js';
206
-
207
- moreTab.addEventListener('click', (event) => {
208
- event.stopPropagation();
209
- const open = moreMenu.hidden;
210
- moreMenu.hidden = !open;
211
- moreTab.setAttribute('aria-expanded', String(open));
212
- });
213
- document.querySelectorAll('#more-menu button').forEach((item) =>
214
- item.addEventListener('click', () => activateTab(item.dataset.tab))
215
- );
216
- document.addEventListener('click', (event) => {
217
- if (!moreMenu.hidden && !event.target.closest('.menu')) hideMoreMenu();
218
- });
219
- document.addEventListener('keydown', (event) => {
220
- if (event.key === 'Escape' && !moreMenu.hidden) hideMoreMenu();
221
- });
222
-
118
+ import { wireModelPicker } from './models.js';
119
+ import { restoreTabs, startNewSession } from './sessions.js';
120
+ import {
121
+ addPerfilFieldRow,
122
+ agentForm,
123
+ agentModal,
124
+ closeAgentForm,
125
+ closeMcpForm,
126
+ closeMemoryForm,
127
+ closeProviderForm,
128
+ closeScheduleForm,
129
+ closeSkillForm,
130
+ importMcp,
131
+ importSkill,
132
+ limitsCachingEl,
133
+ limitsDurationEl,
134
+ limitsOutputEl,
135
+ limitsRoundsEl,
136
+ limitsTokensEl,
137
+ mcpForm,
138
+ mcpListEl,
139
+ mcpModal,
140
+ mcpModeEl,
141
+ mcpPinnedEl,
142
+ memoryForm,
143
+ memoryModal,
144
+ openAgentForm,
145
+ openMcpForm,
146
+ openMemoryForm,
147
+ openProviderForm,
148
+ openScheduleForm,
149
+ openSkillForm,
150
+ perfil,
151
+ providerForm,
152
+ providerModal,
153
+ refreshLimits,
154
+ refreshMcp,
155
+ refreshMcpCatalog,
156
+ refreshProviders,
157
+ refreshSkillCatalog,
158
+ saveMemoryIndex,
159
+ scheduleForm,
160
+ scheduleModal,
161
+ scheduleTypeFields,
162
+ searchMcp,
163
+ searchSkills,
164
+ setStatus,
165
+ skillForm,
166
+ skillModal,
167
+ submitAgent,
168
+ submitAsesor,
169
+ submitChannel,
170
+ submitMcp,
171
+ submitMemory,
172
+ submitProfile,
173
+ submitProvider,
174
+ submitSchedule,
175
+ submitSkill,
176
+ syncMcpFormFields,
177
+ testAsesor,
178
+ testChannel,
179
+ testProvider,
180
+ vozPitchEl,
181
+ vozRateEl,
182
+ wireAjustesNav,
183
+ } from './settings.js';
184
+ import {
185
+ brandVerEl,
186
+ dismissUpdate,
187
+ evalRunBtn,
188
+ evalTasks,
189
+ followEvents,
190
+ forceUpdateCheck,
191
+ reconcile,
192
+ refreshRuns,
193
+ refreshUpdate,
194
+ renderAlertSignals,
195
+ runEval,
196
+ runUpdate,
197
+ watchLifecycle,
198
+ } from './system.js';
199
+ import {
200
+ handsFreeBtn,
201
+ micBtn,
202
+ refreshTranscribe,
203
+ refreshVoz,
204
+ renderVozSliders,
205
+ submitTranscribe,
206
+ submitVoz,
207
+ syncTranscribeMode,
208
+ testVoz,
209
+ toggleHandsFree,
210
+ toggleMic,
211
+ toggleTrTest,
212
+ trModeEl,
213
+ trTestBtn,
214
+ } from './voice.js';
215
+
216
+ moreTab.addEventListener('click', (event) => {
217
+ event.stopPropagation();
218
+ const open = moreMenu.hidden;
219
+ moreMenu.hidden = !open;
220
+ moreTab.setAttribute('aria-expanded', String(open));
221
+ });
222
+ document.querySelectorAll('#more-menu button').forEach((item) =>
223
+ item.addEventListener('click', () => activateTab(item.dataset.tab))
224
+ );
225
+ document.addEventListener('click', (event) => {
226
+ if (!moreMenu.hidden && !event.target.closest('.menu')) hideMoreMenu();
227
+ });
228
+ document.addEventListener('keydown', (event) => {
229
+ if (event.key === 'Escape' && !moreMenu.hidden) hideMoreMenu();
230
+ });
231
+
223
232
  $('#back-chat')?.addEventListener('click', () => activateTab('chat'));
224
233
 
225
- if (settingsModal) {
226
- $('#settings-toggle')?.addEventListener('click', openSettingsModal);
227
- $('#settings-close')?.addEventListener('click', closeSettingsModal);
228
- $('#settings-done')?.addEventListener('click', closeSettingsModal);
229
- $('#settings-providers')?.addEventListener('click', () => {
230
- closeSettingsModal();
231
- activateTab('providers');
232
- });
233
- $('#agent-settings-open')?.addEventListener('click', openSettingsModal);
234
- $('#agent-providers')?.addEventListener('click', () => activateTab('providers'));
235
- $('#default-cwd-pick')?.addEventListener('click', () => openFolderPicker('default'));
236
- $('#default-cwd-clear')?.addEventListener('click', () => {
237
- writeDefaultCwd('');
238
- syncDefaultCwd();
239
- });
240
- settingsModal.addEventListener('click', (event) => {
241
- if (event.target === settingsModal) closeSettingsModal();
242
- });
243
- settingsModal.addEventListener('close', () => moveAgentControls(agentControlsSlot));
244
- }
245
-
246
- if (statusbarEl) {
247
- statusbarEl.addEventListener('click', (event) => {
248
- if (event.target.closest('#sb-context')) return;
249
- openSettingsModal();
250
- });
251
- statusbarEl.addEventListener('keydown', (event) => {
252
- if (event.key === 'Enter' || event.key === ' ') {
253
- event.preventDefault();
254
- openSettingsModal();
255
- }
256
- });
257
- }
258
-
259
- if (palette.modal) {
260
- palette.input.addEventListener('input', () => {
261
- const q = palette.input.value;
262
- renderPalette(q);
263
- clearTimeout(palette.timer);
264
- palette.timer = setTimeout(async () => {
265
- if (q.trim().length < 2) {
266
- palette.results = { sessions: [], messages: [] };
267
- renderPalette(q);
268
- return;
269
- }
270
- const data = await api(`/api/search?q=${encodeURIComponent(q)}&limit=15`).catch(() => null);
271
- if (data) {
272
- palette.results = { sessions: data.sessions || [], messages: data.messages || [] };
273
- renderPalette(q);
274
- }
275
- }, 180);
276
- });
277
- palette.input.addEventListener('keydown', (event) => {
278
- if (event.key === 'ArrowDown') {
279
- event.preventDefault();
280
- setPaletteIndex(palette.index + 1);
281
- } else if (event.key === 'ArrowUp') {
282
- event.preventDefault();
283
- setPaletteIndex(palette.index - 1);
284
- } else if (event.key === 'Enter') {
285
- event.preventDefault();
286
- const item = palette.items[palette.index];
287
- if (item) runPaletteItem(item);
288
- }
289
- });
290
- palette.modal.addEventListener('click', (event) => {
291
- if (event.target === palette.modal) palette.modal.close();
292
- });
293
- $('#search-toggle')?.addEventListener('click', openPalette);
294
- document.addEventListener('keydown', (event) => {
295
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
296
- event.preventDefault();
297
- openPalette();
298
- }
299
- });
300
- }
301
-
302
- modeSelect.addEventListener('change', () => setMode(modeSelect.value));
303
-
304
- document.addEventListener('keydown', (event) => {
305
- if ((event.ctrlKey || event.metaKey) && event.key === '.') {
306
- event.preventDefault();
307
- toggleMode();
308
- }
309
- });
310
-
311
- cwdPill?.addEventListener('click', () => openFolderPicker('session'));
312
- modePill?.addEventListener('click', (event) => {
313
- event.stopPropagation();
314
- if (!modeMenu) return;
315
- modeMenu.hidden = !modeMenu.hidden;
316
- modePill.setAttribute('aria-expanded', modeMenu.hidden ? 'false' : 'true');
317
- });
318
- modeMenu?.addEventListener('click', async (event) => {
319
- const item = event.target.closest('.ctrl-menu-item');
320
- if (!item) return;
321
- const kind = item.dataset.kind;
322
- closeModeMenu();
323
- if (kind === 'full') {
324
- await setMode('agent');
325
- if (yesInput) yesInput.checked = true;
326
- } else {
327
- if (yesInput) yesInput.checked = false;
328
- await setMode(kind === 'read' ? 'plan' : kind === 'voice' ? 'voice' : 'agent');
329
- }
330
- renderModePill();
331
- });
332
- document.addEventListener('click', (event) => {
333
- if (modeMenu && !modeMenu.hidden && !event.target.closest('.ctrl-wrap')) closeModeMenu();
334
- });
335
- document.addEventListener('keydown', (event) => {
336
- if (event.key === 'Escape') closeModeMenu();
337
- });
338
- modelPill?.addEventListener('click', () => openSettingsModal());
339
- yesInput?.addEventListener('change', renderModePill);
340
- strictInput?.addEventListener('change', () =>
341
- api('/api/settings', { method: 'PUT', body: { strictScope: strictInput.checked } }).catch(() => null)
342
- );
343
-
344
- themeToggle?.addEventListener('click', cycleTheme);
345
-
346
- buildAppearanceControls();
347
-
348
- if (logout) {
349
- logout.addEventListener('click', async (event) => {
350
- event.preventDefault();
351
- try {
352
- await api('/api/auth/logout', { method: 'POST' });
353
- } catch {
354
- // igual salimos
355
- }
356
- location.href = '/landing';
357
- });
358
- }
359
-
360
- initLayout();
361
-
362
- providerSelect.addEventListener('change', async () => {
363
- await loadModels({ save: false });
364
- await persistAgent({ provider: providerSelect.value, model: modelSelect.value || undefined });
365
- });
366
- modelSelect.addEventListener('change', () => persistAgent({ model: modelSelect.value }));
367
-
368
- export let restorePreviewMax = null;
369
-
370
- scrollTopBtn.addEventListener('click', () => activePane?.scrollTo({ top: 0, behavior: 'smooth' }));
371
- scrollDownBtn.addEventListener('click', () =>
372
- activePane?.scrollTo({ top: activePane.scrollHeight, behavior: 'smooth' })
373
- );
374
- // `scroll` no burbujea: se escucha en captura desde el contenedor de los panes.
375
- panesEl.addEventListener('scroll', scheduleScrollNav, true);
376
- window.addEventListener('resize', scheduleScrollNav);
377
- new MutationObserver(scheduleScrollNav).observe(panesEl, {
378
- childList: true,
379
- characterData: true,
380
- subtree: true,
381
- });
382
-
383
- document.addEventListener('click', (event) => {
384
- if (!openMessageMenu) return;
385
- if (event.target.closest('.msg-menu') || event.target.closest('.msg-menu-btn')) return;
386
- closeMessageMenus();
387
- });
388
- document.addEventListener('keydown', (event) => {
389
- if (event.key === 'Escape') closeMessageMenus();
390
- });
391
- window.addEventListener('resize', positionMessageMenu);
392
- document.addEventListener('scroll', positionMessageMenu, true);
393
-
394
- panesEl.addEventListener('click', (event) => {
395
- const button = event.target.closest('.code-copy');
396
- if (!button) return;
397
- const pre = button.closest('.code-block')?.querySelector('pre');
398
- if (!pre || !navigator.clipboard) return;
399
- navigator.clipboard.writeText(pre.textContent).then(() => {
400
- button.textContent = 'copiado';
401
- setTimeout(() => (button.textContent = 'copiar'), 1200);
402
- });
403
- });
404
-
405
- /** Tool -> momento en que se aprobo por ultima vez. */
406
- state.recentDecisions = new Map();
407
-
408
- $('#new-session').addEventListener('click', startNewSession);
409
- $('#new-session-top').addEventListener('click', startNewSession);
410
-
411
- if (stopBtn) {
412
- stopBtn.addEventListener('click', async () => {
413
- const id = state.sessionId;
414
- if (!id || stopBtn.disabled) return;
415
- stopBtn.disabled = true;
416
- renderFooterActivity('stopping');
417
- // Abortamos el stream local (corta el seguimiento ya) y pedimos al server que
418
- // aborte el run: sin esto el run es durable y seguiria generando.
419
- controllers.get(id)?.abort();
420
- try {
421
- await api(`/api/sessions/${id}/stop`, { method: 'POST' });
422
- } catch (error) {
423
- toast(`No se pudo detener el run: ${error.message}`);
424
- } finally {
425
- stopBtn.disabled = false;
426
- }
427
- });
428
- }
429
-
430
- if (interruptBtn) interruptBtn.addEventListener('click', sendInterrupt);
431
-
432
- chatForm.addEventListener('submit', (event) => {
433
- event.preventDefault();
434
- sendMessage();
435
- });
436
- chatInput.addEventListener('keydown', (event) => {
437
- if (handleSlashKey(event)) return;
438
- if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
439
- event.preventDefault();
440
- sendInterrupt();
441
- return;
442
- }
443
- if (event.key === 'Enter' && !event.shiftKey) {
444
- event.preventDefault();
445
- sendMessage();
446
- }
447
- });
448
- chatInput.addEventListener('input', onChatInputChanged);
449
-
450
- micBtn?.addEventListener('click', toggleMic);
451
-
452
- handsFreeBtn?.addEventListener('click', toggleHandsFree);
453
-
454
- $('#attach-image').addEventListener('click', () => attachInput.click());
455
- attachInput.addEventListener('change', () => {
456
- addImageFiles([...attachInput.files]);
457
- attachInput.value = '';
458
- });
459
- chatInput.addEventListener('paste', (event) => {
460
- const files = [...(event.clipboardData?.files ?? [])].filter((f) => f.type.startsWith('image/'));
461
- if (!files.length) return;
462
- event.preventDefault();
463
- addImageFiles(files);
464
- });
465
- chatForm.addEventListener('dragover', (event) => {
466
- event.preventDefault();
467
- chatForm.classList.add('dragging');
468
- });
469
- chatForm.addEventListener('dragleave', () => chatForm.classList.remove('dragging'));
470
- chatForm.addEventListener('drop', (event) => {
471
- const files = [...(event.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith('image/'));
472
- if (!files.length) return;
473
- event.preventDefault();
474
- chatForm.classList.remove('dragging');
475
- addImageFiles(files);
476
- });
477
-
478
- chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
479
-
480
- $('#folder-new').addEventListener('click', openCreateFolder);
481
- $('#folder-new-cancel').addEventListener('click', closeCreateFolder);
482
- $('#folder-new-create').addEventListener('click', createFolder);
483
- folderNewName.addEventListener('keydown', (event) => {
484
- if (event.key === 'Enter') {
485
- event.preventDefault();
486
- createFolder();
487
- } else if (event.key === 'Escape') {
488
- event.preventDefault();
489
- closeCreateFolder();
490
- }
491
- });
492
- $('#folder-roots').addEventListener('click', () => loadFolders(''));
493
- $('#folder-home').addEventListener('click', () => loadFolders(state.home || ''));
494
- $('#folder-close').addEventListener('click', closeFolderPicker);
495
- $('#folder-cancel').addEventListener('click', () => {
496
- state.pendingInput = null;
497
- closeFolderPicker();
498
- });
499
- $('#folder-up').addEventListener('click', () => loadFolders(folderParent ?? ''));
500
- $('#folder-use').addEventListener('click', useFolder);
501
- $('#folder-go').addEventListener('click', gotoFolder);
502
- folderGoto.addEventListener('input', filterFolders);
503
- folderGoto.addEventListener('keydown', (event) => {
504
- if (event.key === 'Enter') {
505
- event.preventDefault();
506
- gotoFolder();
507
- }
508
- });
509
- folderList.addEventListener('keydown', (event) => {
510
- if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
511
- const items = [...folderList.querySelectorAll('.folder-item')];
512
- if (!items.length) return;
513
- event.preventDefault();
514
- const current = items.indexOf(document.activeElement);
515
- const next = event.key === 'ArrowDown' ? Math.min(items.length - 1, current + 1) : Math.max(0, current - 1);
516
- items[current === -1 ? 0 : next].focus();
517
- });
518
- folderModal.addEventListener('click', (event) => {
519
- if (event.target === folderModal) {
520
- state.pendingInput = null;
521
- closeFolderPicker();
522
- }
523
- });
524
-
525
- $('#provider-new').addEventListener('click', () => openProviderForm());
526
- $('#pf-cancel').addEventListener('click', closeProviderForm);
527
- $('#pf-close').addEventListener('click', closeProviderForm);
528
- $('#pf-test').addEventListener('click', testProvider);
529
- providerForm.addEventListener('submit', submitProvider);
530
- providerModal.addEventListener('click', (event) => {
531
- if (event.target === providerModal) closeProviderForm();
532
- });
533
-
534
- ajustesTabs.forEach((tab) =>
535
- tab.addEventListener('click', () => activateAjustesPanel(tab.dataset.panel))
536
- );
537
-
538
- $('#limits-save')?.addEventListener('click', async () => {
539
- const body = {
540
- maxRounds: Math.max(0, Number(limitsRoundsEl?.value) || 0),
541
- maxTokens: Math.max(0, Number(limitsTokensEl?.value) || 0),
542
- maxDurationMs: Math.max(0, Math.round((Number(limitsDurationEl?.value) || 0) * 1000)),
543
- maxOutputTokens: Math.max(0, Number(limitsOutputEl?.value) || 0),
544
- promptCaching: Boolean(limitsCachingEl?.checked),
545
- };
546
- try {
547
- await api('/api/settings', { method: 'PUT', body });
548
- refreshLimits();
549
- setStatus('#limits-msg', 'guardado', 'ok');
550
- } catch (error) {
551
- setStatus('#limits-msg', error.message, 'err');
552
- }
553
- });
554
-
555
- $('#tr-save')?.addEventListener('click', submitTranscribe);
556
- trModeEl?.addEventListener('change', syncTranscribeMode);
557
-
558
- trTestBtn?.addEventListener('click', toggleTrTest);
559
-
560
- perfil.add?.addEventListener('click', () => addPerfilFieldRow());
561
- perfil.save?.addEventListener('click', submitProfile);
562
-
563
- if (mcpListEl) {
564
- $('#mcp-new').addEventListener('click', () => openMcpForm());
565
- $('#mcp-import-btn')?.addEventListener('click', importMcp);
566
- $('#mcp-import')?.addEventListener('keydown', (event) => {
567
- if (event.key === 'Enter') {
568
- event.preventDefault();
569
- importMcp();
570
- }
571
- });
572
- $('#skill-import-btn')?.addEventListener('click', importSkill);
573
- $('#skill-import')?.addEventListener('keydown', (event) => {
574
- if (event.key === 'Enter') {
575
- event.preventDefault();
576
- importSkill();
577
- }
578
- });
579
- $('#mcp-search-btn')?.addEventListener('click', searchMcp);
580
- $('#mcp-search')?.addEventListener('keydown', (event) => {
581
- if (event.key === 'Enter') {
582
- event.preventDefault();
583
- searchMcp();
584
- }
585
- });
586
- $('#skill-search-btn')?.addEventListener('click', searchSkills);
587
- $('#skill-search')?.addEventListener('keydown', (event) => {
588
- if (event.key === 'Enter') {
589
- event.preventDefault();
590
- searchSkills();
591
- }
592
- });
593
- refreshMcpCatalog();
594
- refreshSkillCatalog();
595
- $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
596
- mcpModeEl?.addEventListener('change', async () => {
597
- try {
598
- await api('/api/settings', { method: 'PUT', body: { mcpMode: mcpModeEl.value } });
599
- await refreshMcp({ connect: true });
600
- } catch (error) {
601
- toast(error.message);
602
- }
603
- });
604
- const savePinnedMcp = async () => {
605
- try {
606
- await api('/api/settings', { method: 'PUT', body: { mcpPinned: String(mcpPinnedEl.value || '') } });
607
- await refreshMcp({ connect: true });
608
- } catch (error) {
609
- toast(error.message);
610
- }
611
- };
612
- $('#mcp-pinned-save')?.addEventListener('click', savePinnedMcp);
613
- mcpPinnedEl?.addEventListener('keydown', (event) => {
614
- if (event.key === 'Enter') {
615
- event.preventDefault();
616
- savePinnedMcp();
617
- }
618
- });
619
- $('#tg-save')?.addEventListener('click', submitChannel);
620
- $('#tg-test')?.addEventListener('click', testChannel);
621
- $('#voz-save')?.addEventListener('click', submitVoz);
622
- $('#voz-test')?.addEventListener('click', testVoz);
623
- vozRateEl?.addEventListener('input', renderVozSliders);
624
- vozPitchEl?.addEventListener('input', renderVozSliders);
625
- $('#asesor-save')?.addEventListener('click', submitAsesor);
626
- $('#asesor-test')?.addEventListener('click', testAsesor);
627
- $('#mcp-close').addEventListener('click', closeMcpForm);
628
- $('#mf-cancel').addEventListener('click', closeMcpForm);
629
- $('#mf-transport').addEventListener('change', syncMcpFormFields);
630
- mcpForm.addEventListener('submit', submitMcp);
631
- mcpModal.addEventListener('click', (event) => {
632
- if (event.target === mcpModal) closeMcpForm();
633
- });
634
-
635
- $('#skill-new').addEventListener('click', () => openSkillForm());
636
- $('#skill-close').addEventListener('click', closeSkillForm);
637
- $('#sf-cancel').addEventListener('click', closeSkillForm);
638
- skillForm.addEventListener('submit', submitSkill);
639
- skillModal.addEventListener('click', (event) => {
640
- if (event.target === skillModal) closeSkillForm();
641
- });
642
-
643
- $('#memory-new')?.addEventListener('click', () => openMemoryForm());
644
- $('#memory-close')?.addEventListener('click', closeMemoryForm);
645
- $('#mem-cancel')?.addEventListener('click', closeMemoryForm);
646
- memoryForm?.addEventListener('submit', submitMemory);
647
- $('#memory-save-index')?.addEventListener('click', saveMemoryIndex);
648
- memoryModal?.addEventListener('click', (event) => {
649
- if (event.target === memoryModal) closeMemoryForm();
650
- });
651
-
652
- $('#sc-type')?.addEventListener('change', scheduleTypeFields);
653
- $('#schedule-new')?.addEventListener('click', () => openScheduleForm());
654
- $('#schedule-close')?.addEventListener('click', closeScheduleForm);
655
- $('#sc-cancel')?.addEventListener('click', closeScheduleForm);
656
- scheduleForm?.addEventListener('submit', submitSchedule);
657
- scheduleModal?.addEventListener('click', (event) => {
658
- if (event.target === scheduleModal) closeScheduleForm();
659
- });
660
- }
661
-
662
- evalTasks.value = [
663
- '{"id":"math","input":"¿Cuánto es 17 * 23? Respondé solo el número.","expect":{"equals":"391"}}',
664
- '{"id":"regex","input":"Escribí una oración que contenga un número.","expect":{"regex":"\\\\d"}}',
665
- ].join('\n');
666
-
667
- evalRunBtn.addEventListener('click', runEval);
668
-
669
- $('#runs-refresh').addEventListener('click', refreshRuns);
670
-
671
- $('#file-preview-toggle')?.addEventListener('click', async () => {
672
- const toggle = $('#file-preview-toggle');
673
- const code = $('#file-code');
674
- const stage = $('#file-stage');
675
- const frame = $('#file-frame');
676
- const render = $('#file-render');
677
- const device = $('#file-device');
678
- const maxButton = $('#file-max');
679
- const current = filePreviewState.current;
680
- if (!current) return;
681
-
682
- filePreviewState.open = !filePreviewState.open;
683
- const off = () => {
684
- toggle.textContent = 'vista previa';
685
- code.hidden = false;
686
- stage.hidden = true;
687
- render.hidden = true;
688
- device.hidden = true;
689
- maxButton.hidden = false;
690
- $('#file-modal').classList.remove('preview-maximized');
691
- };
692
- if (!filePreviewState.open) return off();
693
-
694
- if (filePreviewState.kind === 'markdown') {
695
- render.innerHTML = renderMarkdown(filePreviewState.content);
696
- code.hidden = true;
697
- stage.hidden = true;
698
- render.hidden = false;
699
- device.hidden = true;
700
- maxButton.hidden = false;
701
- toggle.textContent = 'ver código';
702
- return;
703
- }
704
-
705
- device.hidden = false;
706
- maxButton.hidden = false;
707
- if (!filePreviewState.loaded) {
708
- const data = await api('/api/files/preview', {
709
- method: 'POST',
710
- body: { root: state.cwd, path: current.relPath },
711
- }).catch((error) => ({ error: error.message }));
712
- if (data?.error) {
713
- setExplorerStatus(data.error, true);
714
- filePreviewState.open = false;
715
- return off();
716
- }
717
- frame.src = data.url;
718
- filePreviewState.loaded = true;
719
- filePreviewTools.applyDevice();
720
- }
721
- toggle.textContent = 'ver código';
722
- code.hidden = true;
723
- render.hidden = true;
724
- stage.hidden = false;
725
- });
726
-
727
- document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
728
-
729
- restorePreviewMax = () => previewTools.setMax(false);
730
-
731
- $('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
732
- previewUrlInput?.addEventListener('keydown', (event) => {
733
- if (event.key === 'Enter') {
734
- event.preventDefault();
735
- loadPreview(previewUrlInput.value);
736
- }
737
- });
738
- previewFrame?.addEventListener('load', () => {
739
- if (previewFrame.src) setPreviewStatus(previewFrame.src);
740
- });
741
- $('#preview-tunnel')?.addEventListener('click', () => {
742
- const match = String(previewUrlInput.value).match(/:(\d+)/);
743
- const port = match ? Number(match[1]) : 0;
744
- if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
745
- openRemoteTunnel(port);
746
- });
747
- $('#preview-close')?.addEventListener('click', async () => {
748
- await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null);
749
- setTunnelUi(null);
750
- setPreviewStatus('acceso remoto cerrado.');
751
- });
752
- $('#preview-device')?.addEventListener('change', (event) => storage.set(PREVIEW_DEVICE_KEY, event.target.value));
753
-
754
- // Restaurar lo tipeado y el tamano elegido: en la tablet la pagina se recarga
755
- // sola (iOS la descarta en background) y antes se perdia todo el estado.
756
- (function restorePreview() {
757
- const savedDevice = storage.get(PREVIEW_DEVICE_KEY, '');
758
- if (savedDevice && $('#preview-device')) $('#preview-device').value = savedDevice;
759
- previewTools.applyDevice();
760
- const savedUrl = storage.get(PREVIEW_URL_KEY, '');
761
- if (savedUrl) previewUrlInput.value = savedUrl;
762
- syncPreviewTunnel({ load: true });
763
- })();
764
-
765
- $('#procs-detect')?.addEventListener('click', detectProcs);
766
-
767
- gitFilterEl?.addEventListener('input', () => {
768
- if (explorerMode === 'git' && gitEntries.length) renderGitList();
769
- });
770
-
771
- $('#explorer-handle')?.addEventListener('click', () => setExplorerOpen(true));
772
- $('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
773
- $('#explorer-refresh').addEventListener('click', refreshExplorerMode);
774
- explorerFilter.addEventListener('input', applyTreeFilter);
775
- $('#file-close').addEventListener('click', () => $('#file-modal').close());
776
- $('#file-modal').addEventListener('click', (event) => {
777
- if (event.target === $('#file-modal')) $('#file-modal').close();
778
- });
779
- $('#git-close')?.addEventListener('click', () => gitModalEl?.close());
780
- gitModalEl?.addEventListener('click', (event) => {
781
- if (event.target === gitModalEl) gitModalEl.close();
782
- });
783
-
784
- try {
785
- setExplorerOpen(storage.get('tamnora-explorer-open') === '1');
786
- } catch {
787
- setExplorerOpen(false);
788
- }
789
-
790
- $('#update-run')?.addEventListener('click', runUpdate);
791
- $('#update-later')?.addEventListener('click', dismissUpdate);
792
-
793
- if (brandVerEl) {
794
- brandVerEl.title = 'buscar actualización';
795
- brandVerEl.classList.add('ver-action');
796
- brandVerEl.addEventListener('click', forceUpdateCheck);
797
- }
798
-
799
- /* --- boot ---------------------------------------------------------------- */
800
-
234
+ if (settingsModal) {
235
+ $('#settings-toggle')?.addEventListener('click', openSettingsModal);
236
+ $('#settings-close')?.addEventListener('click', closeSettingsModal);
237
+ $('#settings-done')?.addEventListener('click', closeSettingsModal);
238
+ $('#settings-providers')?.addEventListener('click', () => {
239
+ closeSettingsModal();
240
+ activateTab('providers');
241
+ });
242
+ $('#agent-settings-open')?.addEventListener('click', openSettingsModal);
243
+ $('#agent-providers')?.addEventListener('click', () => activateTab('providers'));
244
+ $('#default-cwd-pick')?.addEventListener('click', () => openFolderPicker('default'));
245
+ $('#default-cwd-clear')?.addEventListener('click', () => {
246
+ writeDefaultCwd('');
247
+ syncDefaultCwd();
248
+ });
249
+ settingsModal.addEventListener('click', (event) => {
250
+ if (event.target === settingsModal) closeSettingsModal();
251
+ });
252
+ settingsModal.addEventListener('close', () => moveAgentControls(agentControlsSlot));
253
+ }
254
+
255
+ if (statusbarEl) {
256
+ statusbarEl.addEventListener('click', (event) => {
257
+ if (event.target.closest('#sb-context')) return;
258
+ openSettingsModal();
259
+ });
260
+ statusbarEl.addEventListener('keydown', (event) => {
261
+ if (event.key === 'Enter' || event.key === ' ') {
262
+ event.preventDefault();
263
+ openSettingsModal();
264
+ }
265
+ });
266
+ }
267
+
268
+ if (palette.modal) {
269
+ palette.input.addEventListener('input', () => {
270
+ const q = palette.input.value;
271
+ renderPalette(q);
272
+ clearTimeout(palette.timer);
273
+ palette.timer = setTimeout(async () => {
274
+ if (q.trim().length < 2) {
275
+ palette.results = { sessions: [], messages: [] };
276
+ renderPalette(q);
277
+ return;
278
+ }
279
+ const data = await api(`/api/search?q=${encodeURIComponent(q)}&limit=15`).catch(() => null);
280
+ if (data) {
281
+ palette.results = { sessions: data.sessions || [], messages: data.messages || [] };
282
+ renderPalette(q);
283
+ }
284
+ }, 180);
285
+ });
286
+ palette.input.addEventListener('keydown', (event) => {
287
+ if (event.key === 'ArrowDown') {
288
+ event.preventDefault();
289
+ setPaletteIndex(palette.index + 1);
290
+ } else if (event.key === 'ArrowUp') {
291
+ event.preventDefault();
292
+ setPaletteIndex(palette.index - 1);
293
+ } else if (event.key === 'Enter') {
294
+ event.preventDefault();
295
+ const item = palette.items[palette.index];
296
+ if (item) runPaletteItem(item);
297
+ }
298
+ });
299
+ palette.modal.addEventListener('click', (event) => {
300
+ if (event.target === palette.modal) palette.modal.close();
301
+ });
302
+ $('#search-toggle')?.addEventListener('click', openPalette);
303
+ document.addEventListener('keydown', (event) => {
304
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
305
+ event.preventDefault();
306
+ openPalette();
307
+ }
308
+ });
309
+ }
310
+
311
+ modeSelect.addEventListener('change', () => setMode(modeSelect.value));
312
+
313
+ document.addEventListener('keydown', (event) => {
314
+ if ((event.ctrlKey || event.metaKey) && event.key === '.') {
315
+ event.preventDefault();
316
+ toggleMode();
317
+ }
318
+ });
319
+
320
+ cwdPill?.addEventListener('click', () => openFolderPicker('session'));
321
+ modePill?.addEventListener('click', (event) => {
322
+ event.stopPropagation();
323
+ if (!modeMenu) return;
324
+ modeMenu.hidden = !modeMenu.hidden;
325
+ modePill.setAttribute('aria-expanded', modeMenu.hidden ? 'false' : 'true');
326
+ });
327
+ modeMenu?.addEventListener('click', async (event) => {
328
+ const item = event.target.closest('.ctrl-menu-item');
329
+ if (!item) return;
330
+ const kind = item.dataset.kind;
331
+ closeModeMenu();
332
+ if (kind === 'full') {
333
+ await setMode('agent');
334
+ if (yesInput) yesInput.checked = true;
335
+ } else {
336
+ if (yesInput) yesInput.checked = false;
337
+ await setMode(kind === 'read' ? 'plan' : kind === 'voice' ? 'voice' : 'agent');
338
+ }
339
+ renderModePill();
340
+ });
341
+ document.addEventListener('click', (event) => {
342
+ if (modeMenu && !modeMenu.hidden && !event.target.closest('.ctrl-wrap')) closeModeMenu();
343
+ });
344
+ document.addEventListener('keydown', (event) => {
345
+ if (event.key === 'Escape') closeModeMenu();
346
+ });
347
+ modelPill?.addEventListener('click', () => openSettingsModal());
348
+ effortPill?.addEventListener('click', (event) => {
349
+ event.stopPropagation();
350
+ if (!effortMenu) return;
351
+ effortMenu.hidden = !effortMenu.hidden;
352
+ effortPill.setAttribute('aria-expanded', effortMenu.hidden ? 'false' : 'true');
353
+ });
354
+ effortMenu?.addEventListener('click', async (event) => {
355
+ const item = event.target.closest('.ctrl-menu-item');
356
+ if (!item) return;
357
+ closeEffortMenu();
358
+ await setEffort(item.dataset.effort || '');
359
+ });
360
+ effortSelect?.addEventListener('change', () => setEffort(effortSelect.value));
361
+ document.addEventListener('click', (event) => {
362
+ if (effortMenu && !effortMenu.hidden && !event.target.closest('.ctrl-wrap')) closeEffortMenu();
363
+ });
364
+ document.addEventListener('keydown', (event) => {
365
+ if (event.key === 'Escape') closeEffortMenu();
366
+ });
367
+ yesInput?.addEventListener('change', renderModePill);
368
+ strictInput?.addEventListener('change', () =>
369
+ api('/api/settings', { method: 'PUT', body: { strictScope: strictInput.checked } }).catch(() => null)
370
+ );
371
+
372
+ themeToggle?.addEventListener('click', cycleTheme);
373
+
374
+ buildAppearanceControls();
375
+
376
+ if (logout) {
377
+ logout.addEventListener('click', async (event) => {
378
+ event.preventDefault();
379
+ try {
380
+ await api('/api/auth/logout', { method: 'POST' });
381
+ } catch {
382
+ // igual salimos
383
+ }
384
+ location.href = '/landing';
385
+ });
386
+ }
387
+
388
+ initLayout();
389
+
390
+ providerSelect.addEventListener('change', async () => {
391
+ await loadModels({ save: false });
392
+ await persistAgent({ provider: providerSelect.value, model: modelSelect.value || undefined });
393
+ });
394
+ modelSelect.addEventListener('change', () => persistAgent({ model: modelSelect.value }));
395
+
396
+ export let restorePreviewMax = null;
397
+
398
+ scrollTopBtn.addEventListener('click', () => activePane?.scrollTo({ top: 0, behavior: scrollBehavior() }));
399
+ scrollDownBtn.addEventListener('click', () =>
400
+ activePane?.scrollTo({ top: activePane.scrollHeight, behavior: scrollBehavior() })
401
+ );
402
+ // `scroll` no burbujea: se escucha en captura desde el contenedor de los panes.
403
+ panesEl.addEventListener('scroll', scheduleScrollNav, true);
404
+ window.addEventListener('resize', scheduleScrollNav);
405
+ new MutationObserver(scheduleScrollNav).observe(panesEl, {
406
+ childList: true,
407
+ characterData: true,
408
+ subtree: true,
409
+ });
410
+
411
+ document.addEventListener('click', (event) => {
412
+ if (!openMessageMenu) return;
413
+ if (event.target.closest('.msg-menu') || event.target.closest('.msg-menu-btn')) return;
414
+ closeMessageMenus();
415
+ });
416
+ document.addEventListener('keydown', (event) => {
417
+ if (event.key === 'Escape') closeMessageMenus();
418
+ });
419
+ window.addEventListener('resize', positionMessageMenu);
420
+ document.addEventListener('scroll', positionMessageMenu, true);
421
+
422
+ panesEl.addEventListener('click', (event) => {
423
+ const button = event.target.closest('.code-copy');
424
+ if (!button) return;
425
+ const pre = button.closest('.code-block')?.querySelector('pre');
426
+ if (!pre || !navigator.clipboard) return;
427
+ navigator.clipboard.writeText(pre.textContent).then(() => {
428
+ button.textContent = 'copiado';
429
+ setTimeout(() => (button.textContent = 'copiar'), 1200);
430
+ });
431
+ });
432
+
433
+ /** Tool -> momento en que se aprobo por ultima vez. */
434
+ state.recentDecisions = new Map();
435
+
436
+ $('#new-session').addEventListener('click', startNewSession);
437
+ $('#new-session-top').addEventListener('click', startNewSession);
438
+
439
+ if (stopBtn) {
440
+ stopBtn.addEventListener('click', async () => {
441
+ const id = state.sessionId;
442
+ if (!id || stopBtn.disabled) return;
443
+ stopBtn.disabled = true;
444
+ renderFooterActivity('stopping');
445
+ // Abortamos el stream local (corta el seguimiento ya) y pedimos al server que
446
+ // aborte el run: sin esto el run es durable y seguiria generando.
447
+ controllers.get(id)?.abort();
448
+ try {
449
+ await api(`/api/sessions/${id}/stop`, { method: 'POST' });
450
+ } catch (error) {
451
+ toast(`No se pudo detener el run: ${error.message}`);
452
+ } finally {
453
+ stopBtn.disabled = false;
454
+ }
455
+ });
456
+ }
457
+
458
+ if (interruptBtn) interruptBtn.addEventListener('click', sendInterrupt);
459
+
460
+ chatForm.addEventListener('submit', (event) => {
461
+ event.preventDefault();
462
+ sendMessage();
463
+ });
464
+ chatInput.addEventListener('keydown', (event) => {
465
+ if (handleSlashKey(event)) return;
466
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
467
+ event.preventDefault();
468
+ sendInterrupt();
469
+ return;
470
+ }
471
+ if (event.key === 'Enter' && !event.shiftKey) {
472
+ event.preventDefault();
473
+ sendMessage();
474
+ }
475
+ });
476
+ chatInput.addEventListener('input', onChatInputChanged);
477
+
478
+ micBtn?.addEventListener('click', toggleMic);
479
+
480
+ handsFreeBtn?.addEventListener('click', toggleHandsFree);
481
+
482
+ $('#attach-image').addEventListener('click', () => attachInput.click());
483
+ attachInput.addEventListener('change', () => {
484
+ addImageFiles([...attachInput.files]);
485
+ attachInput.value = '';
486
+ });
487
+ chatInput.addEventListener('paste', (event) => {
488
+ const files = [...(event.clipboardData?.files ?? [])].filter((f) => f.type.startsWith('image/'));
489
+ if (!files.length) return;
490
+ event.preventDefault();
491
+ addImageFiles(files);
492
+ });
493
+ chatForm.addEventListener('dragover', (event) => {
494
+ event.preventDefault();
495
+ chatForm.classList.add('dragging');
496
+ });
497
+ chatForm.addEventListener('dragleave', () => chatForm.classList.remove('dragging'));
498
+ chatForm.addEventListener('drop', (event) => {
499
+ const files = [...(event.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith('image/'));
500
+ if (!files.length) return;
501
+ event.preventDefault();
502
+ chatForm.classList.remove('dragging');
503
+ addImageFiles(files);
504
+ });
505
+
506
+ chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
507
+
508
+ $('#folder-new').addEventListener('click', openCreateFolder);
509
+ $('#folder-new-cancel').addEventListener('click', closeCreateFolder);
510
+ $('#folder-new-create').addEventListener('click', createFolder);
511
+ folderNewName.addEventListener('keydown', (event) => {
512
+ if (event.key === 'Enter') {
513
+ event.preventDefault();
514
+ createFolder();
515
+ } else if (event.key === 'Escape') {
516
+ event.preventDefault();
517
+ closeCreateFolder();
518
+ }
519
+ });
520
+ $('#folder-roots').addEventListener('click', () => loadFolders(''));
521
+ $('#folder-home').addEventListener('click', () => loadFolders(state.home || ''));
522
+ $('#folder-close').addEventListener('click', closeFolderPicker);
523
+ $('#folder-cancel').addEventListener('click', () => {
524
+ state.pendingInput = null;
525
+ closeFolderPicker();
526
+ });
527
+ $('#folder-up').addEventListener('click', () => loadFolders(folderParent ?? ''));
528
+ $('#folder-use').addEventListener('click', useFolder);
529
+ $('#folder-go').addEventListener('click', gotoFolder);
530
+ folderGoto.addEventListener('input', filterFolders);
531
+ folderGoto.addEventListener('keydown', (event) => {
532
+ if (event.key === 'Enter') {
533
+ event.preventDefault();
534
+ gotoFolder();
535
+ }
536
+ });
537
+ folderList.addEventListener('keydown', (event) => {
538
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
539
+ const items = [...folderList.querySelectorAll('.folder-item')];
540
+ if (!items.length) return;
541
+ event.preventDefault();
542
+ const current = items.indexOf(document.activeElement);
543
+ const next = event.key === 'ArrowDown' ? Math.min(items.length - 1, current + 1) : Math.max(0, current - 1);
544
+ items[current === -1 ? 0 : next].focus();
545
+ });
546
+ folderModal.addEventListener('click', (event) => {
547
+ if (event.target === folderModal) {
548
+ state.pendingInput = null;
549
+ closeFolderPicker();
550
+ }
551
+ });
552
+
553
+ $('#provider-new').addEventListener('click', () => openProviderForm());
554
+ $('#pf-cancel').addEventListener('click', closeProviderForm);
555
+ $('#pf-close').addEventListener('click', closeProviderForm);
556
+ $('#pf-test').addEventListener('click', testProvider);
557
+ providerForm.addEventListener('submit', submitProvider);
558
+ providerModal.addEventListener('click', (event) => {
559
+ if (event.target === providerModal) closeProviderForm();
560
+ });
561
+
562
+ wireAjustesNav();
563
+
564
+ $('#limits-save')?.addEventListener('click', async () => {
565
+ const body = {
566
+ maxRounds: Math.max(0, Number(limitsRoundsEl?.value) || 0),
567
+ maxTokens: Math.max(0, Number(limitsTokensEl?.value) || 0),
568
+ maxDurationMs: Math.max(0, Math.round((Number(limitsDurationEl?.value) || 0) * 1000)),
569
+ maxOutputTokens: Math.max(0, Number(limitsOutputEl?.value) || 0),
570
+ promptCaching: Boolean(limitsCachingEl?.checked),
571
+ };
572
+ try {
573
+ await api('/api/settings', { method: 'PUT', body });
574
+ refreshLimits();
575
+ setStatus('#limits-msg', 'guardado', 'ok');
576
+ } catch (error) {
577
+ setStatus('#limits-msg', error.message, 'err');
578
+ }
579
+ });
580
+
581
+ $('#tr-save')?.addEventListener('click', submitTranscribe);
582
+ trModeEl?.addEventListener('change', syncTranscribeMode);
583
+
584
+ trTestBtn?.addEventListener('click', toggleTrTest);
585
+
586
+ perfil.add?.addEventListener('click', () => addPerfilFieldRow());
587
+ perfil.save?.addEventListener('click', submitProfile);
588
+
589
+ if (mcpListEl) {
590
+ $('#mcp-new').addEventListener('click', () => openMcpForm());
591
+ $('#mcp-import-btn')?.addEventListener('click', importMcp);
592
+ $('#mcp-import')?.addEventListener('keydown', (event) => {
593
+ if (event.key === 'Enter') {
594
+ event.preventDefault();
595
+ importMcp();
596
+ }
597
+ });
598
+ $('#skill-import-btn')?.addEventListener('click', importSkill);
599
+ $('#skill-import')?.addEventListener('keydown', (event) => {
600
+ if (event.key === 'Enter') {
601
+ event.preventDefault();
602
+ importSkill();
603
+ }
604
+ });
605
+ $('#mcp-search-btn')?.addEventListener('click', searchMcp);
606
+ $('#mcp-search')?.addEventListener('keydown', (event) => {
607
+ if (event.key === 'Enter') {
608
+ event.preventDefault();
609
+ searchMcp();
610
+ }
611
+ });
612
+ $('#skill-search-btn')?.addEventListener('click', searchSkills);
613
+ $('#skill-search')?.addEventListener('keydown', (event) => {
614
+ if (event.key === 'Enter') {
615
+ event.preventDefault();
616
+ searchSkills();
617
+ }
618
+ });
619
+ refreshMcpCatalog();
620
+ refreshSkillCatalog();
621
+ $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
622
+ mcpModeEl?.addEventListener('change', async () => {
623
+ try {
624
+ await api('/api/settings', { method: 'PUT', body: { mcpMode: mcpModeEl.value } });
625
+ await refreshMcp({ connect: true });
626
+ } catch (error) {
627
+ toast(error.message);
628
+ }
629
+ });
630
+ const savePinnedMcp = async () => {
631
+ try {
632
+ await api('/api/settings', { method: 'PUT', body: { mcpPinned: String(mcpPinnedEl.value || '') } });
633
+ await refreshMcp({ connect: true });
634
+ } catch (error) {
635
+ toast(error.message);
636
+ }
637
+ };
638
+ $('#mcp-pinned-save')?.addEventListener('click', savePinnedMcp);
639
+ mcpPinnedEl?.addEventListener('keydown', (event) => {
640
+ if (event.key === 'Enter') {
641
+ event.preventDefault();
642
+ savePinnedMcp();
643
+ }
644
+ });
645
+ $('#tg-save')?.addEventListener('click', submitChannel);
646
+ $('#tg-test')?.addEventListener('click', testChannel);
647
+ $('#voz-save')?.addEventListener('click', submitVoz);
648
+ $('#voz-test')?.addEventListener('click', testVoz);
649
+ vozRateEl?.addEventListener('input', renderVozSliders);
650
+ vozPitchEl?.addEventListener('input', renderVozSliders);
651
+ $('#asesor-save')?.addEventListener('click', submitAsesor);
652
+ $('#asesor-test')?.addEventListener('click', testAsesor);
653
+ $('#mcp-close').addEventListener('click', closeMcpForm);
654
+ $('#mf-cancel').addEventListener('click', closeMcpForm);
655
+ $('#mf-transport').addEventListener('change', syncMcpFormFields);
656
+ mcpForm.addEventListener('submit', submitMcp);
657
+ mcpModal.addEventListener('click', (event) => {
658
+ if (event.target === mcpModal) closeMcpForm();
659
+ });
660
+
661
+ $('#skill-new').addEventListener('click', () => openSkillForm());
662
+ $('#skill-close').addEventListener('click', closeSkillForm);
663
+ $('#sf-cancel').addEventListener('click', closeSkillForm);
664
+ skillForm.addEventListener('submit', submitSkill);
665
+ skillModal.addEventListener('click', (event) => {
666
+ if (event.target === skillModal) closeSkillForm();
667
+ });
668
+
669
+ $('#agent-new')?.addEventListener('click', () => openAgentForm());
670
+ $('#agent-close')?.addEventListener('click', closeAgentForm);
671
+ $('#af-cancel')?.addEventListener('click', closeAgentForm);
672
+ agentForm?.addEventListener('submit', submitAgent);
673
+ agentModal?.addEventListener('click', (event) => {
674
+ if (event.target === agentModal) closeAgentForm();
675
+ });
676
+
677
+ $('#memory-new')?.addEventListener('click', () => openMemoryForm());
678
+ $('#memory-close')?.addEventListener('click', closeMemoryForm);
679
+ $('#mem-cancel')?.addEventListener('click', closeMemoryForm);
680
+ memoryForm?.addEventListener('submit', submitMemory);
681
+ $('#memory-save-index')?.addEventListener('click', saveMemoryIndex);
682
+ memoryModal?.addEventListener('click', (event) => {
683
+ if (event.target === memoryModal) closeMemoryForm();
684
+ });
685
+
686
+ $('#sc-type')?.addEventListener('change', scheduleTypeFields);
687
+ $('#schedule-new')?.addEventListener('click', () => openScheduleForm());
688
+ $('#schedule-close')?.addEventListener('click', closeScheduleForm);
689
+ $('#sc-cancel')?.addEventListener('click', closeScheduleForm);
690
+ scheduleForm?.addEventListener('submit', submitSchedule);
691
+ scheduleModal?.addEventListener('click', (event) => {
692
+ if (event.target === scheduleModal) closeScheduleForm();
693
+ });
694
+ }
695
+
696
+ evalTasks.value = [
697
+ '{"id":"math","input":"¿Cuánto es 17 * 23? Respondé solo el número.","expect":{"equals":"391"}}',
698
+ '{"id":"regex","input":"Escribí una oración que contenga un número.","expect":{"regex":"\\\\d"}}',
699
+ ].join('\n');
700
+
701
+ evalRunBtn.addEventListener('click', runEval);
702
+
703
+ $('#runs-refresh').addEventListener('click', refreshRuns);
704
+
705
+ $('#file-preview-toggle')?.addEventListener('click', async () => {
706
+ const toggle = $('#file-preview-toggle');
707
+ const code = $('#file-code');
708
+ const stage = $('#file-stage');
709
+ const frame = $('#file-frame');
710
+ const render = $('#file-render');
711
+ const device = $('#file-device');
712
+ const maxButton = $('#file-max');
713
+ const current = filePreviewState.current;
714
+ if (!current) return;
715
+
716
+ filePreviewState.open = !filePreviewState.open;
717
+ const off = () => {
718
+ toggle.textContent = 'vista previa';
719
+ code.hidden = false;
720
+ stage.hidden = true;
721
+ render.hidden = true;
722
+ device.hidden = true;
723
+ maxButton.hidden = false;
724
+ $('#file-modal').classList.remove('preview-maximized');
725
+ };
726
+ if (!filePreviewState.open) return off();
727
+
728
+ if (filePreviewState.kind === 'markdown') {
729
+ render.innerHTML = renderMarkdown(filePreviewState.content);
730
+ code.hidden = true;
731
+ stage.hidden = true;
732
+ render.hidden = false;
733
+ device.hidden = true;
734
+ maxButton.hidden = false;
735
+ toggle.textContent = 'ver código';
736
+ return;
737
+ }
738
+
739
+ device.hidden = false;
740
+ maxButton.hidden = false;
741
+ if (!filePreviewState.loaded) {
742
+ const data = await api('/api/files/preview', {
743
+ method: 'POST',
744
+ body: { root: state.cwd, path: current.relPath },
745
+ }).catch((error) => ({ error: error.message }));
746
+ if (data?.error) {
747
+ setExplorerStatus(data.error, true);
748
+ filePreviewState.open = false;
749
+ return off();
750
+ }
751
+ frame.src = data.url;
752
+ filePreviewState.loaded = true;
753
+ filePreviewTools.applyDevice();
754
+ }
755
+ toggle.textContent = 'ver código';
756
+ code.hidden = true;
757
+ render.hidden = true;
758
+ stage.hidden = false;
759
+ });
760
+
761
+ document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
762
+
763
+ restorePreviewMax = () => previewTools.setMax(false);
764
+
765
+ $('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
766
+ previewUrlInput?.addEventListener('keydown', (event) => {
767
+ if (event.key === 'Enter') {
768
+ event.preventDefault();
769
+ loadPreview(previewUrlInput.value);
770
+ }
771
+ });
772
+ previewFrame?.addEventListener('load', () => {
773
+ if (previewFrame.src) setPreviewStatus(previewFrame.src);
774
+ });
775
+ $('#preview-tunnel')?.addEventListener('click', () => {
776
+ const match = String(previewUrlInput.value).match(/:(\d+)/);
777
+ const port = match ? Number(match[1]) : 0;
778
+ if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
779
+ openRemoteTunnel(port);
780
+ });
781
+ $('#preview-close')?.addEventListener('click', async () => {
782
+ await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null);
783
+ setTunnelUi(null);
784
+ setPreviewStatus('acceso remoto cerrado.');
785
+ });
786
+ $('#preview-device')?.addEventListener('change', (event) => storage.set(PREVIEW_DEVICE_KEY, event.target.value));
787
+
788
+ // Restaurar lo tipeado y el tamano elegido: en la tablet la pagina se recarga
789
+ // sola (iOS la descarta en background) y antes se perdia todo el estado.
790
+ (function restorePreview() {
791
+ const savedDevice = storage.get(PREVIEW_DEVICE_KEY, '');
792
+ if (savedDevice && $('#preview-device')) $('#preview-device').value = savedDevice;
793
+ previewTools.applyDevice();
794
+ const savedUrl = storage.get(PREVIEW_URL_KEY, '');
795
+ if (savedUrl) previewUrlInput.value = savedUrl;
796
+ syncPreviewTunnel({ load: true });
797
+ })();
798
+
799
+ $('#procs-detect')?.addEventListener('click', detectProcs);
800
+
801
+ gitFilterEl?.addEventListener('input', () => {
802
+ if (explorerMode === 'git' && gitEntries.length) renderGitList();
803
+ });
804
+
805
+ $('#explorer-handle')?.addEventListener('click', () => setExplorerOpen(true));
806
+ $('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
807
+ $('#explorer-refresh').addEventListener('click', refreshExplorerMode);
808
+ explorerFilter.addEventListener('input', applyTreeFilter);
809
+ $('#file-close').addEventListener('click', () => $('#file-modal').close());
810
+ $('#file-modal').addEventListener('click', (event) => {
811
+ if (event.target === $('#file-modal')) $('#file-modal').close();
812
+ });
813
+ $('#git-close')?.addEventListener('click', () => gitModalEl?.close());
814
+ gitModalEl?.addEventListener('click', (event) => {
815
+ if (event.target === gitModalEl) gitModalEl.close();
816
+ });
817
+
818
+ try {
819
+ setExplorerOpen(storage.get('tamnora-explorer-open') === '1');
820
+ } catch {
821
+ setExplorerOpen(false);
822
+ }
823
+
824
+ $('#update-run')?.addEventListener('click', runUpdate);
825
+ $('#update-later')?.addEventListener('click', dismissUpdate);
826
+
827
+ if (brandVerEl) {
828
+ brandVerEl.title = 'buscar actualización';
829
+ brandVerEl.classList.add('ver-action');
830
+ brandVerEl.addEventListener('click', forceUpdateCheck);
831
+ }
832
+
833
+ /* --- boot ---------------------------------------------------------------- */
834
+
801
835
  async function boot() {
802
- wireModelPicker();
803
- const auth = await api('/api/auth/me').catch(() => null);
804
- if (auth?.enabled && !auth.authenticated) {
805
- location.replace('/landing');
806
- return;
807
- }
808
- setCsrfToken(auth?.csrf || '');
809
- document.documentElement.dataset.auth = 'ok';
810
-
811
- const health = await api('/api/health').catch(() => null);
812
- if (health?.cwd) {
813
- document.title = `Tamnora Console · ${health.cwd}`;
814
- setCwd(health.cwd);
815
- }
816
- if (health?.home) state.home = health.home;
817
- if (health?.version) {
818
- const ver = document.querySelector('.brand .ver');
819
- if (ver) ver.textContent = `v${health.version}`;
820
- }
821
- refreshUpdate();
822
- await refreshProviders();
823
- await loadModes();
824
- await loadModels();
825
- await refreshTranscribe();
826
- // La config de voz se carga al boot (sin `await`, para no bloquear con el
827
- // fetch del catálogo de voces): si no, `voz.settings.enabled` queda en el
828
- // default `false` hasta abrir Ajustes y la narración no arranca al entrar.
829
- refreshVoz();
830
- await reconcile();
831
- await restoreTabs();
832
- renderAlertSignals();
833
- followEvents();
834
- watchLifecycle();
835
- // Latido de presencia: mantiene frescas las pestañas abiertas (TTL del server).
836
- setInterval(sendPresence, 30000);
837
- }
838
- boot();
836
+ wireModelPicker();
837
+ const auth = await api('/api/auth/me').catch(() => null);
838
+ if (auth?.enabled && !auth.authenticated) {
839
+ location.replace('/landing');
840
+ return;
841
+ }
842
+ setCsrfToken(auth?.csrf || '');
843
+ document.documentElement.dataset.auth = 'ok';
844
+
845
+ const health = await api('/api/health').catch(() => null);
846
+ if (health?.cwd) {
847
+ document.title = `Tamnora Console · ${health.cwd}`;
848
+ setCwd(health.cwd);
849
+ }
850
+ if (health?.home) state.home = health.home;
851
+ if (health?.version) {
852
+ const ver = document.querySelector('.brand .ver');
853
+ if (ver) ver.textContent = `v${health.version}`;
854
+ }
855
+ refreshUpdate();
856
+ await refreshProviders();
857
+ await loadModes();
858
+ await loadModels();
859
+ await refreshTranscribe();
860
+ // La config de voz se carga al boot (sin `await`, para no bloquear con el
861
+ // fetch del catálogo de voces): si no, `voz.settings.enabled` queda en el
862
+ // default `false` hasta abrir Ajustes y la narración no arranca al entrar.
863
+ refreshVoz();
864
+ await reconcile();
865
+ await restoreTabs();
866
+ renderAlertSignals();
867
+ followEvents();
868
+ watchLifecycle();
869
+ // Latido de presencia: mantiene frescas las pestañas abiertas (TTL del server).
870
+ setInterval(sendPresence, 30000);
871
+ }
872
+ boot();