@danieltmn/tamnora 0.6.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.6.1] - 2026-10-05
9
+
10
+ ### Cambios
11
+
12
+ - feat(consola): configura Telegram desde la consola y cierra el turno con resumen
13
+ - docs(website): actividad colapsable, runs durables, subagentes y Telegram
14
+
8
15
  ## [0.6.0] - 2026-10-04
9
16
 
10
17
  ### Cambios
package/README.md CHANGED
@@ -81,6 +81,12 @@ menu "mas" (los tres puntitos). En el chat:
81
81
  - **`/new`** abre otra sesion en la **misma carpeta** de la sesion activa; el boton
82
82
  "+ nueva sesion" (y `/new` sin sesion) abre el selector de carpetas. La
83
83
  **carpeta predeterminada** se elige en Ajustes.
84
+ - **Actividad colapsable**: las tools de lectura (`read`, `list`, `glob`, `grep`,
85
+ `fetch_url`, `git_*`) salen **plegadas** y al hacer click se ve el detalle
86
+ (args + resultado); el volcado completo se pide bajo demanda.
87
+ - **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
88
+ volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
89
+ el boton de detener (o `POST /api/sessions/:id/stop`).
84
90
 
85
91
  ### Preview
86
92
 
@@ -120,6 +126,22 @@ URL publica **y un QR** para abrirlo desde el celular. Trabaja protegido por el
120
126
  login del server: configura un usuario antes de exponerlo (ver
121
127
  [`SECURITY.md`](SECURITY.md)).
122
128
 
129
+ ### Telegram (canales de mensajeria)
130
+
131
+ Con un token de bot (`telegram.token` en `tamnora.json`, `app.json` o
132
+ `TAMNORA_TELEGRAM_TOKEN`) podes manejar el harness desde el chat. Cada chat mapea
133
+ a una sesion y el bot soporta:
134
+
135
+ - texto, **imagenes** y **notas de voz** (transcripcion con `telegram.transcribeModel`,
136
+ default `whisper-1`, via `POST {baseUrl}/audio/transcriptions`);
137
+ - aprobaciones `ask` con **botones inline** (permitir/denegar);
138
+ - comandos `/new`, `/status`, `/model`, `/provider`, `/mode`, `/stop`, `/help`;
139
+ - **notificaciones**: si un run de la consola pertenece a una sesion vinculada a un
140
+ chat, el canal avisa el fin, los errores, los subagentes y los permisos.
141
+
142
+ `server/src/lib/channels/` define la interfaz (`stop`, `notify`); sumar otro
143
+ proveedor es implementarla.
144
+
123
145
  ## MCP
124
146
 
125
147
  Los servidores MCP se configuran en `tamnora.json` (`mcp[]`) y soportan HTTP y
@@ -136,6 +158,14 @@ Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
136
158
  esta detras de Cloudflare; para esos casos, registra un MCP de navegador (por
137
159
  ejemplo Playwright).
138
160
 
161
+ ## Subagentes
162
+
163
+ El agente puede delegar una subtarea con la tool `task`: lanza un **subagente**
164
+ con contexto y toolset propios (solo lectura por defecto, modo `plan`) y devuelve
165
+ un resumen. En la consola la actividad del subagente se anida en una tarjeta
166
+ plegable; el limite de rondas se ajusta con `agents.maxRounds`. La tool `task`
167
+ tambien esta disponible en modo `plan` (exploracion delegada).
168
+
139
169
  ## Documentacion
140
170
 
141
171
  - [`CHANGELOG.md`](CHANGELOG.md) — historial de versiones.
@@ -178,7 +178,7 @@ function activateTab(name) {
178
178
  hideMoreMenu();
179
179
  if (name === 'runs') refreshRuns();
180
180
  if (name === 'providers') refreshProviders();
181
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); }
181
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); }
182
182
  }
183
183
 
184
184
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -1531,6 +1531,25 @@ function handleAgentEvent(event, data, turn) {
1531
1531
  return true;
1532
1532
  }
1533
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
+
1534
1553
  function handleChatEvent(event, data, turn) {
1535
1554
  if (handleAgentEvent(event, data, turn)) return;
1536
1555
  if (event === 'agent_start') {
@@ -1603,7 +1622,23 @@ function handleChatEvent(event, data, turn) {
1603
1622
  turn.assistant().tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
1604
1623
  } else if (event === 'done') {
1605
1624
  setActivity(turn.active, null);
1606
- if (!data.content && !turn.sawText) setText(turn.assistant(), '(sin texto)');
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);
1607
1642
  updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
1608
1643
  addTokens(data.tokens);
1609
1644
  } else if (event === 'aborted') {
@@ -2361,6 +2396,10 @@ const mcpForm = $('#mcp-form');
2361
2396
  const skillListEl = $('#skill-list');
2362
2397
  const skillModal = $('#skill-modal');
2363
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');
2364
2403
 
2365
2404
  function setStatus(node, text, cls = '') {
2366
2405
  const elNode = $(node);
@@ -2575,6 +2614,55 @@ async function submitSkill(event) {
2575
2614
  refreshSkills();
2576
2615
  }
2577
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
+
2578
2666
  async function importMcp() {
2579
2667
  const input = $('#mcp-import');
2580
2668
  const value = input.value.trim();
@@ -2622,6 +2710,8 @@ if (mcpListEl) {
2622
2710
  }
2623
2711
  });
2624
2712
  $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
2713
+ $('#tg-save')?.addEventListener('click', submitChannel);
2714
+ $('#tg-test')?.addEventListener('click', testChannel);
2625
2715
  $('#mcp-close').addEventListener('click', closeMcpForm);
2626
2716
  $('#mf-cancel').addEventListener('click', closeMcpForm);
2627
2717
  $('#mf-transport').addEventListener('change', syncMcpFormFields);
@@ -33,7 +33,7 @@
33
33
  </head>
34
34
  <body>
35
35
  <header class="topbar">
36
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.6.0</span></div>
36
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.6.1</span></div>
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>
@@ -377,6 +377,41 @@
377
377
  <div id="skill-import-status" class="summary"></div>
378
378
  <ul id="skill-list" class="providers"></ul>
379
379
  </div>
380
+
381
+ <div class="panel">
382
+ <div class="row between">
383
+ <h2>Telegram</h2>
384
+ <span id="tg-status" class="summary">cargando…</span>
385
+ </div>
386
+ <p class="muted verify-line">
387
+ Bot por long polling (no necesita URL pública). El token vive en <code>app.json</code>
388
+ (la casa) y nunca se muestra completo.
389
+ </p>
390
+ <label class="field"><span>token del bot</span>
391
+ <input id="tg-token" type="password" autocomplete="off" spellcheck="false"
392
+ placeholder="123456:ABC… (dejar vacío para no cambiar)" />
393
+ </label>
394
+ <label class="field"><span>chats autorizados (ids separados por coma)</span>
395
+ <input id="tg-chats" spellcheck="false" placeholder="123456789, -1001234567890" />
396
+ </label>
397
+ <label class="field"><span>modelo de transcripción (voz)</span>
398
+ <input id="tg-transcribe" spellcheck="false" placeholder="whisper-1" />
399
+ </label>
400
+ <details class="tg-help">
401
+ <summary>¿Cómo habilito mi chat?</summary>
402
+ <ol>
403
+ <li>Creá el bot con <b>@BotFather</b> (<code>/newbot</code>) y pegá el token acá.</li>
404
+ <li>Guardá y mandale cualquier mensaje al bot: si tu chat no está autorizado, te
405
+ responde tu <b>chat id</b>.</li>
406
+ <li>Agregá ese id a <strong>chats autorizados</strong> y volvé a guardar.</li>
407
+ </ol>
408
+ </details>
409
+ <div class="row">
410
+ <button id="tg-save" class="btn primary" type="button">guardar</button>
411
+ <button id="tg-test" class="btn" type="button">probar token</button>
412
+ <span id="tg-msg" class="summary"></span>
413
+ </div>
414
+ </div>
380
415
  </section>
381
416
 
382
417
  <dialog id="folder-modal" class="modal folder-modal">
@@ -613,6 +613,8 @@ body.resizing * { cursor: col-resize !important; }
613
613
  word-break: break-word;
614
614
  }
615
615
  .chip.err { border-left-color: var(--danger); color: var(--danger); }
616
+ .chip.ok { border-left-color: var(--ok); color: var(--ok); }
617
+ .chip.warn { border-left-color: var(--warn); color: var(--warn); }
616
618
  details.reasoning { margin-top: 6px; }
617
619
  details.reasoning > summary { color: var(--muted); cursor: pointer; font-size: 12.5px; }
618
620
  details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top: 4px; border-left: 1px solid var(--border); padding-left: 10px; }
@@ -1,43 +1,89 @@
1
1
  // Capa de canales de mensajeria. Hoy implementa Telegram; la interfaz comun
2
- // (`stop`, `notify`) permite sumar otros proveedores mas adelante.
2
+ // (`stop`, `notify`) permite sumar otros proveedores mas adelante. El manager
3
+ // permite recargar los canales en caliente tras cambiar la configuracion.
3
4
 
4
5
  import { startTelegramChannel } from './telegram.js';
5
6
 
7
+ function tokenHint(token) {
8
+ const value = String(token ?? '');
9
+ if (!value) return '';
10
+ if (value.includes('•')) return value;
11
+ return value.length <= 6 ? '••••' : `••••${value.slice(-4)}`;
12
+ }
13
+
14
+ function parseAllowed(raw) {
15
+ if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
16
+ return String(raw ?? '')
17
+ .split(/[,\s]+/)
18
+ .map((s) => s.trim())
19
+ .filter(Boolean);
20
+ }
21
+
6
22
  /**
7
- * Arranca los canales configurados y los suscribe a los eventos de los runs
8
- * para avisar a los chats vinculados (por ejemplo, de la consola).
23
+ * Crea el manager de canales. `load()` arranca (o reinicia) los canales segun
24
+ * la config efectiva (app.json + tamnora.json + entorno), `status()` la resume
25
+ * y `stop()` los baja.
9
26
  */
10
- export function startChannels(ctx, { log = () => {} } = {}) {
11
- const tgConfig = { ...(ctx.app?.telegram ?? {}), ...(ctx.config?.telegram ?? {}) };
12
- const token = tgConfig.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
13
- const rawAllow = tgConfig.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? [];
14
- const allowedChatIds = Array.isArray(rawAllow) ? rawAllow : String(rawAllow).split(/[,\s]+/).filter(Boolean);
15
-
16
- const channels = [];
17
- const telegram = startTelegramChannel(ctx, {
18
- token,
19
- allowedChatIds,
20
- transcribeModel: tgConfig.transcribeModel,
21
- log,
22
- });
23
- if (telegram) channels.push(telegram);
24
-
25
- const unsubscribe = ctx.runs?.subscribeAll?.((sessionId, event) => {
26
- for (const channel of channels) {
27
- try {
28
- channel.notify?.(sessionId, event);
29
- } catch {
30
- // un canal caido no debe frenar el run
31
- }
32
- }
33
- });
34
-
35
- return {
36
- channels,
37
- enabled: channels.length > 0,
38
- stop() {
39
- unsubscribe?.();
40
- for (const channel of channels) channel.stop?.();
41
- },
27
+ export function createChannelsManager(ctx, { log = () => {}, enabled = true } = {}) {
28
+ let active = null;
29
+
30
+ // app.json (global) es la base; tamnora.json solo pisa con valores definidos
31
+ // (evita que sus defaults vacios borren lo guardado desde la consola).
32
+ const readConfig = () => {
33
+ const app = ctx.app?.telegram ?? {};
34
+ const cfg = ctx.config?.telegram ?? {};
35
+ const tg = { ...app };
36
+ if (cfg.token) tg.token = cfg.token;
37
+ if (Array.isArray(cfg.allowedChatIds) && cfg.allowedChatIds.length) tg.allowedChatIds = cfg.allowedChatIds;
38
+ if (cfg.transcribeModel) tg.transcribeModel = cfg.transcribeModel;
39
+ return tg;
42
40
  };
41
+
42
+ function effective() {
43
+ const tg = readConfig();
44
+ const token = tg.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
45
+ const allowedChatIds = parseAllowed(tg.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? []);
46
+ return { token, allowedChatIds, transcribeModel: tg.transcribeModel };
47
+ }
48
+
49
+ function stop() {
50
+ active?.unsubscribe?.();
51
+ for (const channel of active?.channels ?? []) channel.stop?.();
52
+ active = null;
53
+ ctx.telegram = false;
54
+ }
55
+
56
+ function load() {
57
+ stop();
58
+ if (!enabled) return { enabled: false, channels: [] };
59
+ const { token, allowedChatIds, transcribeModel } = effective();
60
+ const channels = [];
61
+ const telegram = startTelegramChannel(ctx, { token, allowedChatIds, transcribeModel, log });
62
+ if (telegram) channels.push(telegram);
63
+ const unsubscribe = ctx.runs?.subscribeAll?.((sessionId, event) => {
64
+ for (const channel of channels) {
65
+ try {
66
+ channel.notify?.(sessionId, event);
67
+ } catch {
68
+ // un canal caido no debe frenar el run
69
+ }
70
+ }
71
+ });
72
+ active = { channels, unsubscribe };
73
+ ctx.telegram = channels.length > 0;
74
+ return { enabled: ctx.telegram, channels };
75
+ }
76
+
77
+ function status() {
78
+ const { token, allowedChatIds, transcribeModel } = effective();
79
+ return {
80
+ tokenSet: Boolean(token),
81
+ tokenHint: tokenHint(token),
82
+ allowedChatIds,
83
+ transcribeModel: transcribeModel || 'whisper-1',
84
+ running: Boolean(active && active.channels.length),
85
+ };
86
+ }
87
+
88
+ return { load, stop, status, readConfig };
43
89
  }
@@ -3,7 +3,7 @@
3
3
  // inline. Cada chat mapea a una sesion del harness.
4
4
 
5
5
  import { randomUUID } from 'node:crypto';
6
- import { runAgent, createTools, resolveMode, summarizeToolCall } from '../core.js';
6
+ import { runAgent, createTools, resolveMode, summarizeToolCall, listProviderModels, PROVIDER_META } from '../core.js';
7
7
  import { resolveExistingDir } from '../workspace.js';
8
8
 
9
9
  const API = 'https://api.telegram.org';
@@ -11,11 +11,12 @@ const API = 'https://api.telegram.org';
11
11
  const HELP = [
12
12
  'Tamnora Harness — bot',
13
13
  '',
14
+ '/menu — panel con botones (modelo, modo, proveedor)',
14
15
  '/new — nueva sesión',
15
16
  '/status — proveedor, modelo, modo y sesión',
16
- '/model <id> — cambia el modelo',
17
- '/provider <id> — cambia el proveedor',
18
- '/mode plan|build — cambia el modo',
17
+ '/model [id] — cambia el modelo (sin id: lista con botones)',
18
+ '/provider [id] — cambia el proveedor (sin id: lista)',
19
+ '/mode [plan|build] — cambia el modo (sin arg: panel)',
19
20
  '/stop — cancela la respuesta en curso',
20
21
  '/help — esta ayuda',
21
22
  '',
@@ -42,6 +43,17 @@ async function tg(token, method, payload) {
42
43
  return data.result;
43
44
  }
44
45
 
46
+ /** Valida un token consultando `getMe`. Devuelve `{ ok, username, name }` o `{ ok:false, error }`. */
47
+ export async function getTelegramBotInfo(token) {
48
+ if (!token) return { ok: false, error: 'Falta el token.' };
49
+ try {
50
+ const result = await tg(token, 'getMe', {});
51
+ return { ok: true, username: result?.username ?? null, name: result?.first_name ?? null };
52
+ } catch (error) {
53
+ return { ok: false, error: error?.message ?? String(error) };
54
+ }
55
+ }
56
+
45
57
  async function downloadFile(token, fileId) {
46
58
  const file = await tg(token, 'getFile', { file_id: fileId });
47
59
  const res = await fetch(`${API}/file/bot${token}/${file.file_path}`);
@@ -58,6 +70,15 @@ function splitMessage(text, max = 3900) {
58
70
 
59
71
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
60
72
 
73
+ /** Linea de cierre del turno (rondas, herramientas, duración). */
74
+ export function completionFooter(stopReason, rounds, toolCalls, durationMs) {
75
+ const parts = [`${rounds ?? '?'} ronda${rounds === 1 ? '' : 's'}`];
76
+ if (toolCalls) parts.push(`${toolCalls} herramienta${toolCalls === 1 ? '' : 's'}`);
77
+ if (durationMs > 0) parts.push(durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`);
78
+ const maxed = stopReason === 'max_rounds';
79
+ return `${maxed ? '⚠ máximo de rondas' : '✓ terminado'} · ${parts.join(' · ')}`;
80
+ }
81
+
61
82
  /**
62
83
  * Arranca el canal de Telegram. Devuelve `{ stop, notify }` o `null` sin token.
63
84
  */
@@ -66,6 +87,7 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
66
87
  const allowed = new Set(allowedChatIds.map(String));
67
88
  const controllers = new Map();
68
89
  const ownSessions = new Set();
90
+ const modelChoices = new Map();
69
91
  let running = true;
70
92
  let offset = 0;
71
93
 
@@ -193,6 +215,7 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
193
215
  const controller = new AbortController();
194
216
  controllers.set(String(chatId), controller);
195
217
  ownSessions.add(sessionId);
218
+ const startedAt = Date.now();
196
219
  const message = await send(chatId, '…');
197
220
  let full = '';
198
221
  let activity = '';
@@ -232,8 +255,24 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
232
255
  }
233
256
  },
234
257
  });
235
- await edit(chatId, message.message_id, result.content?.trim() || full || '(sin texto)');
236
- ctx.store.addMessages(sessionId, result.messages.slice(messages.length));
258
+ const newMessages = result.messages.slice(messages.length);
259
+ const toolCalls = newMessages.filter((m) => m.role === 'tool').length;
260
+ const durationMs = Date.now() - startedAt;
261
+ let body = result.content?.trim() || full.trim();
262
+ if (!body) {
263
+ if (result.stopReason === 'max_rounds') {
264
+ body = `⚠ El agente alcanzó el máximo de rondas (${result.rounds}) sin respuesta final.${
265
+ toolCalls ? ` Hizo ${toolCalls} herramientas.` : ''
266
+ } Pedile que continúe o subí maxRounds.`;
267
+ } else if (toolCalls) {
268
+ body = '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.';
269
+ } else {
270
+ body = '(sin texto)';
271
+ }
272
+ }
273
+ const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
274
+ await edit(chatId, message.message_id, `${body}\n\n— ${footer}`);
275
+ ctx.store.addMessages(sessionId, newMessages);
237
276
  ctx.store.touchSession(sessionId, { provider: providerId, model });
238
277
  } catch (error) {
239
278
  await edit(chatId, message.message_id, error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`);
@@ -244,12 +283,71 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
244
283
  }
245
284
  }
246
285
 
286
+ async function sendMenu(chatId) {
287
+ const sessionId = ensureSession(chatId);
288
+ const session = ctx.store.getSession(sessionId);
289
+ const providerId = session?.provider || ctx.effectiveProviderId();
290
+ const model = session?.model || ctx.effectiveModel() || '—';
291
+ const mode = resolveMode(ctx.effectiveMode());
292
+ const text = ['⚙ Panel', `proveedor: ${providerId}`, `modelo: ${model}`, `modo: ${mode.label}`].join('\n');
293
+ await send(chatId, text, {
294
+ reply_markup: {
295
+ inline_keyboard: [
296
+ [
297
+ { text: mode.id === 'plan' ? '● Plan' : 'Plan', callback_data: 'mode:plan' },
298
+ { text: mode.id === 'agent' ? '● Build' : 'Build', callback_data: 'mode:agent' },
299
+ ],
300
+ [
301
+ { text: 'modelos', callback_data: 'menu:models' },
302
+ { text: 'proveedores', callback_data: 'menu:providers' },
303
+ ],
304
+ [
305
+ { text: '🆕 nueva', callback_data: 'menu:new' },
306
+ { text: '⏹ detener', callback_data: 'menu:stop' },
307
+ ],
308
+ ],
309
+ },
310
+ });
311
+ }
312
+
313
+ async function sendModels(chatId) {
314
+ const sessionId = ensureSession(chatId);
315
+ const providerId = ctx.store.getSession(sessionId)?.provider || ctx.effectiveProviderId();
316
+ const models = await listProviderModels({
317
+ ...ctx.providerSettings(providerId),
318
+ apiKey: ctx.apiKeyFor(providerId),
319
+ }).catch(() => []);
320
+
321
+ const current = ctx.store.getSession(sessionId)?.model || ctx.effectiveModel();
322
+ if (!models?.length) return send(chatId, 'No pude listar modelos. Usá /model <id>.');
323
+ const capped = models.slice(0, 12);
324
+ modelChoices.set(String(chatId), capped.map((m) => m.id));
325
+ const rows = capped.map((m, i) => [
326
+ { text: `${m.id === current ? '● ' : ''}${m.label || m.id}`, callback_data: `model:${i}` },
327
+ ]);
328
+ rows.push([{ text: '‹ panel', callback_data: 'menu:home' }]);
329
+ await send(chatId, `Modelos de ${providerId} (elegí uno):`, { reply_markup: { inline_keyboard: rows } });
330
+ if (models.length > capped.length) {
331
+ send(chatId, `Hay ${models.length} modelos; mostré 12. Para otro, usá /model <id>.`).catch(() => {});
332
+ }
333
+ }
334
+
335
+ async function sendProviders(chatId) {
336
+ const custom = ctx.customProviderDefs?.() ?? [];
337
+ const ids = [...new Set([...PROVIDER_META.map((p) => p.id), ...custom.map((p) => p.id)])];
338
+ const current = ctx.effectiveProviderId();
339
+ const rows = ids.map((id) => [{ text: `${id === current ? '● ' : ''}${id}`, callback_data: `provider:${id}` }]);
340
+ rows.push([{ text: '‹ panel', callback_data: 'menu:home' }]);
341
+ await send(chatId, 'Proveedores (elegí uno):', { reply_markup: { inline_keyboard: rows } });
342
+ }
343
+
247
344
  async function handleCommand(chatId, text) {
248
345
  const [raw, ...rest] = text.slice(1).split(/\s+/);
249
346
  const cmd = raw.toLowerCase();
250
347
  const arg = rest.join(' ').trim();
251
348
  const sessionId = ctx.store.getSetting(sessionKey(chatId));
252
349
  if (cmd === 'start' || cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
350
+ if (cmd === 'menu' || cmd === 'panel' || cmd === 'config') return sendMenu(chatId);
253
351
  if (cmd === 'new' || cmd === 'nueva') {
254
352
  ctx.store.setSetting(sessionKey(chatId), null);
255
353
  return send(chatId, 'Nueva sesión. Mandá tu mensaje.');
@@ -261,20 +359,20 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
261
359
  `proveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
262
360
  );
263
361
  }
264
- if (cmd === 'model') {
265
- if (!arg) return send(chatId, `modelo actual: ${ctx.effectiveModel() || '—'}`);
362
+ if (cmd === 'model' || cmd === 'modelo') {
363
+ if (!arg) return sendModels(chatId);
266
364
  ctx.store.setSetting('activeModel', arg);
267
365
  if (sessionId) ctx.store.touchSession(sessionId, { model: arg });
268
366
  return send(chatId, `modelo: ${arg}`);
269
367
  }
270
- if (cmd === 'provider') {
271
- if (!arg) return send(chatId, `proveedor actual: ${ctx.effectiveProviderId()}`);
368
+ if (cmd === 'provider' || cmd === 'proveedor') {
369
+ if (!arg) return sendProviders(chatId);
272
370
  ctx.store.setSetting('activeProvider', arg);
273
371
  if (sessionId) ctx.store.touchSession(sessionId, { provider: arg });
274
372
  return send(chatId, `proveedor: ${arg}`);
275
373
  }
276
374
  if (cmd === 'mode' || cmd === 'modo') {
277
- if (!arg) return send(chatId, `modo actual: ${ctx.effectiveMode()}`);
375
+ if (!arg) return sendMenu(chatId);
278
376
  const id = arg.toLowerCase() === 'build' ? 'agent' : arg.toLowerCase();
279
377
  ctx.store.setSetting('activeMode', resolveMode(id).id);
280
378
  return send(chatId, `modo: ${resolveMode(id).label}`);
@@ -329,10 +427,46 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
329
427
  }
330
428
 
331
429
  async function handleCallback(query) {
332
- const [_, askId, action] = String(query.data ?? '').split(':');
333
- if (askId) ctx.approvals.decide(askId, action === 'allow');
334
- await tg(token, 'answerCallbackQuery', { callback_query_id: query.id, text: action === 'allow' ? 'Permitido' : 'Denegado' }).catch(() => null);
335
- if (query.message) {
430
+ const chatId = String(query.message?.chat.id ?? '');
431
+ const [kind, a, b] = String(query.data ?? '').split(':');
432
+ let toast = '';
433
+
434
+ if (kind === 'perm') {
435
+ ctx.approvals.decide(a, b === 'allow');
436
+ toast = b === 'allow' ? 'Permitido' : 'Denegado';
437
+ } else if (kind === 'mode') {
438
+ ctx.store.setSetting('activeMode', resolveMode(a).id);
439
+ toast = `Modo ${resolveMode(a).label}`;
440
+ } else if (kind === 'provider') {
441
+ ctx.store.setSetting('activeProvider', a);
442
+ const sid = ctx.store.getSetting(sessionKey(chatId));
443
+ if (sid) ctx.store.touchSession(sid, { provider: a });
444
+ toast = `Proveedor ${a}`;
445
+ } else if (kind === 'model') {
446
+ const id = (modelChoices.get(chatId) ?? [])[Number(a)];
447
+ if (id) {
448
+ ctx.store.setSetting('activeModel', id);
449
+ const sid = ctx.store.getSetting(sessionKey(chatId));
450
+ if (sid) ctx.store.touchSession(sid, { model: id });
451
+ toast = `Modelo ${id}`;
452
+ }
453
+ } else if (kind === 'menu') {
454
+ if (a === 'models') await sendModels(chatId);
455
+ else if (a === 'providers') await sendProviders(chatId);
456
+ else if (a === 'home') await sendMenu(chatId);
457
+ else if (a === 'new') {
458
+ ctx.store.setSetting(sessionKey(chatId), null);
459
+ await send(chatId, 'Nueva sesión. Mandá tu mensaje.');
460
+ } else if (a === 'stop') {
461
+ controllers.get(chatId)?.abort();
462
+ await send(chatId, 'Cancelando…');
463
+ }
464
+ toast = 'ok';
465
+ }
466
+
467
+ await tg(token, 'answerCallbackQuery', { callback_query_id: query.id, text: toast }).catch(() => null);
468
+ // Solo los permisos cierran su tarjeta; el panel conserva los botones.
469
+ if (kind === 'perm' && query.message) {
336
470
  await tg(token, 'editMessageReplyMarkup', { chat_id: query.message.chat.id, message_id: query.message.message_id }).catch(() => null);
337
471
  }
338
472
  }
@@ -0,0 +1,47 @@
1
+ // Configuracion de los canales de mensajeria desde la consola. La config vive
2
+ // en app.json (casa); al guardar se recargan los canales en caliente.
3
+
4
+ import { readApp, writeApp } from '../lib/core.js';
5
+ import { getTelegramBotInfo } from '../lib/channels/telegram.js';
6
+ import { readJson, sendJson } from '../lib/http.js';
7
+
8
+ function sanitizeAllowed(value) {
9
+ if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter(Boolean);
10
+ return String(value ?? '')
11
+ .split(/[,\s]+/)
12
+ .map((s) => s.trim())
13
+ .filter(Boolean);
14
+ }
15
+
16
+ export function registerChannels(route, ctx) {
17
+ route('GET', '/api/channels', ({ res }) => {
18
+ sendJson(res, 200, { telegram: ctx.channels.status() });
19
+ });
20
+
21
+ route('PUT', '/api/channels/telegram', async ({ req, res }) => {
22
+ const body = await readJson(req);
23
+ const app = readApp();
24
+ const next = { ...(app.telegram ?? {}) };
25
+
26
+ if ('token' in body) {
27
+ const token = String(body.token ?? '').trim();
28
+ // El cliente puede mandar la mascara (`••••1234`): no pisar el token real.
29
+ if (!token.includes('•')) next.token = token;
30
+ }
31
+ if ('allowedChatIds' in body) next.allowedChatIds = sanitizeAllowed(body.allowedChatIds);
32
+ if ('transcribeModel' in body) next.transcribeModel = String(body.transcribeModel ?? '').trim() || 'whisper-1';
33
+
34
+ app.telegram = next;
35
+ writeApp(app);
36
+ ctx.app = app;
37
+ ctx.channels.load();
38
+ sendJson(res, 200, { telegram: ctx.channels.status() });
39
+ });
40
+
41
+ route('POST', '/api/channels/telegram/test', async ({ req, res }) => {
42
+ const body = await readJson(req).catch(() => ({}));
43
+ let token = String(body.token ?? '').trim();
44
+ if (!token || token.includes('•')) token = ctx.channels.readConfig().token ?? '';
45
+ sendJson(res, 200, await getTelegramBotInfo(token));
46
+ });
47
+ }
@@ -239,6 +239,7 @@ export function registerChat(route, ctx) {
239
239
  const prefixLength = messages.length;
240
240
 
241
241
  (async () => {
242
+ const startedAt = Date.now();
242
243
  const partials = createPartialThrottle(300, (name, payload) => ctx.runs.emit(sessionId, name, payload));
243
244
  try {
244
245
  const result = await runAgent({
@@ -293,7 +294,9 @@ export function registerChat(route, ctx) {
293
294
  const title =
294
295
  current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
295
296
  ctx.store.touchSession(sessionId, { provider: providerId, model, title });
296
- if (result.stopReason === 'aborted') ctx.runs.emit(sessionId, 'aborted', {});
297
+ const toolCalls = newMessages.filter((message) => message.role === 'tool').length;
298
+ const durationMs = Date.now() - startedAt;
299
+ if (result.stopReason === 'aborted') ctx.runs.emit(sessionId, 'aborted', { durationMs, toolCalls });
297
300
  else
298
301
  ctx.runs.emit(sessionId, 'done', {
299
302
  sessionId,
@@ -301,6 +304,8 @@ export function registerChat(route, ctx) {
301
304
  rounds: result.rounds,
302
305
  stopReason: result.stopReason,
303
306
  tokens: result.usage ?? null,
307
+ toolCalls,
308
+ durationMs,
304
309
  });
305
310
  } catch (error) {
306
311
  partials.flush();
@@ -20,7 +20,7 @@ import { skillsPrompt } from './lib/skills.js';
20
20
  import { createApprovalBroker } from './lib/approvals.js';
21
21
  import { createRunManager } from './lib/runManager.js';
22
22
  import { createProcManager } from './lib/procs.js';
23
- import { startChannels } from './lib/channels/index.js';
23
+ import { createChannelsManager } from './lib/channels/index.js';
24
24
  import { createAgentTools } from './lib/agentTools.js';
25
25
  import { createStaticHandler } from './static.js';
26
26
  import { requireLogin, requireCsrf } from './auth.js';
@@ -32,6 +32,7 @@ import { registerModels } from './routes/models.js';
32
32
  import { registerProviders } from './routes/providers.js';
33
33
  import { registerMcp } from './routes/mcp.js';
34
34
  import { registerSkills } from './routes/skills.js';
35
+ import { registerChannels } from './routes/channels.js';
35
36
  import { registerProcs } from './routes/procs.js';
36
37
  import { registerPreview } from './routes/preview.js';
37
38
  import { registerChat } from './routes/chat.js';
@@ -171,10 +172,11 @@ export async function createTamnoraServer({
171
172
  ctx.agentTools = createAgentTools(ctx);
172
173
 
173
174
  // Canales de mensajeria (Telegram via long polling) si hay token.
174
- const channels = telegram
175
- ? startChannels(ctx, { log: (message) => process.stderr.write(`${message}\n`) })
176
- : { channels: [], enabled: false, stop() {} };
177
- ctx.telegram = channels.enabled;
175
+ const channels = createChannelsManager(ctx, {
176
+ enabled: telegram,
177
+ log: (message) => process.stderr.write(`${message}\n`),
178
+ });
179
+ channels.load();
178
180
  ctx.channels = channels;
179
181
 
180
182
  const routes = [];
@@ -201,6 +203,7 @@ export async function createTamnoraServer({
201
203
  registerProviders(route, ctx);
202
204
  registerMcp(route, ctx);
203
205
  registerSkills(route, ctx);
206
+ registerChannels(route, ctx);
204
207
  registerProcs(route, ctx);
205
208
  const preview = registerPreview(route, ctx);
206
209
  registerChat(route, ctx);
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { createTamnoraServer } from '../src/index.js';
8
8
  import { setHome, ensureApp, readApp, writeApp, makeUser } from '../../core/src/index.js';
9
+ import { completionFooter } from '../src/lib/channels/telegram.js';
9
10
 
10
11
  const fakeProvider = {
11
12
  id: 'fake',
@@ -224,6 +225,89 @@ test('keeps a run alive after the client disconnects and can stop it', async ()
224
225
  }
225
226
  });
226
227
 
228
+ test('done reports max_rounds and the tool count when the model never answers', async () => {
229
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-loop-'));
230
+ await writeFile(path.join(cwd, 'a.txt'), 'hola');
231
+ let seq = 0;
232
+ const provider = {
233
+ id: 'loop',
234
+ async streamChat() {
235
+ seq += 1;
236
+ return {
237
+ content: '',
238
+ reasoning: '',
239
+ toolCalls: [
240
+ { id: `call_${seq}`, function: { name: 'read', arguments: JSON.stringify({ path: 'a.txt' }) } },
241
+ ],
242
+ };
243
+ },
244
+ };
245
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
246
+ try {
247
+ const sse = await (
248
+ await fetch(`${base}/api/chat`, {
249
+ method: 'POST',
250
+ headers: { 'Content-Type': 'application/json' },
251
+ body: JSON.stringify({ input: 'leé', model: 'm', maxRounds: 2 }),
252
+ })
253
+ ).text();
254
+ assert.match(sse, /event: done/);
255
+ assert.match(sse, /"stopReason":"max_rounds"/);
256
+ const match = sse.match(/"toolCalls":(\d+)/);
257
+ assert.ok(match && Number(match[1]) >= 2, 'el done debe reportar las tools usadas');
258
+ assert.match(sse, /"durationMs":\d+/);
259
+ } finally {
260
+ await instance.close();
261
+ }
262
+ });
263
+
264
+ test('completionFooter resume el cierre del turno', () => {
265
+ assert.equal(completionFooter('end_turn', 1, 0, 0), '✓ terminado · 1 ronda');
266
+ assert.equal(completionFooter('end_turn', 3, 5, 1200), '✓ terminado · 3 rondas · 5 herramientas · 1.2s');
267
+ assert.match(completionFooter('max_rounds', 2, 2, 300), /^⚠ máximo de rondas · 2 rondas · 2 herramientas · 300ms$/);
268
+ });
269
+
270
+ test('configures Telegram from the console (masked token)', async () => {
271
+ const { instance, base } = await startServer({ telegram: false });
272
+ const json = { 'Content-Type': 'application/json' };
273
+ try {
274
+ let data = await (await fetch(`${base}/api/channels`)).json();
275
+ assert.equal(data.telegram.tokenSet, false);
276
+ assert.equal(data.telegram.transcribeModel, 'whisper-1');
277
+
278
+ let res = await fetch(`${base}/api/channels/telegram`, {
279
+ method: 'PUT',
280
+ headers: json,
281
+ body: JSON.stringify({ allowedChatIds: '123, 456', transcribeModel: '' }),
282
+ });
283
+ data = await res.json();
284
+ assert.deepEqual(data.telegram.allowedChatIds, ['123', '456']);
285
+ assert.equal(data.telegram.transcribeModel, 'whisper-1');
286
+
287
+ res = await fetch(`${base}/api/channels/telegram`, {
288
+ method: 'PUT',
289
+ headers: json,
290
+ body: JSON.stringify({ token: '123456:ABCDEFghij' }),
291
+ });
292
+ data = await res.json();
293
+ assert.equal(data.telegram.tokenSet, true);
294
+ assert.match(data.telegram.tokenHint, /ghij$/);
295
+ assert.doesNotMatch(JSON.stringify(data), /ABCDEF/);
296
+
297
+ // Mandar la mascara no pisa el token real.
298
+ res = await fetch(`${base}/api/channels/telegram`, {
299
+ method: 'PUT',
300
+ headers: json,
301
+ body: JSON.stringify({ token: data.telegram.tokenHint, allowedChatIds: ['1'] }),
302
+ });
303
+ data = await res.json();
304
+ assert.equal(data.telegram.tokenSet, true);
305
+ assert.match(data.telegram.tokenHint, /ghij$/);
306
+ } finally {
307
+ await instance.close();
308
+ }
309
+ });
310
+
227
311
  test('manages MCP servers from the console', async () => {
228
312
  const { instance, base } = await startServer();
229
313
  const json = { 'Content-Type': 'application/json' };
@@ -0,0 +1,94 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { Store } from '../../core/src/index.js';
7
+ import { createApprovalBroker } from '../src/lib/approvals.js';
8
+ import { startTelegramChannel } from '../src/lib/channels/telegram.js';
9
+
10
+ const reply = (data) => ({ ok: true, json: async () => data });
11
+
12
+ function installFetchMock() {
13
+ const sent = [];
14
+ const updates = [];
15
+ const original = global.fetch;
16
+ global.fetch = async (url, opts = {}) => {
17
+ const method = String(url).split('/').pop();
18
+ const body = opts.body ? JSON.parse(opts.body) : {};
19
+ if (method === 'getUpdates') {
20
+ const out = updates.splice(0, updates.length);
21
+ if (!out.length) await new Promise((resolve) => setTimeout(resolve, 20));
22
+ return reply({ ok: true, result: out });
23
+ }
24
+ if (method === 'sendMessage' || method === 'editMessageText') {
25
+ sent.push(body);
26
+ return reply({ ok: true, result: { message_id: sent.length } });
27
+ }
28
+ return reply({ ok: true, result: true });
29
+ };
30
+ return { sent, updates, restore: () => { global.fetch = original; } };
31
+ }
32
+
33
+ function makeCtx(cwd) {
34
+ const store = new Store(':memory:');
35
+ const provider = {
36
+ id: 'fake',
37
+ async streamChat(_config, { onEvent }) {
38
+ onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
39
+ return { content: 'listo', reasoning: '', toolCalls: [] };
40
+ },
41
+ };
42
+ return {
43
+ cwd,
44
+ config: { maxRounds: 2, permissions: {}, telegram: {} },
45
+ app: {},
46
+ store,
47
+ approvals: createApprovalBroker({ timeoutMs: 1000 }),
48
+ effectiveProviderId: () => 'fake',
49
+ effectiveModel: () => 'm',
50
+ effectiveMode: () => 'agent',
51
+ providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
52
+ apiKeyFor: () => '',
53
+ customProviderDefs: () => [],
54
+ resolveProvider: () => ({ provider, config: {} }),
55
+ getMcp: async () => null,
56
+ systemPromptFor: async () => 'sys',
57
+ agentTools: { definitions: [], handles: () => false, execute: async () => '' },
58
+ };
59
+ }
60
+
61
+ async function waitFor(fn, timeout = 3000) {
62
+ const start = Date.now();
63
+ while (Date.now() - start < timeout) {
64
+ if (fn()) return;
65
+ await new Promise((resolve) => setTimeout(resolve, 10));
66
+ }
67
+ throw new Error('timeout esperando la condición');
68
+ }
69
+
70
+ test('el canal de Telegram cierra el turno y muestra el panel', async () => {
71
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tg-'));
72
+ const ctx = makeCtx(cwd);
73
+ const mock = installFetchMock();
74
+ const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
75
+ try {
76
+ mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
77
+ await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
78
+ const completion = mock.sent.find((m) => /✓ terminado/.test(m.text ?? ''));
79
+ assert.match(completion.text, /listo/);
80
+ assert.match(completion.text, /1 ronda/);
81
+
82
+ mock.updates.push({ update_id: 2, message: { chat: { id: 7 }, text: '/menu' } });
83
+ await waitFor(() => mock.sent.some((m) => m.reply_markup?.inline_keyboard));
84
+ const menu = mock.sent.find((m) => m.reply_markup?.inline_keyboard);
85
+ const datas = menu.reply_markup.inline_keyboard.flat().map((b) => b.callback_data);
86
+ assert.ok(datas.includes('mode:plan'));
87
+ assert.ok(datas.includes('menu:models'));
88
+ assert.ok(datas.includes('menu:stop'));
89
+ } finally {
90
+ channel.stop();
91
+ mock.restore();
92
+ ctx.store.close();
93
+ }
94
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",