@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,126 @@
1
+ // Tool "task": el agente principal delega una subtarea a un subagente con
2
+ // contexto propio y un toolset acotado (solo lectura por defecto). Los eventos
3
+ // del subagente se emiten con `agentId` para que la consola los anide, y su
4
+ // resumen final se devuelve como resultado de la tool.
5
+
6
+ import { runAgent, createTools, resolveMode, summarizeToolCall, previewToolResult } from './core.js';
7
+
8
+ const SUBAGENT_PROMPT = [
9
+ 'Sos un subagente de Tamnora. Ejecutá de forma autónoma la tarea encomendada',
10
+ 'usando las herramientas disponibles y devolvé un resumen conciso y concreto de',
11
+ 'lo que encontraste o hiciste (archivos, hallazgos, decisiones). No pidas',
12
+ 'confirmaciones: si algo no es posible, explicalo en el resumen.',
13
+ ].join(' ');
14
+
15
+ function definition(name, description, parameters, required = []) {
16
+ return {
17
+ type: 'function',
18
+ function: { name, description, parameters: { type: 'object', properties: parameters, required } },
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Crea la tool `task` para una sesión/run y la función `execute`.
24
+ * `emit(type, data, agentId)` persiste y difunde el evento (via RunManager).
25
+ */
26
+ export function createSubagentTool(ctx, options) {
27
+ const {
28
+ sessionId,
29
+ cwd,
30
+ providerId,
31
+ model,
32
+ permissions = {},
33
+ mcp = null,
34
+ signal,
35
+ exclude = [],
36
+ emit,
37
+ } = options;
38
+
39
+ const definitions = [
40
+ definition(
41
+ 'task',
42
+ 'Delega una subtarea a un subagente con su propio contexto (solo lectura por defecto). Devolvé un prompt claro y autocontenido; el subagente responde con un resumen.',
43
+ {
44
+ description: { type: 'string', description: 'Título corto de la subtarea' },
45
+ prompt: { type: 'string', description: 'Instrucción completa y autocontenida para el subagente' },
46
+ mode: { type: 'string', enum: ['plan', 'agent'], description: 'plan (solo lectura, default) o agent' },
47
+ tools: { type: 'array', items: { type: 'string' }, description: 'Limitar las tools del subagente' },
48
+ },
49
+ ['prompt']
50
+ ),
51
+ ];
52
+
53
+ const handles = (name) => name === 'task';
54
+
55
+ async function execute(name, args = {}) {
56
+ if (!handles(name)) return `Herramienta desconocida: ${name}`;
57
+ const prompt = String(args.prompt ?? args.task ?? '').trim();
58
+ const description = String(args.description ?? prompt).slice(0, 80).trim() || 'subtarea';
59
+ if (!prompt) return 'La subtarea necesita "prompt".';
60
+
61
+ const agentId = ctx.store.createAgent({ sessionId, kind: 'task', title: description, model });
62
+ const mode = resolveMode(args.mode ?? 'plan');
63
+ emit('agent_start', { agentId, description, mode: mode.id, model }, agentId);
64
+
65
+ const enabled =
66
+ Array.isArray(args.tools) && args.tools.length
67
+ ? args.tools.filter((tool) => !exclude.includes(tool))
68
+ : mode.tools ?? undefined;
69
+ const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp });
70
+ const basePrompt = await ctx.systemPromptFor(cwd);
71
+ const messages = [
72
+ { role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
73
+ { role: 'user', content: prompt },
74
+ ];
75
+ const { provider, config } = ctx.resolveProvider({
76
+ ...ctx.providerSettings(providerId),
77
+ apiKey: ctx.apiKeyFor(providerId),
78
+ });
79
+
80
+ try {
81
+ const result = await runAgent({
82
+ provider,
83
+ config,
84
+ model,
85
+ messages,
86
+ tools: local.definitions,
87
+ execute: local.execute,
88
+ signal,
89
+ maxRounds: ctx.config.agents?.maxRounds ?? 6,
90
+ onEvent(event) {
91
+ if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
92
+ else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
93
+ else if (event.type === 'tool_call') {
94
+ emit(
95
+ 'tool_call',
96
+ { id: event.id, name: event.name, args: event.args, summary: summarizeToolCall(event.name, event.args), agentId },
97
+ agentId
98
+ );
99
+ } else if (event.type === 'tool_result') {
100
+ const { preview, truncated } = previewToolResult(event.name, event.result);
101
+ const failed = /^Error|^Permiso denegado/i.test(String(event.result));
102
+ emit('tool_result', { id: event.id, name: event.name, preview, truncated, failed, agentId }, agentId);
103
+ }
104
+ },
105
+ });
106
+
107
+ ctx.store.addMessages(
108
+ sessionId,
109
+ result.messages.slice(messages.length).map((message) => ({ ...message, agent_id: agentId }))
110
+ );
111
+ const summary = (result.content ?? '').trim() || '(sin resumen)';
112
+ const status = result.stopReason === 'aborted' ? 'aborted' : 'done';
113
+ ctx.store.finishAgent(agentId, { status, summary });
114
+ emit('agent_end', { agentId, status, content: summary, rounds: result.rounds, tokens: result.usage ?? null }, agentId);
115
+ return `Subagente "${description}" (${status}):\n\n${summary}`;
116
+ } catch (error) {
117
+ const status = error?.name === 'AbortError' ? 'aborted' : 'error';
118
+ const message = String(error?.message ?? error);
119
+ ctx.store.finishAgent(agentId, { status, summary: message });
120
+ emit('agent_end', { agentId, status, content: message }, agentId);
121
+ return `Subagente "${description}" falló (${status}): ${message}`;
122
+ }
123
+ }
124
+
125
+ return { definitions, handles, execute };
126
+ }
@@ -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
+ }
@@ -1,7 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { runAgent, createTools, resolveMode, MAX_TOOL_ROUNDS } from '../lib/core.js';
2
+ import {
3
+ runAgent,
4
+ createTools,
5
+ resolveMode,
6
+ MAX_TOOL_ROUNDS,
7
+ summarizeToolCall,
8
+ previewToolResult,
9
+ } from '../lib/core.js';
3
10
  import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
4
11
  import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
12
+ import { createSubagentTool } from '../lib/tasks.js';
5
13
 
6
14
  /**
7
15
  * Emit streaming partials at most once per `ms` window, keeping only the
@@ -49,6 +57,43 @@ export function registerChat(route, ctx) {
49
57
  sendJson(res, 200, { ok: true, allow, scope });
50
58
  });
51
59
 
60
+ route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
61
+ if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
62
+ ctx.runs.cancel(params.id);
63
+ sendJson(res, 200, { ok: true, stopped: true });
64
+ });
65
+
66
+ // Reanuda un turno en curso: reproduce los eventos guardados y sigue en vivo.
67
+ route('GET', '/api/sessions/:id/stream', ({ req, res, params, query }) => {
68
+ if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
69
+ const from = Number(query.get('from') ?? req.headers['last-event-id'] ?? 0) || 0;
70
+ const sse = openSse(req, res);
71
+
72
+ const buffered = [];
73
+ let replaying = true;
74
+ const unsubscribe = ctx.runs.subscribe(params.id, (event) => {
75
+ if (replaying) buffered.push(event);
76
+ else sse.event(event.type, event.data, event.id);
77
+ });
78
+ const stored = ctx.store.getSessionEvents(params.id, { from });
79
+ let cursor = from;
80
+ for (const event of stored) {
81
+ sse.event(event.type, event.data, event.id);
82
+ cursor = event.id;
83
+ }
84
+ replaying = false;
85
+ for (const event of buffered) {
86
+ if (event.id > cursor) sse.event(event.type, event.data, event.id);
87
+ }
88
+ const active = ctx.runs.isActive(params.id);
89
+ sse.event('resumed', { active, cursor });
90
+ req.on('close', unsubscribe);
91
+ if (!active) {
92
+ sse.event('idle', {});
93
+ sse.close();
94
+ }
95
+ });
96
+
52
97
  route('POST', '/api/chat', async ({ req, res }) => {
53
98
  const body = await readJson(req);
54
99
  const input = String(body.input ?? '').trim();
@@ -87,25 +132,28 @@ export function registerChat(route, ctx) {
87
132
  });
88
133
  }
89
134
 
90
- const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
135
+ if (ctx.runs.isActive(sessionId)) {
136
+ return sendError(res, 409, 'La sesión ya tiene una respuesta en curso.');
137
+ }
91
138
 
139
+ const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
92
140
  const sse = openSse(req, res);
93
- const controller = new AbortController();
141
+ ctx.runs.start(sessionId);
142
+ const unsubscribe = ctx.runs.subscribe(sessionId, (event) => sse.event(event.type, event.data, event.id));
143
+ // Desconectar no cancela: el run sigue y los eventos quedan persistidos.
144
+ req.on('close', () => unsubscribe());
145
+
94
146
  const autoApprove = body.yes ?? ctx.yes;
95
147
  const asked = new Set();
96
- req.on('close', () => {
97
- controller.abort();
98
- ctx.approvals.cancel(asked);
99
- });
100
148
 
101
- sse.event('start', { sessionId, provider: providerId, model, mode: mode.id, cwd: sessionCwd });
102
- sse.event('user', { content: input });
149
+ ctx.runs.emit(sessionId, 'start', { sessionId, provider: providerId, model, mode: mode.id, cwd: sessionCwd });
150
+ ctx.runs.emit(sessionId, 'user', { content: input });
103
151
 
104
152
  let mcp = null;
105
153
  try {
106
154
  mcp = await ctx.getMcp();
107
155
  } catch (error) {
108
- sse.event('warning', { message: `MCP: ${error?.message ?? error}` });
156
+ ctx.runs.emit(sessionId, 'warning', { message: `MCP: ${error?.message ?? error}` });
109
157
  }
110
158
 
111
159
  const enabled = Array.isArray(body.tools) && body.tools.length ? body.tools : mode.tools ?? undefined;
@@ -116,11 +164,11 @@ export function registerChat(route, ctx) {
116
164
  const askId = randomUUID();
117
165
  asked.add(askId);
118
166
  askMeta.set(askId, { sessionId });
119
- sse.event('permission', { askId, sessionId, name, args });
167
+ ctx.runs.emit(sessionId, 'permission', { askId, sessionId, name, args });
120
168
  const allow = await ctx.approvals.ask(askId);
121
169
  asked.delete(askId);
122
170
  askMeta.delete(askId);
123
- sse.event('permission_resolved', { askId, allow });
171
+ ctx.runs.emit(sessionId, 'permission_resolved', { askId, allow });
124
172
  return allow;
125
173
  };
126
174
  const local = createTools({
@@ -134,11 +182,29 @@ export function registerChat(route, ctx) {
134
182
  // En modo plan (solo lectura) no exponemos las tools de administración.
135
183
  const exposeAdmin = !mode.tools;
136
184
  const admin = ctx.agentTools;
137
- const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
138
- const execute = (name, args, context) =>
139
- exposeAdmin && admin.handles(name)
140
- ? admin.execute(name, args, { approve: autoApprove ? async () => true : onAsk })
141
- : local.execute(name, args, context);
185
+ const emitAgent = (type, data, agentId = null) => ctx.runs.emit(sessionId, type, data, { agentId });
186
+ const tasks = createSubagentTool(ctx, {
187
+ sessionId,
188
+ cwd: sessionCwd,
189
+ providerId,
190
+ model,
191
+ permissions: ctx.config.permissions,
192
+ mcp,
193
+ signal: ctx.runs.signal(sessionId),
194
+ exclude: admin.definitions.map((d) => d.function.name),
195
+ emit: emitAgent,
196
+ });
197
+ const definitions = [
198
+ ...(exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions),
199
+ ...tasks.definitions,
200
+ ];
201
+ const execute = (name, args, context) => {
202
+ if (tasks.handles(name)) return tasks.execute(name, args);
203
+ if (exposeAdmin && admin.handles(name)) {
204
+ return admin.execute(name, args, { approve: autoApprove ? async () => true : onAsk });
205
+ }
206
+ return local.execute(name, args, context);
207
+ };
142
208
 
143
209
  const { provider, config } = ctx.resolveProvider({
144
210
  ...ctx.providerSettings(providerId),
@@ -167,66 +233,89 @@ export function registerChat(route, ctx) {
167
233
  ...history,
168
234
  userMessage,
169
235
  ];
236
+ // Persistimos el mensaje del usuario ya: asi sobrevive a un corte y la
237
+ // reconexion puede reconstruir el turno desde el historial.
238
+ ctx.store.addMessage(sessionId, userMessage);
239
+ const prefixLength = messages.length;
170
240
 
171
- try {
172
- const partials = createPartialThrottle(300, (name, payload) => sse.event(name, payload));
173
- const result = await runAgent({
174
- provider,
175
- config,
176
- model,
177
- messages,
178
- tools: definitions,
179
- execute,
180
- signal: controller.signal,
181
- maxRounds: Number(body.maxRounds) || ctx.config.maxRounds || MAX_TOOL_ROUNDS,
182
- stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
183
- contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
184
- onEvent(event) {
185
- if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
186
- else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
187
- else if (event.type === 'stall') {
188
- partials.flush();
189
- sse.event('stall', { seconds: event.seconds });
190
- } else if (event.type === 'retry') {
191
- partials.flush();
192
- sse.event('retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
193
- } else if (event.type === 'trim') {
194
- partials.flush();
195
- sse.event('trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
196
- } else if (event.type === 'tool_round') {
197
- partials.flush();
198
- sse.event('tools', { toolCalls: event.toolCalls });
199
- } else if (event.type === 'tool_call') {
200
- partials.flush();
201
- sse.event('tool_call', { name: event.name, args: event.args });
202
- } else if (event.type === 'tool_result') {
203
- partials.flush();
204
- sse.event('tool_result', { name: event.name, result: String(event.result).slice(0, 8000) });
205
- }
206
- },
207
- });
208
- partials.flush();
209
-
210
- const newMessages = result.messages
211
- .slice(1 + history.length)
212
- .map((message) => (message.role === 'assistant' ? { ...message, mode: mode.id } : message));
213
- ctx.store.addMessages(sessionId, newMessages);
214
- const current = ctx.store.getSession(sessionId);
215
- const title =
216
- current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
217
- ctx.store.touchSession(sessionId, { provider: providerId, model, title });
218
- sse.event('done', {
219
- sessionId,
220
- content: result.content,
221
- rounds: result.rounds,
222
- stopReason: result.stopReason,
223
- tokens: result.usage ?? null,
224
- });
225
- } catch (error) {
226
- if (error?.name === 'AbortError') sse.event('aborted', {});
227
- else sse.event('error', { message: error?.message ?? String(error) });
228
- } finally {
229
- sse.close();
230
- }
241
+ (async () => {
242
+ const startedAt = Date.now();
243
+ const partials = createPartialThrottle(300, (name, payload) => ctx.runs.emit(sessionId, name, payload));
244
+ try {
245
+ const result = await runAgent({
246
+ provider,
247
+ config,
248
+ model,
249
+ messages,
250
+ tools: definitions,
251
+ execute,
252
+ signal: ctx.runs.signal(sessionId),
253
+ maxRounds: Number(body.maxRounds) || ctx.config.maxRounds || MAX_TOOL_ROUNDS,
254
+ stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
255
+ contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
256
+ onEvent(event) {
257
+ if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
258
+ else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
259
+ else if (event.type === 'stall') {
260
+ partials.flush();
261
+ ctx.runs.emit(sessionId, 'stall', { seconds: event.seconds });
262
+ } else if (event.type === 'retry') {
263
+ partials.flush();
264
+ ctx.runs.emit(sessionId, 'retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
265
+ } else if (event.type === 'trim') {
266
+ partials.flush();
267
+ ctx.runs.emit(sessionId, 'trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
268
+ } else if (event.type === 'tool_round') {
269
+ partials.flush();
270
+ ctx.runs.emit(sessionId, 'tools', { toolCalls: event.toolCalls });
271
+ } else if (event.type === 'tool_call') {
272
+ partials.flush();
273
+ ctx.runs.emit(sessionId, 'tool_call', {
274
+ id: event.id,
275
+ name: event.name,
276
+ args: event.args,
277
+ summary: summarizeToolCall(event.name, event.args),
278
+ });
279
+ } else if (event.type === 'tool_result') {
280
+ partials.flush();
281
+ const { preview, truncated } = previewToolResult(event.name, event.result);
282
+ const failed = /^Error|^Permiso denegado/i.test(String(event.result));
283
+ ctx.runs.emit(sessionId, 'tool_result', { id: event.id, name: event.name, preview, truncated, failed });
284
+ }
285
+ },
286
+ });
287
+ partials.flush();
288
+
289
+ const newMessages = result.messages
290
+ .slice(prefixLength)
291
+ .map((message) => (message.role === 'assistant' ? { ...message, mode: mode.id } : message));
292
+ ctx.store.addMessages(sessionId, newMessages);
293
+ const current = ctx.store.getSession(sessionId);
294
+ const title =
295
+ current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
296
+ ctx.store.touchSession(sessionId, { provider: providerId, model, title });
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 });
300
+ else
301
+ ctx.runs.emit(sessionId, 'done', {
302
+ sessionId,
303
+ content: result.content,
304
+ rounds: result.rounds,
305
+ stopReason: result.stopReason,
306
+ tokens: result.usage ?? null,
307
+ toolCalls,
308
+ durationMs,
309
+ });
310
+ } catch (error) {
311
+ partials.flush();
312
+ if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', {});
313
+ else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error) });
314
+ } finally {
315
+ unsubscribe();
316
+ sse.close();
317
+ ctx.runs.finish(sessionId);
318
+ }
319
+ })();
231
320
  });
232
321
  }
@@ -76,9 +76,26 @@ export function registerSessions(route, ctx) {
76
76
  session: view(session),
77
77
  tokens: ctx.store.sessionTokens(params.id),
78
78
  messages: ctx.store.getMessages(params.id),
79
+ running: ctx.runs?.isActive(params.id) ?? false,
79
80
  });
80
81
  });
81
82
 
83
+ route('GET', '/api/sessions/:id/agents', ({ res, params }) => {
84
+ if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
85
+ sendJson(res, 200, { agents: ctx.store.listAgents(params.id) });
86
+ });
87
+
88
+ route('GET', '/api/sessions/:id/tool-result', ({ res, params, query }) => {
89
+ if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
90
+ const callId = String(query.get('callId') || '');
91
+ if (!callId) return sendError(res, 400, 'Falta "callId".');
92
+ const message = ctx.store.getMessages(params.id).find((m) => m.tool_call_id === callId);
93
+ if (!message) return sendError(res, 404, 'Resultado no encontrado.');
94
+ const content =
95
+ typeof message.content === 'string' ? message.content : JSON.stringify(message.content, null, 2);
96
+ sendJson(res, 200, { callId, name: message.name ?? null, content });
97
+ });
98
+
82
99
  route('PUT', '/api/sessions/:id', async ({ req, res, params }) => {
83
100
  const session = ctx.store.getSession(params.id);
84
101
  if (!session) return sendError(res, 404, 'Sesión no encontrada.');
@@ -18,8 +18,9 @@ import { sendJson, sendError } from './lib/http.js';
18
18
  import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
19
19
  import { skillsPrompt } from './lib/skills.js';
20
20
  import { createApprovalBroker } from './lib/approvals.js';
21
+ import { createRunManager } from './lib/runManager.js';
21
22
  import { createProcManager } from './lib/procs.js';
22
- import { startTelegramBot } from './lib/telegram.js';
23
+ import { createChannelsManager } from './lib/channels/index.js';
23
24
  import { createAgentTools } from './lib/agentTools.js';
24
25
  import { createStaticHandler } from './static.js';
25
26
  import { requireLogin, requireCsrf } from './auth.js';
@@ -31,6 +32,7 @@ import { registerModels } from './routes/models.js';
31
32
  import { registerProviders } from './routes/providers.js';
32
33
  import { registerMcp } from './routes/mcp.js';
33
34
  import { registerSkills } from './routes/skills.js';
35
+ import { registerChannels } from './routes/channels.js';
34
36
  import { registerProcs } from './routes/procs.js';
35
37
  import { registerPreview } from './routes/preview.js';
36
38
  import { registerChat } from './routes/chat.js';
@@ -71,6 +73,7 @@ export async function createTamnoraServer({
71
73
  const authEnabled = Boolean(app.user && app.user.password);
72
74
  const store = new Store(dbPath ?? databasePath(cwd));
73
75
  const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
76
+ const runs = createRunManager({ store });
74
77
  const procs = createProcManager({ allow: config.processes?.allow });
75
78
  const procsEnabled = config.processes?.enabled !== false;
76
79
  const autoApproved = new Set();
@@ -142,6 +145,7 @@ export async function createTamnoraServer({
142
145
  authEnabled,
143
146
  store,
144
147
  approvals,
148
+ runs,
145
149
  isAutoApprove,
146
150
  setAutoApprove,
147
151
  yes,
@@ -167,20 +171,13 @@ export async function createTamnoraServer({
167
171
  ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
168
172
  ctx.agentTools = createAgentTools(ctx);
169
173
 
170
- // Bot de Telegram (long polling) si hay token: config, app.json o entorno.
171
- const tgConfig = { ...(app.telegram ?? {}), ...(config.telegram ?? {}) };
172
- const tgToken = tgConfig.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
173
- const rawAllow = tgConfig.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? [];
174
- const tgAllow = Array.isArray(rawAllow) ? rawAllow : String(rawAllow).split(/[,\s]+/).filter(Boolean);
175
- const telegramBot =
176
- telegram && tgToken
177
- ? startTelegramBot(ctx, {
178
- token: tgToken,
179
- allowedChatIds: tgAllow,
180
- log: (message) => process.stderr.write(`${message}\n`),
181
- })
182
- : null;
183
- ctx.telegram = Boolean(telegramBot);
174
+ // Canales de mensajeria (Telegram via long polling) si hay token.
175
+ const channels = createChannelsManager(ctx, {
176
+ enabled: telegram,
177
+ log: (message) => process.stderr.write(`${message}\n`),
178
+ });
179
+ channels.load();
180
+ ctx.channels = channels;
184
181
 
185
182
  const routes = [];
186
183
  const route = (method, pattern, handler) =>
@@ -206,6 +203,7 @@ export async function createTamnoraServer({
206
203
  registerProviders(route, ctx);
207
204
  registerMcp(route, ctx);
208
205
  registerSkills(route, ctx);
206
+ registerChannels(route, ctx);
209
207
  registerProcs(route, ctx);
210
208
  const preview = registerPreview(route, ctx);
211
209
  registerChat(route, ctx);
@@ -290,7 +288,8 @@ export async function createTamnoraServer({
290
288
 
291
289
  const close = async () => {
292
290
  approvals.cancelAll();
293
- telegramBot?.stop();
291
+ runs.cancelAll();
292
+ channels.stop();
294
293
  preview.close();
295
294
  procs.close();
296
295
  try {