@danieltmn/tamnora 0.5.1 → 0.6.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.
@@ -102,13 +102,14 @@ async function api(path, { method = 'GET', body } = {}) {
102
102
  return res.json();
103
103
  }
104
104
 
105
- async function streamPost(url, body, onEvent, signal) {
106
- const headers = { 'Content-Type': 'application/json' };
107
- if (csrfToken) headers['x-csrf'] = csrfToken;
105
+ async function streamSse(url, { method = 'GET', body, signal } = {}, onEvent) {
106
+ const headers = {};
107
+ if (body) headers['Content-Type'] = 'application/json';
108
+ if (method !== 'GET' && csrfToken) headers['x-csrf'] = csrfToken;
108
109
  const res = await fetch(url, {
109
- method: 'POST',
110
+ method,
110
111
  headers,
111
- body: JSON.stringify(body),
112
+ body: body ? JSON.stringify(body) : undefined,
112
113
  signal,
113
114
  });
114
115
  if (!res.ok) {
@@ -133,9 +134,11 @@ async function streamPost(url, body, onEvent, signal) {
133
134
  buffer = buffer.slice(index + 2);
134
135
  let event = 'message';
135
136
  let data = '';
137
+ let id = null;
136
138
  for (const line of frame.split('\n')) {
137
139
  if (line.startsWith('event:')) event = line.slice(6).trim();
138
140
  else if (line.startsWith('data:')) data += line.slice(5).trim();
141
+ else if (line.startsWith('id:')) id = line.slice(3).trim();
139
142
  }
140
143
  let parsed = {};
141
144
  if (data) {
@@ -145,11 +148,13 @@ async function streamPost(url, body, onEvent, signal) {
145
148
  parsed = { raw: data };
146
149
  }
147
150
  }
148
- onEvent(event, parsed);
151
+ onEvent(event, parsed, id);
149
152
  }
150
153
  }
151
154
  }
152
155
 
156
+ const streamPost = (url, body, onEvent, signal) => streamSse(url, { method: 'POST', body, signal }, onEvent);
157
+
153
158
  /* --- tabs ---------------------------------------------------------------- */
154
159
 
155
160
  const moreTab = $('#more-tab');
@@ -173,7 +178,7 @@ function activateTab(name) {
173
178
  hideMoreMenu();
174
179
  if (name === 'runs') refreshRuns();
175
180
  if (name === 'providers') refreshProviders();
176
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); }
181
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); }
177
182
  }
178
183
 
179
184
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -760,14 +765,21 @@ function copyMessage(message) {
760
765
  if (write?.catch) write.catch(() => {});
761
766
  }
762
767
 
763
- async function reloadPane(id) {
768
+ async function renderPane(id) {
764
769
  const record = state.panes.get(id);
765
- if (!record) return;
770
+ if (!record) return null;
766
771
  const detail = await api(`/api/sessions/${id}`).catch(() => null);
767
- if (!detail) return;
772
+ if (!detail) return null;
768
773
  record.el.replaceChildren();
769
- for (const stored of detail.messages) renderStored(stored, record.el);
774
+ renderStoredMessages(detail.messages, record.el);
770
775
  if (!detail.messages.length) record.el.appendChild(el('div', 'empty', 'Sesión vacía.'));
776
+ return detail;
777
+ }
778
+
779
+ async function reloadPane(id) {
780
+ const detail = await renderPane(id);
781
+ const record = state.panes.get(id);
782
+ if (detail?.running && record && !record.working) await attachRun(id, record.el);
771
783
  }
772
784
 
773
785
  async function regenerate(message) {
@@ -887,19 +899,165 @@ function renderToolOut(node, text) {
887
899
  }
888
900
  }
889
901
 
890
- function addToolCard(message, name, args) {
891
- const card = el('div', 'tool-card running');
892
- const head = el('div', 'tool-head');
893
- head.append(
894
- el('span', 'tool-glyph', '⏺'),
895
- el('span', 'tool-name', name),
896
- el('span', 'tool-args', truncate(args, 200))
897
- );
902
+ const READ_ONLY_TOOLS = new Set([
903
+ 'read',
904
+ 'list',
905
+ 'glob',
906
+ 'grep',
907
+ 'fetch_url',
908
+ 'git_status',
909
+ 'git_diff',
910
+ 'git_log',
911
+ 'task',
912
+ 'spawn_agent',
913
+ ]);
914
+ const toolCardIndex = new Map();
915
+
916
+ function isReadOnlyTool(name) {
917
+ const value = String(name ?? '');
918
+ if (READ_ONLY_TOOLS.has(value)) return true;
919
+ return /^mcp__.+__(read|list|search|get|fetch|query)/i.test(value);
920
+ }
921
+
922
+ function shortText(value, max = 80) {
923
+ const raw = String(value ?? '').replace(/\s+/g, ' ').trim();
924
+ return raw.length > max ? `${raw.slice(0, max - 1)}…` : raw;
925
+ }
926
+
927
+ function toolSummary(name, args) {
928
+ const a = args && typeof args === 'object' ? args : {};
929
+ switch (name) {
930
+ case 'read': {
931
+ const base = a.path ? shortText(a.path, 60) : '.';
932
+ const offset = Number(a.offset) || 0;
933
+ const limit = Number(a.limit) || 0;
934
+ if (offset && limit) return `${base}:${offset}-${offset + limit - 1}`;
935
+ if (offset) return `${base}:${offset}+`;
936
+ return base;
937
+ }
938
+ case 'write':
939
+ case 'edit':
940
+ return a.path ? shortText(a.path, 60) : 'archivo';
941
+ case 'list':
942
+ return a.path ? shortText(a.path, 60) : '.';
943
+ case 'glob':
944
+ return [a.pattern ? `"${shortText(a.pattern, 50)}"` : '', a.path ? `en ${shortText(a.path, 40)}` : '']
945
+ .filter(Boolean)
946
+ .join(' ');
947
+ case 'grep':
948
+ return [
949
+ a.pattern ? `"${shortText(a.pattern, 50)}"` : '',
950
+ a.include ? shortText(a.include, 30) : '',
951
+ a.path ? `en ${shortText(a.path, 40)}` : '',
952
+ ]
953
+ .filter(Boolean)
954
+ .join(' ');
955
+ case 'bash':
956
+ return shortText(String(a.command ?? '').split('\n')[0], 90);
957
+ case 'fetch_url':
958
+ return shortText(a.url, 90);
959
+ case 'git_diff':
960
+ return [a.ref ? `vs ${shortText(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? shortText(a.path, 40) : '']
961
+ .filter(Boolean)
962
+ .join(' ');
963
+ case 'git_log':
964
+ return a.limit ? `${a.limit} commits` : '';
965
+ case 'git_commit':
966
+ return shortText(a.message, 70);
967
+ case 'task':
968
+ case 'spawn_agent':
969
+ return shortText(a.description || a.prompt, 80);
970
+ default:
971
+ return '';
972
+ }
973
+ }
974
+
975
+ function parseToolArgs(call) {
976
+ const raw = call?.function?.arguments ?? call?.arguments;
977
+ if (raw && typeof raw === 'object') return raw;
978
+ if (typeof raw !== 'string') return {};
979
+ try {
980
+ return JSON.parse(raw);
981
+ } catch {
982
+ return {};
983
+ }
984
+ }
985
+
986
+ function previewText(value, maxLines = 24, maxChars = 4000) {
987
+ const content = typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2) ?? '';
988
+ const lines = content.split('\n');
989
+ let preview = lines.slice(0, maxLines).join('\n');
990
+ let truncated = lines.length > maxLines;
991
+ if (preview.length > maxChars) {
992
+ preview = preview.slice(0, maxChars);
993
+ truncated = true;
994
+ }
995
+ return { preview, truncated };
996
+ }
997
+
998
+ function ensureToolsToggle(message) {
999
+ if (!message.toolsToggle) {
1000
+ const bar = el('div', 'tools-actions');
1001
+ const label = el('span', 'tools-count');
1002
+ const toggle = button('colapsar todo', () => {
1003
+ const details = [...message.tools.querySelectorAll('details.tool-card')];
1004
+ const anyOpen = details.some((node) => node.open);
1005
+ for (const node of details) node.open = !anyOpen;
1006
+ toggle.textContent = anyOpen ? 'expandir todo' : 'colapsar todo';
1007
+ }, 'tools-toggle');
1008
+ bar.append(label, toggle);
1009
+ message.tools.prepend(bar);
1010
+ message.toolsToggle = bar;
1011
+ message.toolsCount = label;
1012
+ message.toolsToggleBtn = toggle;
1013
+ }
1014
+ const total = message.tools.querySelectorAll('details.tool-card').length;
1015
+ const groups = new Set([...message.tools.querySelectorAll('details.tool-card')].map((n) => n.dataset.round ?? ''));
1016
+ message.toolsToggle.hidden = total < 2;
1017
+ message.toolsCount.textContent = groups.size > 1 ? `${total} pasos` : `${total} herramientas`;
1018
+ }
1019
+
1020
+ async function loadFullToolResult(entry, message) {
1021
+ if (entry.full || !entry.callId) return;
1022
+ const sessionId = paneOf(message)?.dataset.session;
1023
+ if (!sessionId) return;
1024
+ entry.more.disabled = true;
1025
+ entry.more.textContent = 'cargando…';
1026
+ const data = await api(
1027
+ `/api/sessions/${sessionId}/tool-result?callId=${encodeURIComponent(entry.callId)}`
1028
+ ).catch(() => null);
1029
+ entry.more.disabled = false;
1030
+ if (data?.content != null) {
1031
+ renderToolOut(entry.out, data.content);
1032
+ entry.full = true;
1033
+ entry.more.hidden = true;
1034
+ } else {
1035
+ entry.more.textContent = 'ver completo';
1036
+ }
1037
+ }
1038
+
1039
+ function addToolCard(message, name, args, { callId = null, open = null, round = null } = {}) {
1040
+ const collapsed = open === null ? isReadOnlyTool(name) : !open;
1041
+ const card = el('details', 'tool-card running');
1042
+ card.open = !collapsed;
1043
+ if (round != null) card.dataset.round = String(round);
1044
+ const head = el('summary', 'tool-head');
1045
+ const glyph = el('span', 'tool-glyph', '⏺');
1046
+ const argsEl = el('span', 'tool-args', toolSummary(name, args));
1047
+ head.append(glyph, el('span', 'tool-name', name), argsEl);
898
1048
  const out = el('div', 'tool-out');
899
- card.append(head, out);
1049
+ const more = button('ver completo', () => loadFullToolResult(entry, message), 'tool-more');
1050
+ more.hidden = true;
1051
+ card.append(head, out, more);
900
1052
  message.tools.appendChild(card);
1053
+ ensureToolsToggle(message);
901
1054
  scrollMessages(paneOf(message));
902
- return { card, out };
1055
+
1056
+ const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
1057
+ card.addEventListener('toggle', () => {
1058
+ if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
1059
+ });
1060
+ return entry;
903
1061
  }
904
1062
 
905
1063
  function addPermissionCard(message, data) {
@@ -957,6 +1115,7 @@ function userImages(content) {
957
1115
  }
958
1116
 
959
1117
  function renderStored(message, pane) {
1118
+ if (message.agent_id) return;
960
1119
  if (message.role === 'user') {
961
1120
  const bubble = addMessage('user', userText(message.content), pane);
962
1121
  if (message.id) {
@@ -972,16 +1131,31 @@ function renderStored(message, pane) {
972
1131
  bubble.wrap.dataset.messageId = message.id;
973
1132
  }
974
1133
  for (const call of message.tool_calls ?? []) {
975
- const tool = addToolCard(
976
- bubble,
977
- call.function?.name ?? call.name,
978
- call.function?.arguments ?? call.arguments ?? ''
979
- );
1134
+ const name = call.function?.name ?? call.name;
1135
+ const tool = addToolCard(bubble, name, parseToolArgs(call), { callId: call.id, open: false });
980
1136
  tool.card.classList.remove('running');
981
1137
  tool.card.classList.add('done');
982
- tool.card.querySelector('.tool-glyph').textContent = '✓';
1138
+ tool.glyph.textContent = '✓';
1139
+ if (call.id) toolCardIndex.set(call.id, tool);
983
1140
  }
984
- }
1141
+ } else if (message.role === 'tool') {
1142
+ const tool = toolCardIndex.get(message.tool_call_id);
1143
+ if (!tool) return;
1144
+ const failed = /^Error|^Permiso denegado/i.test(String(message.content));
1145
+ const { preview, truncated } = previewText(message.content);
1146
+ renderToolOut(tool.out, preview);
1147
+ tool.card.classList.remove('running');
1148
+ tool.card.classList.toggle('failed', failed);
1149
+ if (!failed) tool.card.classList.add('done');
1150
+ tool.glyph.textContent = failed ? '✗' : '✓';
1151
+ tool.truncated = truncated;
1152
+ if (truncated) tool.more.hidden = false;
1153
+ }
1154
+ }
1155
+
1156
+ function renderStoredMessages(messages, pane) {
1157
+ toolCardIndex.clear();
1158
+ for (const message of messages) renderStored(message, pane);
985
1159
  }
986
1160
 
987
1161
  function basename(p) {
@@ -1097,7 +1271,7 @@ async function removeProject(cwd, count) {
1097
1271
 
1098
1272
  /* --- session tabs --------------------------------------------------------- */
1099
1273
 
1100
- function registerSession(session, messages = [], tokens = { in: 0, out: 0 }) {
1274
+ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, running = false) {
1101
1275
  const id = session.id;
1102
1276
  if (state.panes.has(id)) return state.panes.get(id);
1103
1277
  const pane = document.createElement('div');
@@ -1115,10 +1289,11 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }) {
1115
1289
  };
1116
1290
  state.panes.set(id, record);
1117
1291
  state.tabOrder.push(id);
1118
- for (const message of messages) renderStored(message, pane);
1292
+ renderStoredMessages(messages, pane);
1119
1293
  if (!messages.length) {
1120
1294
  pane.appendChild(el('div', 'empty', `Sesión vacía${session.cwd ? ` en ${basename(session.cwd)}` : ''}.`));
1121
1295
  }
1296
+ if (running) attachRun(id, pane);
1122
1297
  return record;
1123
1298
  }
1124
1299
 
@@ -1163,7 +1338,7 @@ async function openTab(id) {
1163
1338
  return null;
1164
1339
  });
1165
1340
  if (!data) return;
1166
- registerSession(data.session, data.messages, data.tokens);
1341
+ registerSession(data.session, data.messages, data.tokens, data.running);
1167
1342
  activateSession(id);
1168
1343
  }
1169
1344
 
@@ -1244,7 +1419,7 @@ async function restoreTabs() {
1244
1419
  for (const id of ids) {
1245
1420
  if (state.panes.has(id)) continue;
1246
1421
  const data = await api(`/api/sessions/${id}`).catch(() => null);
1247
- if (data) registerSession(data.session, data.messages, data.tokens);
1422
+ if (data) registerSession(data.session, data.messages, data.tokens, data.running);
1248
1423
  }
1249
1424
  const active = saved?.active && state.panes.has(saved.active) ? saved.active : state.tabOrder[state.tabOrder.length - 1];
1250
1425
  if (active) activateSession(active);
@@ -1259,6 +1434,248 @@ $('#new-session').addEventListener('click', () => {
1259
1434
  const INIT_PROMPT =
1260
1435
  'Analizá este proyecto (estructura, stack, convenciones, comandos de build/test) y creá o actualizá un archivo AGENTS.md en la raíz con instrucciones claras para futuros agentes. Inspeccioná archivos reales antes de escribir.';
1261
1436
 
1437
+ function createChatTurn(pane, sessionKey) {
1438
+ const turn = {
1439
+ pane,
1440
+ sessionKey,
1441
+ active: null,
1442
+ sawText: false,
1443
+ lastTool: null,
1444
+ startNewOnText: false,
1445
+ agents: new Map(),
1446
+ turnMode: modeSelect.value || 'agent',
1447
+ assistant() {
1448
+ if (!turn.active) {
1449
+ turn.active = addMessage('assistant', '', turn.pane);
1450
+ setMessageMode(turn.active, turn.turnMode);
1451
+ }
1452
+ return turn.active;
1453
+ },
1454
+ flowTarget() {
1455
+ if (turn.startNewOnText) {
1456
+ turn.active = addMessage('assistant', '', turn.pane);
1457
+ setMessageMode(turn.active, turn.turnMode);
1458
+ turn.startNewOnText = false;
1459
+ setActivity(turn.active, 'thinking');
1460
+ }
1461
+ return turn.assistant();
1462
+ },
1463
+ };
1464
+ return turn;
1465
+ }
1466
+
1467
+ function addAgentCard(message, data) {
1468
+ const card = el('details', 'agent-card running');
1469
+ const head = el('summary', 'agent-head');
1470
+ const glyph = el('span', 'agent-glyph', '⏺');
1471
+ const title = el('span', 'agent-title', `subagente · ${data.description ?? 'tarea'}`);
1472
+ const status = el('span', 'agent-status', data.mode === 'plan' ? 'solo lectura' : 'agente');
1473
+ head.append(glyph, title, status);
1474
+ const body = el('div', 'agent-body');
1475
+ const reasoning = el('details', 'reasoning');
1476
+ reasoning.appendChild(el('summary', '', 'razonamiento'));
1477
+ const reasoningEl = el('div', 'body');
1478
+ reasoning.appendChild(reasoningEl);
1479
+ reasoning.hidden = true;
1480
+ const textEl = el('div', 'agent-text');
1481
+ const toolsEl = el('div', 'agent-tools');
1482
+ body.append(reasoning, textEl, toolsEl);
1483
+ card.append(head, body);
1484
+ message.tools.appendChild(card);
1485
+ ensureToolsToggle(message);
1486
+ scrollMessages(paneOf(message));
1487
+ const pseudo = { tools: toolsEl, bubble: card, toolsToggle: null };
1488
+ return {
1489
+ card,
1490
+ glyph,
1491
+ statusEl: status,
1492
+ textEl,
1493
+ reasoning,
1494
+ reasoningEl,
1495
+ toolsEl,
1496
+ message: pseudo,
1497
+ lastTool: null,
1498
+ sawText: false,
1499
+ };
1500
+ }
1501
+
1502
+ function handleAgentEvent(event, data, turn) {
1503
+ if (!data?.agentId) return false;
1504
+ const agent = turn.agents.get(data.agentId);
1505
+ if (!agent) return false;
1506
+ if (event === 'text') {
1507
+ agent.sawText = true;
1508
+ agent.textEl.innerHTML = renderMarkdown(data.full);
1509
+ scrollMessages(paneOf(agent.message));
1510
+ } else if (event === 'reasoning') {
1511
+ agent.reasoning.hidden = false;
1512
+ if (!agent.sawText) agent.reasoningEl.textContent = data.full;
1513
+ } else if (event === 'tool_call') {
1514
+ agent.lastTool = addToolCard(agent.message, data.name, data.args, { callId: data.id });
1515
+ } else if (event === 'tool_result') {
1516
+ if (agent.lastTool) {
1517
+ const failed = data.failed ?? /^Error|^Permiso denegado/i.test(String(data.preview ?? data.result));
1518
+ renderToolOut(agent.lastTool.out, data.preview ?? data.result);
1519
+ agent.lastTool.card.classList.remove('running');
1520
+ agent.lastTool.card.classList.toggle('failed', failed);
1521
+ if (!failed) agent.lastTool.card.classList.add('done');
1522
+ agent.lastTool.glyph.textContent = failed ? '✗' : '✓';
1523
+ if (data.truncated) {
1524
+ agent.lastTool.truncated = true;
1525
+ agent.lastTool.more.hidden = false;
1526
+ }
1527
+ }
1528
+ } else {
1529
+ return false;
1530
+ }
1531
+ return true;
1532
+ }
1533
+
1534
+ function formatDuration(ms) {
1535
+ const value = Number(ms) || 0;
1536
+ if (value <= 0) return '';
1537
+ return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${value}ms`;
1538
+ }
1539
+
1540
+ /** Cierre del turno: siempre deja un resumen visible (rondas, tools, tiempo). */
1541
+ function renderCompletion(message, data) {
1542
+ const tools = Number(data.toolCalls) || 0;
1543
+ const parts = [`${data.rounds ?? '?'} ronda${data.rounds === 1 ? '' : 's'}`];
1544
+ if (tools) parts.push(`${tools} herramienta${tools === 1 ? '' : 's'}`);
1545
+ const duration = formatDuration(data.durationMs);
1546
+ if (duration) parts.push(duration);
1547
+ if (data.tokens?.total_tokens) parts.push(`${Number(data.tokens.total_tokens).toLocaleString('es')} tokens`);
1548
+ const maxed = data.stopReason === 'max_rounds';
1549
+ message.tools.appendChild(el('div', maxed ? 'chip warn' : 'chip ok', `${maxed ? '⚠ máximo de rondas' : '✓ terminado'} · ${parts.join(' · ')}`));
1550
+ scrollMessages(paneOf(message));
1551
+ }
1552
+
1553
+ function handleChatEvent(event, data, turn) {
1554
+ if (handleAgentEvent(event, data, turn)) return;
1555
+ if (event === 'agent_start') {
1556
+ turn.agents.set(data.agentId, addAgentCard(turn.assistant(), data));
1557
+ } else if (event === 'agent_end') {
1558
+ const agent = turn.agents.get(data.agentId);
1559
+ if (agent) {
1560
+ agent.card.classList.remove('running');
1561
+ agent.card.classList.add(data.status === 'error' ? 'failed' : 'done');
1562
+ agent.glyph.textContent = data.status === 'error' ? '✗' : '✓';
1563
+ agent.statusEl.textContent = data.status;
1564
+ if (data.content && !agent.sawText) agent.textEl.textContent = data.content;
1565
+ }
1566
+ } else if (event === 'start') {
1567
+ if (data.cwd) setCwd(data.cwd);
1568
+ if (data.mode) turn.turnMode = data.mode;
1569
+ if (turn.active) setMessageMode(turn.active, turn.turnMode);
1570
+ } else if (event === 'text') {
1571
+ turn.sawText = true;
1572
+ const target = turn.flowTarget();
1573
+ setText(target, data.full);
1574
+ setActivity(target, 'thinking');
1575
+ } else if (event === 'reasoning') {
1576
+ const target = turn.flowTarget();
1577
+ if (!turn.sawText) ensureReasoning(target).textContent = data.full;
1578
+ setActivity(target, 'thinking');
1579
+ } else if (event === 'tools') {
1580
+ turn.startNewOnText = true;
1581
+ } else if (event === 'tool_call') {
1582
+ const target = turn.assistant();
1583
+ setActivity(target, 'working');
1584
+ turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id });
1585
+ } else if (event === 'tool_result') {
1586
+ setActivity(turn.assistant(), 'thinking');
1587
+ if (turn.lastTool) {
1588
+ const failed = data.failed ?? /^Error|^Permiso denegado/i.test(String(data.preview ?? data.result));
1589
+ renderToolOut(turn.lastTool.out, data.preview ?? data.result);
1590
+ turn.lastTool.card.classList.remove('running');
1591
+ turn.lastTool.card.classList.toggle('failed', failed);
1592
+ if (!failed) turn.lastTool.card.classList.add('done');
1593
+ turn.lastTool.glyph.textContent = failed ? '✗' : '✓';
1594
+ if (data.truncated) {
1595
+ turn.lastTool.truncated = true;
1596
+ turn.lastTool.more.hidden = false;
1597
+ }
1598
+ }
1599
+ } else if (event === 'permission') {
1600
+ setActivity(turn.assistant(), 'waiting');
1601
+ addPermissionCard(turn.assistant(), data);
1602
+ } else if (event === 'permission_resolved') {
1603
+ setActivity(turn.assistant(), 'thinking');
1604
+ const entry = state.permissionCards.get(data.askId);
1605
+ if (entry && entry.verdict.textContent === 'requiere aprobación') {
1606
+ entry.card.classList.add('collapsed', data.allow ? 'approved' : 'denied');
1607
+ entry.card.classList.remove('pending');
1608
+ entry.card.querySelector('.perm-icon').textContent = data.allow ? '✓' : '✗';
1609
+ entry.verdict.textContent = data.allow ? 'aprobado' : 'denegado (sin respuesta)';
1610
+ for (const btn of Object.values(entry.buttons)) btn.disabled = true;
1611
+ }
1612
+ } else if (event === 'stall') {
1613
+ if (!turn.sawText) setText(turn.assistant(), `⚠ ${data.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`);
1614
+ } else if (event === 'retry') {
1615
+ if (!turn.sawText) setText(turn.assistant(), `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
1616
+ } else if (event === 'trim') {
1617
+ turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1618
+ } else if (event === 'warning') {
1619
+ turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1620
+ } else if (event === 'error') {
1621
+ setActivity(turn.active, null);
1622
+ turn.assistant().tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
1623
+ } else if (event === 'done') {
1624
+ setActivity(turn.active, null);
1625
+ const finalText = (turn.active?.textEl?.textContent ?? '').trim();
1626
+ const tools = Number(data.toolCalls) || 0;
1627
+ if (!finalText) {
1628
+ if (data.stopReason === 'max_rounds') {
1629
+ setText(
1630
+ turn.assistant(),
1631
+ `⚠ El agente alcanzó el máximo de rondas (${data.rounds ?? '?'}) sin dejar una respuesta final.${
1632
+ tools ? ` Hizo ${tools} llamadas a herramientas.` : ''
1633
+ } Pedile que continúe o subí \`maxRounds\`.`
1634
+ );
1635
+ } else if (tools) {
1636
+ setText(turn.assistant(), '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.');
1637
+ } else {
1638
+ setText(turn.assistant(), '(sin texto)');
1639
+ }
1640
+ }
1641
+ renderCompletion(turn.active, data);
1642
+ updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
1643
+ addTokens(data.tokens);
1644
+ } else if (event === 'aborted') {
1645
+ setActivity(turn.active, null);
1646
+ setText(turn.assistant(), '⏹ cancelado.');
1647
+ }
1648
+ }
1649
+
1650
+ /**
1651
+ * Se engancha a un run en curso: re-renderiza el historial persistido y
1652
+ * reproduce los eventos del turno actual (asi el progreso no se pierde si el
1653
+ * telefono bloqueo la pantalla o se corto la red).
1654
+ */
1655
+ async function attachRun(sessionKey, pane) {
1656
+ const record = state.panes.get(sessionKey);
1657
+ if (record?.working) return;
1658
+ await renderPane(sessionKey);
1659
+ const controller = new AbortController();
1660
+ controllers.set(sessionKey, controller);
1661
+ if (record) setWorking(sessionKey, true);
1662
+ const turn = createChatTurn(pane, sessionKey);
1663
+ try {
1664
+ await streamSse(
1665
+ `/api/sessions/${sessionKey}/stream?from=0`,
1666
+ { signal: controller.signal },
1667
+ (event, data) => handleChatEvent(event, data, turn)
1668
+ );
1669
+ } catch {
1670
+ // si falla la reconexion, el historial persistido queda visible
1671
+ } finally {
1672
+ controllers.delete(sessionKey);
1673
+ if (state.panes.get(sessionKey) === record) setWorking(sessionKey, false);
1674
+ setActivity(turn.active, null);
1675
+ refreshSessions();
1676
+ }
1677
+ }
1678
+
1262
1679
  async function sendMessage() {
1263
1680
  const current = state.panes.get(state.sessionId);
1264
1681
  if (current?.working) return;
@@ -1297,24 +1714,13 @@ async function sendMessage() {
1297
1714
  chatInput.value = '';
1298
1715
  state.attachments = [];
1299
1716
  renderAttachments();
1300
- let active = addMessage('assistant', '', pane);
1717
+
1718
+ const turn = createChatTurn(pane, sessionKey);
1719
+ turn.active = addMessage('assistant', '', pane);
1720
+ setMessageMode(turn.active, turn.turnMode);
1301
1721
  const controller = new AbortController();
1302
1722
  controllers.set(sessionKey, controller);
1303
1723
  setWorking(sessionKey, true);
1304
- let sawText = false;
1305
- let lastTool = null;
1306
- let startNewOnText = false;
1307
- let turnMode = modeSelect.value || 'agent';
1308
-
1309
- const flowTarget = () => {
1310
- if (startNewOnText) {
1311
- active = addMessage('assistant', '', pane);
1312
- setMessageMode(active, turnMode);
1313
- startNewOnText = false;
1314
- setActivity(active, 'thinking');
1315
- }
1316
- return active;
1317
- };
1318
1724
 
1319
1725
  try {
1320
1726
  await streamPost(
@@ -1332,74 +1738,27 @@ async function sendMessage() {
1332
1738
  return context ? Math.floor(context * 0.8) : undefined;
1333
1739
  })(),
1334
1740
  },
1335
- (event, data) => {
1336
- if (event === 'start') {
1337
- if (data.cwd) setCwd(data.cwd);
1338
- if (data.mode) turnMode = data.mode;
1339
- setMessageMode(active, turnMode);
1340
- } else if (event === 'text') {
1341
- sawText = true;
1342
- const target = flowTarget();
1343
- setText(target, data.full);
1344
- setActivity(target, 'thinking');
1345
- } else if (event === 'reasoning') {
1346
- const target = flowTarget();
1347
- if (!sawText) ensureReasoning(target).textContent = data.full;
1348
- setActivity(target, 'thinking');
1349
- } else if (event === 'tools') {
1350
- startNewOnText = true;
1351
- } else if (event === 'tool_call') {
1352
- setActivity(active, 'working');
1353
- lastTool = addToolCard(active, data.name, JSON.stringify(data.args));
1354
- } else if (event === 'tool_result') {
1355
- setActivity(active, 'thinking');
1356
- if (lastTool) {
1357
- const failed = /^Error|^Permiso denegado/i.test(String(data.result));
1358
- renderToolOut(lastTool.out, truncate(data.result));
1359
- lastTool.card.classList.remove('running');
1360
- lastTool.card.classList.add(failed ? 'failed' : 'done');
1361
- lastTool.card.querySelector('.tool-glyph').textContent = failed ? '✗' : '✓';
1362
- }
1363
- } else if (event === 'permission') {
1364
- setActivity(active, 'waiting');
1365
- addPermissionCard(active, data);
1366
- } else if (event === 'permission_resolved') {
1367
- setActivity(active, 'thinking');
1368
- const entry = state.permissionCards.get(data.askId);
1369
- if (entry && entry.verdict.textContent === 'requiere aprobación') {
1370
- entry.card.classList.add('collapsed', data.allow ? 'approved' : 'denied');
1371
- entry.card.classList.remove('pending');
1372
- entry.card.querySelector('.perm-icon').textContent = data.allow ? '✓' : '✗';
1373
- entry.verdict.textContent = data.allow ? 'aprobado' : 'denegado (sin respuesta)';
1374
- for (const btn of Object.values(entry.buttons)) btn.disabled = true;
1375
- }
1376
- } else if (event === 'stall') {
1377
- if (!sawText) setText(active, `⚠ ${data.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`);
1378
- } else if (event === 'retry') {
1379
- if (!sawText) setText(active, `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
1380
- } else if (event === 'trim') {
1381
- active.tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1382
- } else if (event === 'warning') {
1383
- active.tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1384
- } else if (event === 'error') {
1385
- setActivity(active, null);
1386
- active.tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
1387
- } else if (event === 'done') {
1388
- setActivity(active, null);
1389
- if (!data.content && !sawText) setText(active, '(sin texto)');
1390
- updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
1391
- addTokens(data.tokens);
1392
- }
1393
- },
1741
+ (event, data) => handleChatEvent(event, data, turn),
1394
1742
  controller.signal
1395
1743
  );
1396
1744
  } catch (error) {
1397
- setActivity(active, null);
1398
- if (error.name === 'AbortError') setText(active, '⏹ cancelado.');
1399
- else setText(active, `Error: ${error.message}`);
1745
+ if (error.name === 'AbortError') {
1746
+ setActivity(turn.active, null);
1747
+ setText(turn.assistant(), '⏹ cancelado.');
1748
+ } else {
1749
+ const detail = await api(`/api/sessions/${sessionKey}`).catch(() => null);
1750
+ if (detail?.running) {
1751
+ controllers.delete(sessionKey);
1752
+ setWorking(sessionKey, false);
1753
+ await attachRun(sessionKey, pane);
1754
+ } else {
1755
+ setActivity(turn.active, null);
1756
+ setText(turn.assistant(), `Error: ${error.message}`);
1757
+ }
1758
+ }
1400
1759
  } finally {
1401
1760
  controllers.delete(sessionKey);
1402
- setActivity(active, null);
1761
+ setActivity(turn.active, null);
1403
1762
  setWorking(sessionKey, false);
1404
1763
  refreshSessions();
1405
1764
  }
@@ -1407,7 +1766,9 @@ async function sendMessage() {
1407
1766
 
1408
1767
  if (stopBtn) {
1409
1768
  stopBtn.addEventListener('click', () => {
1410
- const controller = controllers.get(state.sessionId);
1769
+ const id = state.sessionId;
1770
+ if (id) api(`/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
1771
+ const controller = controllers.get(id);
1411
1772
  if (controller) controller.abort();
1412
1773
  });
1413
1774
  }
@@ -2035,6 +2396,10 @@ const mcpForm = $('#mcp-form');
2035
2396
  const skillListEl = $('#skill-list');
2036
2397
  const skillModal = $('#skill-modal');
2037
2398
  const skillForm = $('#skill-form');
2399
+ const tgTokenEl = $('#tg-token');
2400
+ const tgChatsEl = $('#tg-chats');
2401
+ const tgTranscribeEl = $('#tg-transcribe');
2402
+ const tgStatusEl = $('#tg-status');
2038
2403
 
2039
2404
  function setStatus(node, text, cls = '') {
2040
2405
  const elNode = $(node);
@@ -2249,6 +2614,55 @@ async function submitSkill(event) {
2249
2614
  refreshSkills();
2250
2615
  }
2251
2616
 
2617
+ async function refreshChannels() {
2618
+ if (!tgStatusEl) return;
2619
+ const data = await api('/api/channels').catch(() => null);
2620
+ if (!data?.telegram) return;
2621
+ const tg = data.telegram;
2622
+ tgStatusEl.className = `summary${tg.running ? ' ok' : ''}`;
2623
+ tgStatusEl.textContent = tg.running
2624
+ ? `activo${tg.tokenHint ? ` · ${tg.tokenHint}` : ''}`
2625
+ : tg.tokenSet
2626
+ ? 'token guardado (bot detenido)'
2627
+ : 'sin configurar';
2628
+ if (tgChatsEl && document.activeElement !== tgChatsEl) {
2629
+ tgChatsEl.value = (tg.allowedChatIds ?? []).join(', ');
2630
+ }
2631
+ if (tgTranscribeEl && document.activeElement !== tgTranscribeEl) {
2632
+ tgTranscribeEl.value = tg.transcribeModel ?? 'whisper-1';
2633
+ }
2634
+ if (tgTokenEl && document.activeElement !== tgTokenEl) {
2635
+ tgTokenEl.value = '';
2636
+ tgTokenEl.placeholder = tg.tokenHint ? `${tg.tokenHint} (dejar vacío para no cambiar)` : '123456:ABC…';
2637
+ }
2638
+ }
2639
+
2640
+ async function submitChannel() {
2641
+ if (!tgTokenEl) return;
2642
+ setStatus('#tg-msg', 'guardando…');
2643
+ const body = {
2644
+ allowedChatIds: tgChatsEl?.value ?? '',
2645
+ transcribeModel: tgTranscribeEl?.value.trim() || 'whisper-1',
2646
+ };
2647
+ const token = tgTokenEl.value.trim();
2648
+ if (token) body.token = token;
2649
+ const data = await api('/api/channels/telegram', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
2650
+ if (data?.error) return setStatus('#tg-msg', data.error, 'err');
2651
+ setStatus('#tg-msg', data.telegram?.running ? 'guardado · bot activo' : 'guardado', 'ok');
2652
+ refreshChannels();
2653
+ }
2654
+
2655
+ async function testChannel() {
2656
+ if (!tgTokenEl) return;
2657
+ setStatus('#tg-msg', 'probando…');
2658
+ const data = await api('/api/channels/telegram/test', {
2659
+ method: 'POST',
2660
+ body: { token: tgTokenEl.value.trim() },
2661
+ }).catch((e) => ({ ok: false, error: e.message }));
2662
+ if (data?.ok) setStatus('#tg-msg', `token OK${data.username ? ` · @${data.username}` : ''}`, 'ok');
2663
+ else setStatus('#tg-msg', data?.error || 'token inválido', 'err');
2664
+ }
2665
+
2252
2666
  async function importMcp() {
2253
2667
  const input = $('#mcp-import');
2254
2668
  const value = input.value.trim();
@@ -2296,6 +2710,8 @@ if (mcpListEl) {
2296
2710
  }
2297
2711
  });
2298
2712
  $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
2713
+ $('#tg-save')?.addEventListener('click', submitChannel);
2714
+ $('#tg-test')?.addEventListener('click', testChannel);
2299
2715
  $('#mcp-close').addEventListener('click', closeMcpForm);
2300
2716
  $('#mf-cancel').addEventListener('click', closeMcpForm);
2301
2717
  $('#mf-transport').addEventListener('change', syncMcpFormFields);