@danieltmn/tamnora 0.21.2 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/harness/packages/cli/bin/tamnora.mjs +7 -1
  5. package/harness/packages/cli/src/commands/doctor.js +42 -4
  6. package/harness/packages/cli/src/commands/repl.js +1 -0
  7. package/harness/packages/cli/src/commands/run.js +1 -0
  8. package/harness/packages/cli/src/commands/tui.js +1 -0
  9. package/harness/packages/cli/src/lib/runtime.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +2 -0
  11. package/harness/packages/core/src/agent/runAgent.js +9 -5
  12. package/harness/packages/core/src/browser/index.js +27 -13
  13. package/harness/packages/core/src/index.js +1 -0
  14. package/harness/packages/core/src/mcp/stdio.js +15 -0
  15. package/harness/packages/core/src/net/hosts.js +70 -0
  16. package/harness/packages/core/src/redact.js +2 -0
  17. package/harness/packages/core/src/tools/executors.js +19 -24
  18. package/harness/packages/core/src/tools/index.js +1 -1
  19. package/harness/packages/core/src/tools/paths.js +35 -5
  20. package/harness/packages/core/src/tools/permissions.js +41 -2
  21. package/harness/packages/core/src/tools/registry.js +8 -3
  22. package/harness/packages/core/src/tts/edge.js +34 -4
  23. package/harness/packages/core/src/tunnel.js +15 -5
  24. package/harness/packages/core/test/agent.test.js +18 -0
  25. package/harness/packages/core/test/servicios-vivos.test.js +71 -0
  26. package/harness/packages/core/test/tools.test.js +44 -1
  27. package/harness/packages/core/test/tts.test.js +81 -0
  28. package/harness/packages/server/bin/tamnora-server.mjs +7 -1
  29. package/harness/packages/server/public/app.js +347 -6757
  30. package/harness/packages/server/public/cards.js +470 -0
  31. package/harness/packages/server/public/chat.js +1343 -0
  32. package/harness/packages/server/public/chatFormat.js +156 -0
  33. package/harness/packages/server/public/chrome.js +813 -0
  34. package/harness/packages/server/public/commands.js +403 -0
  35. package/harness/packages/server/public/core.js +209 -0
  36. package/harness/packages/server/public/explorer.js +625 -0
  37. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/index.html +40 -41
  40. package/harness/packages/server/public/sessions.js +274 -0
  41. package/harness/packages/server/public/settings.js +1080 -0
  42. package/harness/packages/server/public/styles.css +45 -52
  43. package/harness/packages/server/public/system.js +495 -0
  44. package/harness/packages/server/public/voice.js +1058 -0
  45. package/harness/packages/server/src/auth.js +12 -10
  46. package/harness/packages/server/src/lib/agentRun.js +6 -1
  47. package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
  48. package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
  49. package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
  50. package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
  51. package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
  52. package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
  53. package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
  54. package/harness/packages/server/src/lib/channels/telegram.js +163 -932
  55. package/harness/packages/server/src/lib/presence.js +12 -1
  56. package/harness/packages/server/src/lib/scheduler.js +12 -2
  57. package/harness/packages/server/src/lib/tasks.js +21 -3
  58. package/harness/packages/server/src/lib/workspace.js +90 -2
  59. package/harness/packages/server/src/routes/auth.js +8 -4
  60. package/harness/packages/server/src/routes/chat.js +16 -5
  61. package/harness/packages/server/src/routes/preview.js +10 -2
  62. package/harness/packages/server/src/routes/providers.js +14 -9
  63. package/harness/packages/server/src/server.js +25 -0
  64. package/harness/packages/server/test/chat.test.js +586 -0
  65. package/harness/packages/server/test/chatFormat.test.js +119 -0
  66. package/harness/packages/server/test/consoleModules.test.js +301 -0
  67. package/harness/packages/server/test/consoleSmoke.test.js +34 -0
  68. package/harness/packages/server/test/helpers.js +121 -0
  69. package/harness/packages/server/test/ops.test.js +515 -0
  70. package/harness/packages/server/test/runs.test.js +816 -0
  71. package/harness/packages/server/test/security.test.js +112 -9
  72. package/harness/packages/server/test/sessions.test.js +437 -0
  73. package/harness/packages/server/test/settings.test.js +482 -0
  74. package/harness/scripts/canary.mjs +24 -0
  75. package/harness/tamnora.example.json +1 -1
  76. package/package.json +3 -2
  77. package/harness/packages/server/test/server.test.js +0 -2885
@@ -75,29 +75,7 @@
75
75
  </head>
76
76
  <body>
77
77
  <header class="topbar">
78
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.21.2</span></div>
79
- <nav class="tabs">
80
- <button class="tab active" data-tab="chat">chat</button>
81
- <button class="tab" data-tab="providers">proveedores</button>
82
- <button class="tab" data-tab="ajustes">ajustes</button>
83
- <div class="menu">
84
- <button id="more-tab" class="tab" title="más" aria-haspopup="true" aria-expanded="false" aria-label="más">
85
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
86
- <circle cx="12" cy="12" r="1" />
87
- <circle cx="19" cy="12" r="1" />
88
- <circle cx="5" cy="12" r="1" />
89
- </svg>
90
- <span id="more-tab-label">chat</span>
91
- </button>
92
- <div id="more-menu" class="menu-pop" hidden>
93
- <button type="button" class="menu-primary" data-tab="chat">chat</button>
94
- <button type="button" class="menu-primary" data-tab="providers">proveedores</button>
95
- <button type="button" class="menu-primary" data-tab="ajustes">ajustes</button>
96
- <button type="button" data-tab="eval">eval</button>
97
- <button type="button" data-tab="runs">runs</button>
98
- </div>
99
- </div>
100
- </nav>
78
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.23.0</span></div>
101
79
  <div class="settings">
102
80
  <button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
103
81
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
@@ -111,6 +89,21 @@
111
89
  <path d="m21 21-4.3-4.3" />
112
90
  </svg>
113
91
  </button>
92
+ <div class="menu">
93
+ <button id="more-tab" type="button" class="btn icon" title="herramientas" aria-haspopup="menu" aria-controls="more-menu" aria-expanded="false" aria-label="herramientas">
94
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
95
+ <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
96
+ </svg>
97
+ </button>
98
+ <div id="more-menu" class="menu-pop" role="menu" hidden>
99
+ <button type="button" class="active" data-tab="chat" role="menuitem">chat</button>
100
+ <button type="button" data-tab="providers" role="menuitem">proveedores</button>
101
+ <button type="button" data-tab="ajustes" role="menuitem">ajustes</button>
102
+ <div class="menu-divider" role="separator"></div>
103
+ <button type="button" data-tab="eval" role="menuitem">eval</button>
104
+ <button type="button" data-tab="runs" role="menuitem">runs</button>
105
+ </div>
106
+ </div>
114
107
  <button id="settings-toggle" type="button" class="btn icon" title="modelo, modo y auto" aria-label="ajustes del agente">
115
108
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
116
109
  <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3" />
@@ -253,7 +246,7 @@
253
246
  </svg>
254
247
  </button>
255
248
  <input id="image-input" type="file" accept="image/*" multiple hidden />
256
- <textarea id="chat-input" rows="1" placeholder="escribí un mensaje…" title="Enter envía · Shift+Enter salto" spellcheck="false"></textarea>
249
+ <textarea id="chat-input" rows="1" placeholder="escribí un mensaje…" title="Enter envía · Shift+Enter salto" aria-label="mensaje para el asistente" spellcheck="false"></textarea>
257
250
  <button type="button" id="model-pill" class="ctrl-pill model-pill" title="proveedor y modelo activos">
258
251
  <span id="model-pill-label">—</span>
259
252
  <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
@@ -312,7 +305,7 @@
312
305
  <div id="xp-files" class="xp-pane">
313
306
  <div id="explorer-root" class="explorer-cwd" title=""></div>
314
307
  <div class="explorer-filter">
315
- <input id="explorer-filter" placeholder="filtrar por nombre…" spellcheck="false" />
308
+ <input id="explorer-filter" placeholder="filtrar por nombre…" aria-label="filtrar archivos por nombre" spellcheck="false" />
316
309
  </div>
317
310
  <ul id="explorer-tree" class="tree"></ul>
318
311
  <div id="explorer-status" class="explorer-status"></div>
@@ -320,7 +313,7 @@
320
313
 
321
314
  <div id="xp-preview" class="xp-pane" hidden>
322
315
  <div class="xp-bar">
323
- <input id="preview-url" placeholder="http://127.0.0.1:5173" spellcheck="false" />
316
+ <input id="preview-url" placeholder="http://127.0.0.1:5173" aria-label="URL de la vista previa" spellcheck="false" />
324
317
  <button id="preview-open" class="btn" type="button">abrir</button>
325
318
  <select id="preview-provider" title="cómo exponer el puerto para acceso remoto" aria-label="proveedor de acceso remoto">
326
319
  <option value="tailscale">tailscale</option>
@@ -366,7 +359,7 @@
366
359
  <span id="git-sync" class="muted grow"></span>
367
360
  </div>
368
361
  <div class="explorer-filter">
369
- <input id="git-filter" placeholder="filtrar por nombre…" spellcheck="false" />
362
+ <input id="git-filter" aria-label="filtrar archivos por nombre" placeholder="filtrar por nombre…" spellcheck="false" />
370
363
  </div>
371
364
  <ul id="git-list" class="git-list"></ul>
372
365
  <div id="git-status" class="explorer-status"></div>
@@ -486,10 +479,10 @@
486
479
  <textarea id="eval-tasks" rows="8" spellcheck="false" placeholder='{"id":"t1","input":"¿2+2?","expect":{"equals":"4"}}'></textarea>
487
480
  </label>
488
481
  <div class="row">
489
- <input id="eval-models" placeholder="modelos separados por coma" spellcheck="false" />
490
- <input id="eval-trials" type="number" min="1" value="1" title="trials" />
491
- <input id="eval-concurrency" type="number" min="1" value="2" title="concurrencia" />
492
- <input id="eval-judge" placeholder="judge-model (opcional)" spellcheck="false" />
482
+ <input id="eval-models" aria-label="modelos para evaluar (separados por coma)" placeholder="modelos separados por coma" spellcheck="false" />
483
+ <input id="eval-trials" aria-label="trials por tarea" type="number" min="1" value="1" title="trials" />
484
+ <input id="eval-concurrency" aria-label="concurrencia" type="number" min="1" value="2" title="concurrencia" />
485
+ <input id="eval-judge" aria-label="judge-model (opcional)" placeholder="judge-model (opcional)" spellcheck="false" />
493
486
  <button id="eval-run" class="btn primary">correr</button>
494
487
  </div>
495
488
  <div id="eval-summary" class="summary"></div>
@@ -633,15 +626,15 @@
633
626
  <span id="mcp-count" class="muted"></span>
634
627
  </div>
635
628
  <div class="import-bar">
636
- <input id="mcp-pinned" placeholder="tools/servidores fijos (coma), ej. mcp__postgres__query" spellcheck="false" />
629
+ <input id="mcp-pinned" aria-label="tools/servidores MCP fijados" placeholder="tools/servidores fijos (coma), ej. mcp__postgres__query" spellcheck="false" />
637
630
  <button id="mcp-pinned-save" class="btn" type="button">fijar</button>
638
631
  </div>
639
632
  <div class="import-bar">
640
- <input id="mcp-import" placeholder="nombre del registro oficial, URL o JSON (bloque mcpServers)" spellcheck="false" />
633
+ <input id="mcp-import" aria-label="importar servidor MCP" placeholder="nombre del registro oficial, URL o JSON (bloque mcpServers)" spellcheck="false" />
641
634
  <button id="mcp-import-btn" class="btn" type="button">importar</button>
642
635
  </div>
643
636
  <div class="import-bar">
644
- <input id="mcp-search" placeholder="buscar en el registro oficial (ej. github, postgres)" spellcheck="false" />
637
+ <input id="mcp-search" aria-label="buscar en el registro de MCP" placeholder="buscar en el registro oficial (ej. github, postgres)" spellcheck="false" />
645
638
  <button id="mcp-search-btn" class="btn" type="button">buscar</button>
646
639
  </div>
647
640
  <div id="mcp-import-status" class="summary"></div>
@@ -663,11 +656,11 @@
663
656
  Instrucciones especializadas en la casa de Tamnora (<code>skills/</code>). Las habilitadas se inyectan al system prompt.
664
657
  </p>
665
658
  <div class="import-bar">
666
- <input id="skill-import" placeholder="https://skills.sh/owner/repo/skill" spellcheck="false" />
659
+ <input id="skill-import" aria-label="importar skill" placeholder="https://skills.sh/owner/repo/skill" spellcheck="false" />
667
660
  <button id="skill-import-btn" class="btn" type="button">importar</button>
668
661
  </div>
669
662
  <div class="import-bar">
670
- <input id="skill-search" placeholder="buscar skills en GitHub (ej. testing, react)" spellcheck="false" />
663
+ <input id="skill-search" aria-label="buscar skills en GitHub" placeholder="buscar skills en GitHub (ej. testing, react)" spellcheck="false" />
671
664
  <button id="skill-search-btn" class="btn" type="button">buscar</button>
672
665
  </div>
673
666
  <div id="skill-import-status" class="summary"></div>
@@ -954,6 +947,12 @@
954
947
  <label class="field"><span>presupuesto de tiempo en segundos (0 = sin límite)</span>
955
948
  <input id="limits-duration" type="number" min="0" step="1" placeholder="0" />
956
949
  </label>
950
+ <label class="field"><span>máximo de tokens de salida por respuesta (0 = default del proveedor)</span>
951
+ <input id="limits-output" type="number" min="0" step="256" placeholder="0" />
952
+ </label>
953
+ <label class="check" title="reusa el prompt procesado entre rondas: ahorra costo y latencia en conversaciones largas (según el proveedor)">
954
+ <input id="limits-caching" type="checkbox" /> prompt caching
955
+ </label>
957
956
  <div class="row">
958
957
  <button id="limits-save" class="btn primary" type="button">guardar</button>
959
958
  <span id="limits-msg" class="summary"></span>
@@ -980,12 +979,12 @@
980
979
  </div>
981
980
  <div class="voz-range">
982
981
  <span class="muted">tamaño del chat</span>
983
- <input id="apariencia-chat-font" type="range" min="12" max="20" step="0.5" />
982
+ <input id="apariencia-chat-font" aria-label="tamano del texto del chat" type="range" min="12" max="20" step="0.5" />
984
983
  <output id="apariencia-chat-font-out">14.5px</output>
985
984
  </div>
986
985
  <div class="voz-range">
987
986
  <span class="muted">escala de la interfaz</span>
988
- <input id="apariencia-scale" type="range" min="80" max="130" step="5" />
987
+ <input id="apariencia-scale" aria-label="escala general de la interfaz" type="range" min="80" max="130" step="5" />
989
988
  <output id="apariencia-scale-out">100%</output>
990
989
  </div>
991
990
  <div class="row">
@@ -1034,11 +1033,11 @@
1034
1033
  <nav id="folder-crumbs" class="crumbs" aria-label="ruta actual"></nav>
1035
1034
  </div>
1036
1035
  <div class="folder-search">
1037
- <input id="folder-goto" spellcheck="false" placeholder="filtrar carpetas, o pegá una ruta y Enter" />
1036
+ <input id="folder-goto" aria-label="ir a una ruta o filtrar carpetas" spellcheck="false" placeholder="filtrar carpetas, o pegá una ruta y Enter" />
1038
1037
  <button type="button" id="folder-go" class="btn">ir</button>
1039
1038
  </div>
1040
1039
  <div id="folder-create" class="folder-create" hidden>
1041
- <input id="folder-new-name" spellcheck="false" placeholder="nombre de la nueva carpeta" />
1040
+ <input id="folder-new-name" aria-label="nombre de la nueva carpeta" spellcheck="false" placeholder="nombre de la nueva carpeta" />
1042
1041
  <button type="button" id="folder-new-create" class="btn primary">crear</button>
1043
1042
  <button type="button" id="folder-new-cancel" class="btn">cancelar</button>
1044
1043
  </div>
@@ -1222,7 +1221,7 @@
1222
1221
  <circle cx="11" cy="11" r="8" />
1223
1222
  <path d="m21 21-4.3-4.3" />
1224
1223
  </svg>
1225
- <input id="palette-input" type="text" placeholder="buscar sesiones y mensajes, o escribí un comando…" spellcheck="false" autocomplete="off" />
1224
+ <input id="palette-input" aria-label="buscar sesiones, mensajes y comandos" type="text" placeholder="buscar sesiones y mensajes, o escribí un comando…" spellcheck="false" autocomplete="off" />
1226
1225
  <span class="palette-hint">esc</span>
1227
1226
  </div>
1228
1227
  <ul id="palette-list" class="palette-list"></ul>
@@ -0,0 +1,274 @@
1
+ import {
2
+ addMessage,
3
+ addPermissionCard,
4
+ attachRun,
5
+ basename,
6
+ chatInput,
7
+ idleWaiters,
8
+ interruptBtn,
9
+ livePermission,
10
+ panesEl,
11
+ persistTabs,
12
+ presenceClosed,
13
+ refreshSessions,
14
+ renderFooterActivity,
15
+ renderQueue,
16
+ renderStoredMessages,
17
+ showPane,
18
+ showStandaloneQuestion,
19
+ stopBtn,
20
+ submitBtn,
21
+ tabbarEl,
22
+ } from './chat.js';
23
+ import {
24
+ applySessionAgent,
25
+ isMobile,
26
+ openFolderPicker,
27
+ renderTokens,
28
+ setCwd,
29
+ setSidebarOpen,
30
+ updateStatus,
31
+ } from './chrome.js';
32
+ import { notify } from './commands.js';
33
+ import { $, api, clear, el, icon, state, storage } from './core.js';
34
+ import { renderAlertSignals } from './system.js';
35
+
36
+ /* --- session tabs --------------------------------------------------------- */
37
+
38
+ export function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, running = false, permissions = [], questions = []) {
39
+ const id = session.id;
40
+ if (state.panes.has(id)) return state.panes.get(id);
41
+ const pane = document.createElement('div');
42
+ pane.className = 'messages';
43
+ pane.dataset.session = id;
44
+ panesEl.appendChild(pane);
45
+ const record = {
46
+ id,
47
+ el: pane,
48
+ title: session.title || '(sin título)',
49
+ cwd: session.cwd || '',
50
+ // Tipo de agente de la sesion: se aplica a los controles al abrirla y se
51
+ // persiste al cambiarlos. Cada pestaña puede correr con uno distinto.
52
+ provider: session.provider || '',
53
+ model: session.model || '',
54
+ mode: session.mode || '',
55
+ working: false,
56
+ tokens,
57
+ lastTurn: { prompt: 0, completion: 0 },
58
+ };
59
+ state.panes.set(id, record);
60
+ state.tabOrder.push(id);
61
+ renderStoredMessages(messages, pane);
62
+ if (!messages.length) {
63
+ pane.appendChild(el('div', 'empty', `Sesión vacía${session.cwd ? ` en ${basename(session.cwd)}` : ''}.`));
64
+ }
65
+ if (running) attachRun(id, pane);
66
+ // Los permisos pendientes no son mensajes: vienen aparte en el detalle de la
67
+ // sesion. Sin esto, recargar con un permiso en el aire dejaba el run
68
+ // bloqueado esperando una decision que no se podia tomar.
69
+ for (const ask of permissions) showStandalonePermission(record, ask);
70
+ for (const ask of questions) showStandaloneQuestion(record, ask);
71
+ return record;
72
+ }
73
+
74
+ /**
75
+ * Un permiso que sigue esperando, recreado desde `/api/state` o del detalle de
76
+ * la sesion. Va en una tarjeta propia al final del pane (no hay mensaje al que
77
+ * colgarse todavia).
78
+ */
79
+ export function showStandalonePermission(record, ask) {
80
+ if (livePermission(ask.askId)) return;
81
+ record.el.querySelector('.empty')?.remove();
82
+ const holder = addMessage('assistant', '', record.el);
83
+ holder.textEl.remove();
84
+ addPermissionCard(holder, { askId: ask.askId, name: ask.name, args: ask.args });
85
+ state.pendingAsks.set(ask.askId, { sessionId: record.id, name: ask.name ?? '', args: ask.args ?? {} });
86
+ renderAlertSignals();
87
+ }
88
+
89
+ export function renderTabs() {
90
+ clear(tabbarEl);
91
+ tabbarEl.hidden = !state.tabOrder.length;
92
+ for (const id of state.tabOrder) {
93
+ const record = state.panes.get(id);
94
+ if (!record) continue;
95
+ const tab = el('button', `session-tab${id === state.activeTab ? ' active' : ''}`);
96
+ tab.type = 'button';
97
+ tab.title = `${record.title}${record.cwd ? ` · ${record.cwd}` : ''}`;
98
+ if (record.working) tab.appendChild(el('span', 'tab-spinner'));
99
+ tab.appendChild(el('span', 'tab-title', record.title || '(sin título)'));
100
+ const close = el('span', 'tab-close');
101
+ close.title = 'cerrar';
102
+ close.appendChild(icon('x', 12));
103
+ close.addEventListener('click', (event) => {
104
+ event.stopPropagation();
105
+ closeTab(id);
106
+ });
107
+ tab.appendChild(close);
108
+ tab.addEventListener('click', () => openTab(id));
109
+ tabbarEl.appendChild(tab);
110
+ }
111
+ }
112
+
113
+ export async function openTab(id) {
114
+ if (state.panes.has(id)) {
115
+ activateSession(id);
116
+ return;
117
+ }
118
+ const data = await api(`/api/sessions/${id}`).catch((error) => {
119
+ notify(`No se pudo abrir la sesión: ${error.message}`, 'notice err');
120
+ return null;
121
+ });
122
+ if (!data) return;
123
+ registerSession(data.session, data.messages, data.tokens, data.running, data.permissions, data.questions);
124
+ activateSession(id);
125
+ }
126
+
127
+ export function activateSession(id) {
128
+ const record = state.panes.get(id);
129
+ if (!record) return;
130
+ state.sessionId = id;
131
+ state.activeTab = id;
132
+ showPane(id);
133
+ if (record.cwd) setCwd(record.cwd);
134
+ if (stopBtn) stopBtn.hidden = !record.working;
135
+ syncInterruptBtn();
136
+ auditStatus(record);
137
+ updateStatus();
138
+ renderTokens();
139
+ applySessionAgent(record);
140
+ renderQueue(id);
141
+ renderTabs();
142
+ refreshSessions();
143
+ persistTabs();
144
+ chatInput.focus();
145
+ }
146
+
147
+ export function updateSubmitLabel(working) {
148
+ if (!submitBtn) return;
149
+ submitBtn.dataset.working = working ? '1' : '0';
150
+ submitBtn.setAttribute('aria-label', working ? 'encolar' : 'enviar');
151
+ submitBtn.title = working ? 'El agente está trabajando: se envía al terminar' : 'enviar';
152
+ }
153
+
154
+ /** El boton de interrumpir solo tiene sentido con un turno en curso y texto que mandar. */
155
+ export function syncInterruptBtn() {
156
+ if (!interruptBtn) return;
157
+ const record = state.panes.get(state.sessionId);
158
+ const working = Boolean(record?.working);
159
+ const hasInput = Boolean(chatInput.value.trim()) || state.attachments.length > 0;
160
+ interruptBtn.hidden = !working;
161
+ interruptBtn.disabled = !working || !hasInput;
162
+ }
163
+
164
+ /** Promesa que resuelve cuando el turno de la sesion deja de estar activo. */
165
+ export function whenIdle(sessionKey, timeoutMs = 8000) {
166
+ const record = state.panes.get(sessionKey);
167
+ if (!record?.working) return Promise.resolve();
168
+ return new Promise((resolve) => {
169
+ let timer = null;
170
+ const settle = () => {
171
+ if (timer) clearTimeout(timer);
172
+ const list = idleWaiters.get(sessionKey);
173
+ if (list) {
174
+ const index = list.indexOf(settle);
175
+ if (index >= 0) list.splice(index, 1);
176
+ if (!list.length) idleWaiters.delete(sessionKey);
177
+ }
178
+ resolve();
179
+ };
180
+ timer = setTimeout(settle, timeoutMs);
181
+ const list = idleWaiters.get(sessionKey) ?? [];
182
+ list.push(settle);
183
+ idleWaiters.set(sessionKey, list);
184
+ });
185
+ }
186
+
187
+ export function auditStatus(record) {
188
+ submitBtn.disabled = false;
189
+ updateSubmitLabel(record.working);
190
+ $('#sb-rounds').textContent = record.working ? '' : 'listo';
191
+ $('#sb-rounds').classList.toggle('running', record.working);
192
+ renderFooterActivity(record.working ? 'thinking' : null);
193
+ }
194
+
195
+ export function closeTab(id) {
196
+ const record = state.panes.get(id);
197
+ if (!record) return;
198
+ record.el.remove();
199
+ state.panes.delete(id);
200
+ state.queues.delete(id);
201
+ state.tabOrder = state.tabOrder.filter((item) => item !== id);
202
+ presenceClosed.add(id);
203
+ if (state.activeTab === id) {
204
+ const next = state.tabOrder[state.tabOrder.length - 1] ?? null;
205
+ if (next) {
206
+ activateSession(next);
207
+ } else {
208
+ state.sessionId = null;
209
+ state.activeTab = null;
210
+ showPane(null);
211
+ renderTabs();
212
+ renderTokens();
213
+ refreshTokensDefaults();
214
+ refreshSessions();
215
+ }
216
+ } else {
217
+ renderTabs();
218
+ }
219
+ persistTabs();
220
+ }
221
+
222
+ export function refreshTokensDefaults() {
223
+ $('#sb-rounds').textContent = 'listo';
224
+ $('#sb-rounds').classList.remove('running');
225
+ submitBtn.disabled = false;
226
+ updateSubmitLabel(false);
227
+ }
228
+
229
+ export function setWorking(id, working) {
230
+ const record = state.panes.get(id);
231
+ // `attached` tiene que acompanar a `working`: mientras el pane sigue un run
232
+ // (tiene un controller vivo) el reconciliador NO debe recrearlo, o el
233
+ // `replaceChildren` de `renderPane` borra lo que el agente viene haciendo y
234
+ // solo se recupera recargando.
235
+ if (record) record.working = record.attached = working;
236
+ renderTabs();
237
+ if (!working) {
238
+ const waiters = idleWaiters.get(id);
239
+ if (waiters) {
240
+ idleWaiters.delete(id);
241
+ for (const settle of [...waiters]) settle();
242
+ }
243
+ }
244
+ if (id === state.sessionId) {
245
+ submitBtn.disabled = false;
246
+ updateSubmitLabel(working);
247
+ if (stopBtn) stopBtn.hidden = !working;
248
+ syncInterruptBtn();
249
+ $('#sb-rounds').textContent = working ? '' : 'listo';
250
+ $('#sb-rounds').classList.toggle('running', working);
251
+ renderFooterActivity(working ? 'thinking' : null);
252
+ }
253
+ }
254
+
255
+ export async function restoreTabs() {
256
+ const saved = storage.getJson('tamnora-open-tabs', null);
257
+ const ids = Array.isArray(saved?.ids) ? saved.ids : [];
258
+ for (const id of ids) {
259
+ if (state.panes.has(id)) continue;
260
+ const data = await api(`/api/sessions/${id}`).catch(() => null);
261
+ if (data) registerSession(data.session, data.messages, data.tokens, data.running, data.permissions, data.questions);
262
+ }
263
+ const active = saved?.active && state.panes.has(saved.active) ? saved.active : state.tabOrder[state.tabOrder.length - 1];
264
+ if (active) activateSession(active);
265
+ else renderTabs();
266
+ }
267
+
268
+ // El mismo handler para el boton del sidebar y el del topbar: en movil el
269
+ // sidebar es un drawer, asi que sin el del topbar crear una sesion eran dos
270
+ // toques y un menu escondido.
271
+ export function startNewSession() {
272
+ if (isMobile()) setSidebarOpen(false);
273
+ openFolderPicker();
274
+ }