@danieltmn/tamnora 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -427,8 +427,38 @@ body.resizing * { cursor: col-resize !important; }
427
427
  .msg-actions button.msg-act.danger:hover { color: var(--danger); border-color: var(--danger); }
428
428
  .bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
429
429
 
430
+ .tools-actions { display: flex; align-items: center; gap: 8px; }
431
+ .tools-actions[hidden] { display: none; }
432
+ .tools-count { color: var(--muted); font-size: 12px; }
433
+ .tools-toggle {
434
+ background: none;
435
+ border: none;
436
+ color: var(--accent);
437
+ cursor: pointer;
438
+ font: 600 12px var(--sans);
439
+ padding: 2px 0;
440
+ }
441
+ .tools-toggle:hover { text-decoration: underline; }
442
+
430
443
  .tool-card { font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; min-width: 0; }
431
- .tool-card .tool-head { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
444
+ .tool-card > .tool-head {
445
+ display: flex;
446
+ gap: 8px;
447
+ align-items: baseline;
448
+ min-width: 0;
449
+ cursor: pointer;
450
+ list-style: none;
451
+ touch-action: manipulation;
452
+ }
453
+ .tool-card > .tool-head::-webkit-details-marker { display: none; }
454
+ .tool-card > .tool-head::before {
455
+ content: '▸';
456
+ color: var(--muted);
457
+ flex: 0 0 auto;
458
+ font-size: 10px;
459
+ transition: transform 0.12s ease;
460
+ }
461
+ .tool-card[open] > .tool-head::before { transform: rotate(90deg); }
432
462
  .tool-card .tool-glyph { color: var(--muted); flex: 0 0 auto; }
433
463
  .tool-card.running .tool-glyph { color: var(--accent); animation: pulse 1s steps(2, start) infinite; }
434
464
  .tool-card.done .tool-glyph { color: var(--ok); }
@@ -442,17 +472,78 @@ body.resizing * { cursor: col-resize !important; }
442
472
  text-overflow: ellipsis;
443
473
  white-space: nowrap;
444
474
  }
445
- .tool-card .tool-out {
475
+ .tool-card > .tool-out {
446
476
  color: var(--muted);
447
477
  margin: 4px 0 2px 18px;
448
478
  padding-left: 10px;
449
479
  border-left: 1px solid var(--border);
450
480
  white-space: pre-wrap;
451
481
  word-break: break-word;
452
- max-height: 260px;
482
+ max-height: 320px;
453
483
  overflow: auto;
454
484
  }
455
- .tool-card .tool-out:empty { display: none; }
485
+ .tool-card > .tool-out:empty { display: none; }
486
+ .tool-more {
487
+ display: inline-block;
488
+ margin: 2px 0 2px 18px;
489
+ background: none;
490
+ border: none;
491
+ color: var(--accent);
492
+ cursor: pointer;
493
+ font: 600 12px var(--sans);
494
+ padding: 0;
495
+ }
496
+ .tool-more[hidden] { display: none; }
497
+ .tool-more:hover { text-decoration: underline; }
498
+
499
+ .agent-card {
500
+ border: 1px solid var(--border);
501
+ border-radius: 7px;
502
+ padding: 6px 9px;
503
+ background: color-mix(in srgb, var(--accent) 4%, transparent);
504
+ min-width: 0;
505
+ }
506
+ .agent-card > .agent-head {
507
+ display: flex;
508
+ gap: 7px;
509
+ align-items: baseline;
510
+ cursor: pointer;
511
+ list-style: none;
512
+ touch-action: manipulation;
513
+ }
514
+ .agent-card > .agent-head::-webkit-details-marker { display: none; }
515
+ .agent-card > .agent-head::before {
516
+ content: '▸';
517
+ color: var(--muted);
518
+ font-size: 10px;
519
+ transition: transform 0.12s ease;
520
+ }
521
+ .agent-card[open] > .agent-head::before { transform: rotate(90deg); }
522
+ .agent-card .agent-glyph { color: var(--accent); flex: 0 0 auto; }
523
+ .agent-card.running .agent-glyph { animation: pulse 1s steps(2, start) infinite; }
524
+ .agent-card.done .agent-glyph { color: var(--ok); }
525
+ .agent-card.failed .agent-glyph { color: var(--danger); }
526
+ .agent-card .agent-title {
527
+ color: var(--text);
528
+ font-weight: 700;
529
+ flex: 1 1 auto;
530
+ min-width: 0;
531
+ overflow: hidden;
532
+ text-overflow: ellipsis;
533
+ white-space: nowrap;
534
+ }
535
+ .agent-card .agent-status { color: var(--muted); font-size: 11px; flex: 0 0 auto; }
536
+ .agent-card > .agent-body {
537
+ margin: 6px 0 2px 16px;
538
+ border-left: 1px solid var(--border);
539
+ padding-left: 10px;
540
+ display: flex;
541
+ flex-direction: column;
542
+ gap: 6px;
543
+ }
544
+ .agent-card .agent-text:empty { display: none; }
545
+ .agent-card .agent-tools { display: flex; flex-direction: column; gap: 6px; }
546
+ .agent-card .agent-tools:empty { display: none; }
456
547
  .tool-out .diff-line { display: block; }
457
548
  .tool-out .diff-add { color: var(--ok); }
458
549
  .tool-out .diff-del { color: var(--danger); }
@@ -0,0 +1,43 @@
1
+ // Capa de canales de mensajeria. Hoy implementa Telegram; la interfaz comun
2
+ // (`stop`, `notify`) permite sumar otros proveedores mas adelante.
3
+
4
+ import { startTelegramChannel } from './telegram.js';
5
+
6
+ /**
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).
9
+ */
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
+ },
42
+ };
43
+ }
@@ -1,5 +1,10 @@
1
- import { runAgent, createTools, resolveMode } from './core.js';
2
- import { resolveExistingDir } from './workspace.js';
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 } from '../core.js';
7
+ import { resolveExistingDir } from '../workspace.js';
3
8
 
4
9
  const API = 'https://api.telegram.org';
5
10
 
@@ -14,7 +19,7 @@ const HELP = [
14
19
  '/stop — cancela la respuesta en curso',
15
20
  '/help — esta ayuda',
16
21
  '',
17
- 'Cualquier otro texto se envía al agente.',
22
+ 'Podés mandar texto, imágenes o notas de voz.',
18
23
  ].join('\n');
19
24
 
20
25
  async function tg(token, method, payload) {
@@ -37,6 +42,13 @@ async function tg(token, method, payload) {
37
42
  return data.result;
38
43
  }
39
44
 
45
+ async function downloadFile(token, fileId) {
46
+ const file = await tg(token, 'getFile', { file_id: fileId });
47
+ const res = await fetch(`${API}/file/bot${token}/${file.file_path}`);
48
+ if (!res.ok) throw new Error(`no se pudo bajar el archivo (HTTP ${res.status})`);
49
+ return Buffer.from(await res.arrayBuffer());
50
+ }
51
+
40
52
  function splitMessage(text, max = 3900) {
41
53
  const value = String(text ?? '').trim() || '(sin texto)';
42
54
  const parts = [];
@@ -47,24 +59,24 @@ function splitMessage(text, max = 3900) {
47
59
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
48
60
 
49
61
  /**
50
- * Bot de Telegram por long polling (no necesita URL publica). Los chats
51
- * autorizados conversan con el mismo engine del harness; cada chat mapea a una
52
- * sesion. Devuelve `{ stop }` o `null` si no hay token.
62
+ * Arranca el canal de Telegram. Devuelve `{ stop, notify }` o `null` sin token.
53
63
  */
54
- export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () => {} } = {}) {
64
+ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcribeModel = 'whisper-1', log = () => {} } = {}) {
55
65
  if (!token) return null;
56
66
  const allowed = new Set(allowedChatIds.map(String));
57
67
  const controllers = new Map();
68
+ const ownSessions = new Set();
58
69
  let running = true;
59
70
  let offset = 0;
60
71
 
61
72
  const authorized = (chatId) => allowed.size > 0 && allowed.has(String(chatId));
62
73
  const sessionKey = (chatId) => `telegram:chat:${chatId}`;
74
+ const reverseKey = (sessionId) => `telegram:session:${sessionId}`;
63
75
 
64
- async function send(chatId, text) {
76
+ async function send(chatId, text, extra = {}) {
65
77
  let first = null;
66
78
  for (const chunk of splitMessage(text)) {
67
- const result = await tg(token, 'sendMessage', { chat_id: chatId, text: chunk, disable_web_page_preview: true });
79
+ const result = await tg(token, 'sendMessage', { chat_id: chatId, text: chunk, disable_web_page_preview: true, ...extra });
68
80
  if (!first) first = result;
69
81
  }
70
82
  return first;
@@ -91,10 +103,29 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
91
103
  });
92
104
  ctx.store.setSetting(key, sessionId);
93
105
  }
106
+ ctx.store.setSetting(reverseKey(sessionId), String(chatId));
94
107
  return sessionId;
95
108
  }
96
109
 
97
- async function run(chatId, input) {
110
+ async function transcribe(buffer, filename) {
111
+ const providerId = ctx.effectiveProviderId();
112
+ const baseUrl = (ctx.providerSettings(providerId).baseUrl || '').replace(/\/+$/, '');
113
+ const apiKey = ctx.apiKeyFor(providerId);
114
+ if (!baseUrl) throw new Error('el proveedor no tiene baseUrl para transcribir');
115
+ const form = new FormData();
116
+ form.append('file', new Blob([buffer]), filename);
117
+ form.append('model', transcribeModel);
118
+ const res = await fetch(`${baseUrl}/audio/transcriptions`, {
119
+ method: 'POST',
120
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
121
+ body: form,
122
+ });
123
+ if (!res.ok) throw new Error(`transcripción falló (HTTP ${res.status})`);
124
+ const data = await res.json();
125
+ return String(data.text ?? '').trim();
126
+ }
127
+
128
+ async function run(chatId, input, images = []) {
98
129
  const sessionId = ensureSession(chatId);
99
130
  const session = ctx.store.getSession(sessionId);
100
131
  const providerId = session.provider || ctx.effectiveProviderId();
@@ -114,11 +145,28 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
114
145
  mcp = null;
115
146
  }
116
147
 
148
+ const onAsk = async (name, args) => {
149
+ const askId = randomUUID();
150
+ const preview = JSON.stringify(args ?? {}).slice(0, 300);
151
+ await send(chatId, `⚠ Permiso para ${name}\n${preview}`, {
152
+ reply_markup: {
153
+ inline_keyboard: [
154
+ [
155
+ { text: 'Permitir', callback_data: `perm:${askId}:allow` },
156
+ { text: 'Denegar', callback_data: `perm:${askId}:deny` },
157
+ ],
158
+ ],
159
+ },
160
+ });
161
+ return ctx.approvals.ask(askId);
162
+ };
163
+
117
164
  const local = createTools({
118
165
  root: cwd,
119
166
  enabled: mode.tools ?? undefined,
120
167
  permissions: ctx.config.permissions,
121
168
  yes: false,
169
+ onAsk,
122
170
  mcp,
123
171
  });
124
172
  const exposeAdmin = !mode.tools;
@@ -126,26 +174,38 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
126
174
  const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
127
175
  const execute = (name, args, context) =>
128
176
  exposeAdmin && admin.handles(name)
129
- ? admin.execute(name, args, { approve: async () => false })
177
+ ? admin.execute(name, args, { approve: onAsk })
130
178
  : local.execute(name, args, context);
131
179
  const { provider, config } = ctx.resolveProvider({
132
180
  ...ctx.providerSettings(providerId),
133
181
  apiKey: ctx.apiKeyFor(providerId),
134
182
  });
135
183
  const basePrompt = await ctx.systemPromptFor(cwd);
184
+ const userMessage = images.length
185
+ ? { role: 'user', content: [{ type: 'text', text: input }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] }
186
+ : { role: 'user', content: input };
136
187
  const messages = [
137
188
  { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
138
189
  ...history,
139
- { role: 'user', content: input },
190
+ userMessage,
140
191
  ];
141
192
 
142
193
  const controller = new AbortController();
143
194
  controllers.set(String(chatId), controller);
195
+ ownSessions.add(sessionId);
144
196
  const message = await send(chatId, '…');
145
197
  let full = '';
198
+ let activity = '';
146
199
  let lastEdit = 0;
147
200
 
201
+ const render = () => {
202
+ const body = full.trim();
203
+ const suffix = activity ? `\n\n— ${activity}` : '';
204
+ return `${body || '…'}${suffix}`;
205
+ };
206
+
148
207
  try {
208
+ await tg(token, 'sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => null);
149
209
  const result = await runAgent({
150
210
  provider,
151
211
  config,
@@ -156,23 +216,30 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
156
216
  signal: controller.signal,
157
217
  maxRounds: ctx.config.maxRounds,
158
218
  onEvent(event) {
159
- if (event.type !== 'text') return;
160
- full = event.full;
219
+ if (event.type === 'text') {
220
+ full = event.full;
221
+ } else if (event.type === 'tool_call') {
222
+ activity = `${event.name} ${summarizeToolCall(event.name, event.args)}`.trim();
223
+ } else if (event.type === 'tool_result') {
224
+ activity = `${event.name} ✓`;
225
+ } else {
226
+ return;
227
+ }
161
228
  const now = Date.now();
162
229
  if (now - lastEdit > 1200) {
163
230
  lastEdit = now;
164
- edit(chatId, message.message_id, full || '…');
231
+ edit(chatId, message.message_id, render());
165
232
  }
166
233
  },
167
234
  });
168
235
  await edit(chatId, message.message_id, result.content?.trim() || full || '(sin texto)');
169
- const newMessages = result.messages.slice(1 + history.length);
170
- ctx.store.addMessages(sessionId, newMessages);
236
+ ctx.store.addMessages(sessionId, result.messages.slice(messages.length));
171
237
  ctx.store.touchSession(sessionId, { provider: providerId, model });
172
238
  } catch (error) {
173
239
  await edit(chatId, message.message_id, error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`);
174
240
  } finally {
175
241
  controllers.delete(String(chatId));
242
+ ownSessions.delete(sessionId);
176
243
  mcp?.close?.();
177
244
  }
178
245
  }
@@ -219,23 +286,62 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
219
286
  return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
220
287
  }
221
288
 
289
+ async function handleVoice(chatId, voice) {
290
+ await send(chatId, '🎧 Transcribiendo…');
291
+ try {
292
+ const buffer = await downloadFile(token, voice.file_id);
293
+ const text = await transcribe(buffer, voice.file_name || 'audio.ogg');
294
+ if (!text) return send(chatId, 'No pude transcribir el audio.');
295
+ await send(chatId, `📝 ${text}`);
296
+ return run(chatId, text);
297
+ } catch (error) {
298
+ return send(chatId, `No pude procesar el audio: ${error.message}`);
299
+ }
300
+ }
301
+
222
302
  async function handleMessage(message) {
223
303
  const chatId = String(message.chat.id);
224
304
  if (!authorized(chatId)) {
225
305
  await send(chatId, `No autorizado. Tu chat id es ${chatId}; agregalo a telegram.allowedChatIds para habilitarlo.`);
226
306
  return;
227
307
  }
228
- const text = String(message.text ?? '').trim();
229
- if (!text) return;
230
- if (text.startsWith('/')) return handleCommand(chatId, text);
231
- return run(chatId, text);
308
+ const text = String(message.text ?? message.caption ?? '').trim();
309
+ if (text.startsWith('/')) {
310
+ await handleCommand(chatId, text);
311
+ return;
312
+ }
313
+ if (message.voice || message.audio) {
314
+ await handleVoice(chatId, message.voice ?? message.audio);
315
+ return;
316
+ }
317
+ if (message.photo?.length) {
318
+ try {
319
+ const largest = message.photo[message.photo.length - 1];
320
+ const buffer = await downloadFile(token, largest.file_id);
321
+ const dataUrl = `data:image/jpeg;base64,${buffer.toString('base64')}`;
322
+ await run(chatId, text || '(imagen)', [dataUrl]);
323
+ } catch (error) {
324
+ await send(chatId, `No pude procesar la imagen: ${error.message}`);
325
+ }
326
+ return;
327
+ }
328
+ if (text) await run(chatId, text);
329
+ }
330
+
331
+ 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) {
336
+ await tg(token, 'editMessageReplyMarkup', { chat_id: query.message.chat.id, message_id: query.message.message_id }).catch(() => null);
337
+ }
232
338
  }
233
339
 
234
340
  async function loop() {
235
341
  while (running) {
236
342
  let updates = [];
237
343
  try {
238
- updates = await tg(token, 'getUpdates', { offset, timeout: 25, allowed_updates: ['message'] });
344
+ updates = await tg(token, 'getUpdates', { offset, timeout: 25, allowed_updates: ['message', 'callback_query'] });
239
345
  } catch (error) {
240
346
  if (error.status === 401 || error.status === 404) {
241
347
  log(`telegram: token inválido (${error.message}); se detiene el bot.`);
@@ -247,21 +353,46 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
247
353
  }
248
354
  for (const update of updates) {
249
355
  offset = update.update_id + 1;
250
- if (update.message?.text) {
251
- handleMessage(update.message).catch((error) => log(`telegram: ${error?.message ?? error}`));
252
- }
356
+ if (update.message) handleMessage(update.message).catch((error) => log(`telegram: ${error?.message ?? error}`));
357
+ else if (update.callback_query) handleCallback(update.callback_query).catch((error) => log(`telegram: ${error?.message ?? error}`));
253
358
  }
254
359
  }
255
360
  }
256
361
 
257
- // Arranca en segundo plano; no bloquea el server.
258
362
  loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
259
363
 
260
364
  return {
365
+ id: 'telegram',
261
366
  stop() {
262
367
  running = false;
263
368
  for (const controller of controllers.values()) controller.abort();
264
369
  controllers.clear();
265
370
  },
371
+ /** Avisa a un chat vinculado sobre un run de otra interfaz (p.ej. la consola). */
372
+ notify(sessionId, event) {
373
+ if (ownSessions.has(sessionId)) return;
374
+ const chatId = ctx.store.getSetting(reverseKey(sessionId));
375
+ if (!chatId) return;
376
+ if (event.type === 'done') {
377
+ send(chatId, `✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`).catch(() => {});
378
+ } else if (event.type === 'error') {
379
+ send(chatId, `✗ Error en la sesión: ${event.data?.message ?? ''}`).catch(() => {});
380
+ } else if (event.type === 'agent_end') {
381
+ send(chatId, `🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`).catch(() => {});
382
+ } else if (event.type === 'permission') {
383
+ const askId = event.data?.askId;
384
+ if (!askId) return;
385
+ send(chatId, `⚠ Permiso para ${event.data?.name ?? 'una tool'} (desde la consola)`, {
386
+ reply_markup: {
387
+ inline_keyboard: [
388
+ [
389
+ { text: 'Permitir', callback_data: `perm:${askId}:allow` },
390
+ { text: 'Denegar', callback_data: `perm:${askId}:deny` },
391
+ ],
392
+ ],
393
+ },
394
+ }).catch(() => {});
395
+ }
396
+ },
266
397
  };
267
398
  }
@@ -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
+ }