@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.
@@ -0,0 +1,532 @@
1
+ // Canal de Telegram: long polling (no necesita URL publica) con soporte de
2
+ // texto, imagenes, notas de voz (transcripcion) y aprobaciones por botones
3
+ // inline. Cada chat mapea a una sesion del harness.
4
+
5
+ import { randomUUID } from 'node:crypto';
6
+ import { runAgent, createTools, resolveMode, summarizeToolCall, listProviderModels, PROVIDER_META } from '../core.js';
7
+ import { resolveExistingDir } from '../workspace.js';
8
+
9
+ const API = 'https://api.telegram.org';
10
+
11
+ const HELP = [
12
+ 'Tamnora Harness — bot',
13
+ '',
14
+ '/menu — panel con botones (modelo, modo, proveedor)',
15
+ '/new — nueva sesión',
16
+ '/status — proveedor, modelo, modo y sesión',
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)',
20
+ '/stop — cancela la respuesta en curso',
21
+ '/help — esta ayuda',
22
+ '',
23
+ 'Podés mandar texto, imágenes o notas de voz.',
24
+ ].join('\n');
25
+
26
+ async function tg(token, method, payload) {
27
+ const res = await fetch(`${API}/bot${token}/${method}`, {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json' },
30
+ body: JSON.stringify(payload ?? {}),
31
+ });
32
+ let data = null;
33
+ try {
34
+ data = await res.json();
35
+ } catch {
36
+ data = null;
37
+ }
38
+ if (!data?.ok) {
39
+ const error = new Error(data?.description || `HTTP ${res.status}`);
40
+ error.status = res.status;
41
+ throw error;
42
+ }
43
+ return data.result;
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
+
57
+ async function downloadFile(token, fileId) {
58
+ const file = await tg(token, 'getFile', { file_id: fileId });
59
+ const res = await fetch(`${API}/file/bot${token}/${file.file_path}`);
60
+ if (!res.ok) throw new Error(`no se pudo bajar el archivo (HTTP ${res.status})`);
61
+ return Buffer.from(await res.arrayBuffer());
62
+ }
63
+
64
+ function splitMessage(text, max = 3900) {
65
+ const value = String(text ?? '').trim() || '(sin texto)';
66
+ const parts = [];
67
+ for (let i = 0; i < value.length; i += max) parts.push(value.slice(i, i + max));
68
+ return parts;
69
+ }
70
+
71
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
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
+
82
+ /**
83
+ * Arranca el canal de Telegram. Devuelve `{ stop, notify }` o `null` sin token.
84
+ */
85
+ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcribeModel = 'whisper-1', log = () => {} } = {}) {
86
+ if (!token) return null;
87
+ const allowed = new Set(allowedChatIds.map(String));
88
+ const controllers = new Map();
89
+ const ownSessions = new Set();
90
+ const modelChoices = new Map();
91
+ let running = true;
92
+ let offset = 0;
93
+
94
+ const authorized = (chatId) => allowed.size > 0 && allowed.has(String(chatId));
95
+ const sessionKey = (chatId) => `telegram:chat:${chatId}`;
96
+ const reverseKey = (sessionId) => `telegram:session:${sessionId}`;
97
+
98
+ async function send(chatId, text, extra = {}) {
99
+ let first = null;
100
+ for (const chunk of splitMessage(text)) {
101
+ const result = await tg(token, 'sendMessage', { chat_id: chatId, text: chunk, disable_web_page_preview: true, ...extra });
102
+ if (!first) first = result;
103
+ }
104
+ return first;
105
+ }
106
+
107
+ async function edit(chatId, messageId, text) {
108
+ const parts = splitMessage(text);
109
+ await tg(token, 'editMessageText', { chat_id: chatId, message_id: messageId, text: parts[0], disable_web_page_preview: true }).catch(() => null);
110
+ for (let i = 1; i < parts.length; i++) {
111
+ await tg(token, 'sendMessage', { chat_id: chatId, text: parts[i], disable_web_page_preview: true }).catch(() => null);
112
+ }
113
+ }
114
+
115
+ function ensureSession(chatId) {
116
+ const key = sessionKey(chatId);
117
+ let sessionId = ctx.store.getSetting(key);
118
+ if (!sessionId || !ctx.store.getSession(sessionId)) {
119
+ sessionId = ctx.store.createSession({
120
+ title: `Telegram ${chatId}`,
121
+ provider: ctx.effectiveProviderId(),
122
+ model: ctx.effectiveModel(),
123
+ cwd: ctx.cwd,
124
+ meta: { telegramChatId: String(chatId) },
125
+ });
126
+ ctx.store.setSetting(key, sessionId);
127
+ }
128
+ ctx.store.setSetting(reverseKey(sessionId), String(chatId));
129
+ return sessionId;
130
+ }
131
+
132
+ async function transcribe(buffer, filename) {
133
+ const providerId = ctx.effectiveProviderId();
134
+ const baseUrl = (ctx.providerSettings(providerId).baseUrl || '').replace(/\/+$/, '');
135
+ const apiKey = ctx.apiKeyFor(providerId);
136
+ if (!baseUrl) throw new Error('el proveedor no tiene baseUrl para transcribir');
137
+ const form = new FormData();
138
+ form.append('file', new Blob([buffer]), filename);
139
+ form.append('model', transcribeModel);
140
+ const res = await fetch(`${baseUrl}/audio/transcriptions`, {
141
+ method: 'POST',
142
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
143
+ body: form,
144
+ });
145
+ if (!res.ok) throw new Error(`transcripción falló (HTTP ${res.status})`);
146
+ const data = await res.json();
147
+ return String(data.text ?? '').trim();
148
+ }
149
+
150
+ async function run(chatId, input, images = []) {
151
+ const sessionId = ensureSession(chatId);
152
+ const session = ctx.store.getSession(sessionId);
153
+ const providerId = session.provider || ctx.effectiveProviderId();
154
+ const model = session.model || ctx.effectiveModel();
155
+ if (!model) {
156
+ await send(chatId, 'Falta el modelo. Usá /model <id> o configurá uno en Ajustes.');
157
+ return;
158
+ }
159
+ const mode = resolveMode(ctx.effectiveMode());
160
+ const cwd = resolveExistingDir(session.cwd || ctx.cwd);
161
+ const history = ctx.store.getMessages(sessionId);
162
+
163
+ let mcp = null;
164
+ try {
165
+ mcp = await ctx.getMcp();
166
+ } catch {
167
+ mcp = null;
168
+ }
169
+
170
+ const onAsk = async (name, args) => {
171
+ const askId = randomUUID();
172
+ const preview = JSON.stringify(args ?? {}).slice(0, 300);
173
+ await send(chatId, `⚠ Permiso para ${name}\n${preview}`, {
174
+ reply_markup: {
175
+ inline_keyboard: [
176
+ [
177
+ { text: 'Permitir', callback_data: `perm:${askId}:allow` },
178
+ { text: 'Denegar', callback_data: `perm:${askId}:deny` },
179
+ ],
180
+ ],
181
+ },
182
+ });
183
+ return ctx.approvals.ask(askId);
184
+ };
185
+
186
+ const local = createTools({
187
+ root: cwd,
188
+ enabled: mode.tools ?? undefined,
189
+ permissions: ctx.config.permissions,
190
+ yes: false,
191
+ onAsk,
192
+ mcp,
193
+ });
194
+ const exposeAdmin = !mode.tools;
195
+ const admin = ctx.agentTools;
196
+ const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
197
+ const execute = (name, args, context) =>
198
+ exposeAdmin && admin.handles(name)
199
+ ? admin.execute(name, args, { approve: onAsk })
200
+ : local.execute(name, args, context);
201
+ const { provider, config } = ctx.resolveProvider({
202
+ ...ctx.providerSettings(providerId),
203
+ apiKey: ctx.apiKeyFor(providerId),
204
+ });
205
+ const basePrompt = await ctx.systemPromptFor(cwd);
206
+ const userMessage = images.length
207
+ ? { role: 'user', content: [{ type: 'text', text: input }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] }
208
+ : { role: 'user', content: input };
209
+ const messages = [
210
+ { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
211
+ ...history,
212
+ userMessage,
213
+ ];
214
+
215
+ const controller = new AbortController();
216
+ controllers.set(String(chatId), controller);
217
+ ownSessions.add(sessionId);
218
+ const startedAt = Date.now();
219
+ const message = await send(chatId, '…');
220
+ let full = '';
221
+ let activity = '';
222
+ let lastEdit = 0;
223
+
224
+ const render = () => {
225
+ const body = full.trim();
226
+ const suffix = activity ? `\n\n— ${activity}` : '';
227
+ return `${body || '…'}${suffix}`;
228
+ };
229
+
230
+ try {
231
+ await tg(token, 'sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => null);
232
+ const result = await runAgent({
233
+ provider,
234
+ config,
235
+ model,
236
+ messages,
237
+ tools: definitions,
238
+ execute,
239
+ signal: controller.signal,
240
+ maxRounds: ctx.config.maxRounds,
241
+ onEvent(event) {
242
+ if (event.type === 'text') {
243
+ full = event.full;
244
+ } else if (event.type === 'tool_call') {
245
+ activity = `${event.name} ${summarizeToolCall(event.name, event.args)}`.trim();
246
+ } else if (event.type === 'tool_result') {
247
+ activity = `${event.name} ✓`;
248
+ } else {
249
+ return;
250
+ }
251
+ const now = Date.now();
252
+ if (now - lastEdit > 1200) {
253
+ lastEdit = now;
254
+ edit(chatId, message.message_id, render());
255
+ }
256
+ },
257
+ });
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);
276
+ ctx.store.touchSession(sessionId, { provider: providerId, model });
277
+ } catch (error) {
278
+ await edit(chatId, message.message_id, error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`);
279
+ } finally {
280
+ controllers.delete(String(chatId));
281
+ ownSessions.delete(sessionId);
282
+ mcp?.close?.();
283
+ }
284
+ }
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
+
344
+ async function handleCommand(chatId, text) {
345
+ const [raw, ...rest] = text.slice(1).split(/\s+/);
346
+ const cmd = raw.toLowerCase();
347
+ const arg = rest.join(' ').trim();
348
+ const sessionId = ctx.store.getSetting(sessionKey(chatId));
349
+ if (cmd === 'start' || cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
350
+ if (cmd === 'menu' || cmd === 'panel' || cmd === 'config') return sendMenu(chatId);
351
+ if (cmd === 'new' || cmd === 'nueva') {
352
+ ctx.store.setSetting(sessionKey(chatId), null);
353
+ return send(chatId, 'Nueva sesión. Mandá tu mensaje.');
354
+ }
355
+ if (cmd === 'status') {
356
+ const session = sessionId ? ctx.store.getSession(sessionId) : null;
357
+ return send(
358
+ chatId,
359
+ `proveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
360
+ );
361
+ }
362
+ if (cmd === 'model' || cmd === 'modelo') {
363
+ if (!arg) return sendModels(chatId);
364
+ ctx.store.setSetting('activeModel', arg);
365
+ if (sessionId) ctx.store.touchSession(sessionId, { model: arg });
366
+ return send(chatId, `modelo: ${arg}`);
367
+ }
368
+ if (cmd === 'provider' || cmd === 'proveedor') {
369
+ if (!arg) return sendProviders(chatId);
370
+ ctx.store.setSetting('activeProvider', arg);
371
+ if (sessionId) ctx.store.touchSession(sessionId, { provider: arg });
372
+ return send(chatId, `proveedor: ${arg}`);
373
+ }
374
+ if (cmd === 'mode' || cmd === 'modo') {
375
+ if (!arg) return sendMenu(chatId);
376
+ const id = arg.toLowerCase() === 'build' ? 'agent' : arg.toLowerCase();
377
+ ctx.store.setSetting('activeMode', resolveMode(id).id);
378
+ return send(chatId, `modo: ${resolveMode(id).label}`);
379
+ }
380
+ if (cmd === 'stop' || cmd === 'parar') {
381
+ controllers.get(String(chatId))?.abort();
382
+ return send(chatId, 'Cancelando…');
383
+ }
384
+ return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
385
+ }
386
+
387
+ async function handleVoice(chatId, voice) {
388
+ await send(chatId, '🎧 Transcribiendo…');
389
+ try {
390
+ const buffer = await downloadFile(token, voice.file_id);
391
+ const text = await transcribe(buffer, voice.file_name || 'audio.ogg');
392
+ if (!text) return send(chatId, 'No pude transcribir el audio.');
393
+ await send(chatId, `📝 ${text}`);
394
+ return run(chatId, text);
395
+ } catch (error) {
396
+ return send(chatId, `No pude procesar el audio: ${error.message}`);
397
+ }
398
+ }
399
+
400
+ async function handleMessage(message) {
401
+ const chatId = String(message.chat.id);
402
+ if (!authorized(chatId)) {
403
+ await send(chatId, `No autorizado. Tu chat id es ${chatId}; agregalo a telegram.allowedChatIds para habilitarlo.`);
404
+ return;
405
+ }
406
+ const text = String(message.text ?? message.caption ?? '').trim();
407
+ if (text.startsWith('/')) {
408
+ await handleCommand(chatId, text);
409
+ return;
410
+ }
411
+ if (message.voice || message.audio) {
412
+ await handleVoice(chatId, message.voice ?? message.audio);
413
+ return;
414
+ }
415
+ if (message.photo?.length) {
416
+ try {
417
+ const largest = message.photo[message.photo.length - 1];
418
+ const buffer = await downloadFile(token, largest.file_id);
419
+ const dataUrl = `data:image/jpeg;base64,${buffer.toString('base64')}`;
420
+ await run(chatId, text || '(imagen)', [dataUrl]);
421
+ } catch (error) {
422
+ await send(chatId, `No pude procesar la imagen: ${error.message}`);
423
+ }
424
+ return;
425
+ }
426
+ if (text) await run(chatId, text);
427
+ }
428
+
429
+ async function handleCallback(query) {
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) {
470
+ await tg(token, 'editMessageReplyMarkup', { chat_id: query.message.chat.id, message_id: query.message.message_id }).catch(() => null);
471
+ }
472
+ }
473
+
474
+ async function loop() {
475
+ while (running) {
476
+ let updates = [];
477
+ try {
478
+ updates = await tg(token, 'getUpdates', { offset, timeout: 25, allowed_updates: ['message', 'callback_query'] });
479
+ } catch (error) {
480
+ if (error.status === 401 || error.status === 404) {
481
+ log(`telegram: token inválido (${error.message}); se detiene el bot.`);
482
+ running = false;
483
+ return;
484
+ }
485
+ await sleep(3000);
486
+ continue;
487
+ }
488
+ for (const update of updates) {
489
+ offset = update.update_id + 1;
490
+ if (update.message) handleMessage(update.message).catch((error) => log(`telegram: ${error?.message ?? error}`));
491
+ else if (update.callback_query) handleCallback(update.callback_query).catch((error) => log(`telegram: ${error?.message ?? error}`));
492
+ }
493
+ }
494
+ }
495
+
496
+ loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
497
+
498
+ return {
499
+ id: 'telegram',
500
+ stop() {
501
+ running = false;
502
+ for (const controller of controllers.values()) controller.abort();
503
+ controllers.clear();
504
+ },
505
+ /** Avisa a un chat vinculado sobre un run de otra interfaz (p.ej. la consola). */
506
+ notify(sessionId, event) {
507
+ if (ownSessions.has(sessionId)) return;
508
+ const chatId = ctx.store.getSetting(reverseKey(sessionId));
509
+ if (!chatId) return;
510
+ if (event.type === 'done') {
511
+ send(chatId, `✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`).catch(() => {});
512
+ } else if (event.type === 'error') {
513
+ send(chatId, `✗ Error en la sesión: ${event.data?.message ?? ''}`).catch(() => {});
514
+ } else if (event.type === 'agent_end') {
515
+ send(chatId, `🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`).catch(() => {});
516
+ } else if (event.type === 'permission') {
517
+ const askId = event.data?.askId;
518
+ if (!askId) return;
519
+ send(chatId, `⚠ Permiso para ${event.data?.name ?? 'una tool'} (desde la consola)`, {
520
+ reply_markup: {
521
+ inline_keyboard: [
522
+ [
523
+ { text: 'Permitir', callback_data: `perm:${askId}:allow` },
524
+ { text: 'Denegar', callback_data: `perm:${askId}:deny` },
525
+ ],
526
+ ],
527
+ },
528
+ }).catch(() => {});
529
+ }
530
+ },
531
+ };
532
+ }
@@ -55,9 +55,10 @@ export function openSse(req, res) {
55
55
  });
56
56
 
57
57
  return {
58
- event(name, data) {
58
+ event(name, data, id) {
59
59
  if (closed || res.writableEnded) return;
60
- res.write(`event: ${name}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
60
+ const cursor = id === undefined || id === null ? '' : `id: ${id}\n`;
61
+ res.write(`${cursor}event: ${name}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
61
62
  },
62
63
  comment(text) {
63
64
  if (closed || res.writableEnded) return;
@@ -0,0 +1,102 @@
1
+ // Gestiona los runs activos del server, desacoplados del request HTTP: si el
2
+ // cliente se desconecta (por ejemplo, el celular bloquea la pantalla), el run
3
+ // sigue; cada evento se persiste con un cursor para que un cliente nuevo pueda
4
+ // reconectar y reconstruir el turno en curso.
5
+
6
+ /**
7
+ * @param {object} deps
8
+ * @param {import('../../../core/src/storage/sqlite.js').Store} deps.store
9
+ */
10
+ export function createRunManager({ store }) {
11
+ const runs = new Map();
12
+ const listeners = new Set();
13
+
14
+ function persist(run, sessionId, type, data, agentId) {
15
+ const coalesce =
16
+ (type === 'text' || type === 'reasoning') &&
17
+ run?.lastEvent &&
18
+ run.lastEvent.type === type &&
19
+ run.lastEvent.agentId === agentId;
20
+ if (coalesce) {
21
+ store.updateSessionEvent(run.lastEvent.id, data);
22
+ return { id: run.lastEvent.id, type, data, agentId };
23
+ }
24
+ const id = store.addSessionEvent(sessionId, { agentId, type, data });
25
+ if (run) run.lastEvent = { id, type, agentId };
26
+ return { id, type, data, agentId };
27
+ }
28
+
29
+ function emit(sessionId, type, data, { agentId = null } = {}) {
30
+ const run = runs.get(sessionId) ?? null;
31
+ const event = persist(run, sessionId, type, data, agentId);
32
+ if (run) {
33
+ for (const subscriber of [...run.subscribers]) {
34
+ try {
35
+ subscriber(event);
36
+ } catch {
37
+ // un suscriptor caido no debe frenar el run
38
+ }
39
+ }
40
+ }
41
+ for (const listener of [...listeners]) {
42
+ try {
43
+ listener(sessionId, event);
44
+ } catch {
45
+ // un listener caido no debe frenar el run
46
+ }
47
+ }
48
+ return event;
49
+ }
50
+
51
+ function start(sessionId) {
52
+ if (runs.has(sessionId)) return null;
53
+ const controller = new AbortController();
54
+ const run = { sessionId, controller, subscribers: new Set(), lastEvent: null, startedAt: Date.now() };
55
+ runs.set(sessionId, run);
56
+ store.clearSessionEvents(sessionId);
57
+ return run;
58
+ }
59
+
60
+ function finish(sessionId) {
61
+ const run = runs.get(sessionId);
62
+ if (!run) return;
63
+ run.subscribers.clear();
64
+ runs.delete(sessionId);
65
+ }
66
+
67
+ function cancel(sessionId) {
68
+ runs.get(sessionId)?.controller.abort();
69
+ }
70
+
71
+ function cancelAll() {
72
+ for (const run of runs.values()) run.controller.abort();
73
+ runs.clear();
74
+ }
75
+
76
+ function isActive(sessionId) {
77
+ return runs.has(sessionId);
78
+ }
79
+
80
+ function signal(sessionId) {
81
+ return runs.get(sessionId)?.controller.signal;
82
+ }
83
+
84
+ function subscribe(sessionId, fn) {
85
+ const run = runs.get(sessionId);
86
+ if (!run) return () => {};
87
+ run.subscribers.add(fn);
88
+ return () => run.subscribers.delete(fn);
89
+ }
90
+
91
+ function activeSessions() {
92
+ return [...runs.keys()];
93
+ }
94
+
95
+ /** Suscriptor global de todos los runs (usado por los canales). */
96
+ function subscribeAll(fn) {
97
+ listeners.add(fn);
98
+ return () => listeners.delete(fn);
99
+ }
100
+
101
+ return { emit, start, finish, cancel, cancelAll, isActive, signal, subscribe, subscribeAll, activeSessions };
102
+ }