@danieltmn/tamnora 0.8.0 → 0.9.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/harness/packages/cli/src/cli.js +1 -1
  3. package/harness/packages/cli/src/commands/bridge.js +180 -119
  4. package/harness/packages/cli/src/commands/server.js +26 -50
  5. package/harness/packages/cli/src/lib/daemon.js +92 -40
  6. package/harness/packages/cli/test/release.test.js +9 -0
  7. package/harness/packages/core/src/agent/modes.js +1 -1
  8. package/harness/packages/core/src/index.js +14 -1
  9. package/harness/packages/core/src/open.js +43 -0
  10. package/harness/packages/core/src/pid.js +35 -0
  11. package/harness/packages/core/src/tools/executors.js +16 -0
  12. package/harness/packages/core/src/tools/labels.js +2 -0
  13. package/harness/packages/core/src/tools/permissions.js +1 -0
  14. package/harness/packages/core/src/tools/schema.js +17 -0
  15. package/harness/packages/core/src/tts/edge.js +232 -0
  16. package/harness/packages/core/src/tunnel.js +180 -19
  17. package/harness/packages/core/test/open.test.js +19 -0
  18. package/harness/packages/core/test/tools.test.js +12 -0
  19. package/harness/packages/core/test/tts.test.js +41 -0
  20. package/harness/packages/server/public/app.js +4715 -3975
  21. package/harness/packages/server/public/index.html +63 -5
  22. package/harness/packages/server/public/reconcile.js +121 -0
  23. package/harness/packages/server/public/relayClient.js +170 -0
  24. package/harness/packages/server/public/runStream.js +207 -0
  25. package/harness/packages/server/public/styles.css +142 -5
  26. package/harness/packages/server/src/auth.js +29 -5
  27. package/harness/packages/server/src/lib/agentRun.js +77 -0
  28. package/harness/packages/server/src/lib/announcer.js +92 -0
  29. package/harness/packages/server/src/lib/approvals.js +25 -3
  30. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  31. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  32. package/harness/packages/server/src/lib/procs.js +3 -18
  33. package/harness/packages/server/src/lib/runManager.js +24 -2
  34. package/harness/packages/server/src/lib/sessionView.js +43 -0
  35. package/harness/packages/server/src/lib/tasks.js +4 -9
  36. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  37. package/harness/packages/server/src/lib/workspace.js +37 -1
  38. package/harness/packages/server/src/routes/chat.js +48 -47
  39. package/harness/packages/server/src/routes/events.js +35 -0
  40. package/harness/packages/server/src/routes/files.js +22 -26
  41. package/harness/packages/server/src/routes/folders.js +9 -17
  42. package/harness/packages/server/src/routes/git.js +6 -14
  43. package/harness/packages/server/src/routes/procs.js +14 -16
  44. package/harness/packages/server/src/routes/sessions.js +12 -19
  45. package/harness/packages/server/src/routes/shell.js +5 -9
  46. package/harness/packages/server/src/routes/state.js +32 -0
  47. package/harness/packages/server/src/routes/tts.js +82 -0
  48. package/harness/packages/server/src/server.js +21 -1
  49. package/harness/packages/server/test/announcer.test.js +51 -0
  50. package/harness/packages/server/test/reconcile.test.js +146 -0
  51. package/harness/packages/server/test/relay.test.js +219 -0
  52. package/harness/packages/server/test/runStream.test.js +245 -0
  53. package/harness/packages/server/test/security.test.js +252 -0
  54. package/harness/packages/server/test/server.test.js +511 -1
  55. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  56. package/harness/packages/server/test/tts.test.js +133 -0
  57. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ // Conversion MP3 -> OGG/Opus para las notas de voz de Telegram (sendVoice
2
+ // exige OGG con Opus). Telegram acepta MP3 solo como audio normal, asi que sin
3
+ // ffmpeg caemos a ese formato. Se detecta una sola vez por proceso.
4
+
5
+ import { spawn } from 'node:child_process';
6
+
7
+ let disponible = null;
8
+
9
+ /** `true` si hay un ffmpeg ejecutable en el PATH (se cachea). */
10
+ export function ffmpegDisponible() {
11
+ if (disponible === null) {
12
+ disponible = new Promise((resolve) => {
13
+ const hijo = spawn('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true });
14
+ hijo.on('error', () => resolve(false));
15
+ hijo.on('close', (code) => resolve(code === 0));
16
+ });
17
+ }
18
+ return disponible;
19
+ }
20
+
21
+ /**
22
+ * Convierte un buffer de MP3 a OGG/Opus por stdin/stdout.
23
+ * Devuelve el buffer o `null` si no hay ffmpeg o la conversion falla.
24
+ */
25
+ export async function mp3AOgg(mp3) {
26
+ if (!Buffer.isBuffer(mp3) || !mp3.length) return null;
27
+ if (!(await ffmpegDisponible())) return null;
28
+ return new Promise((resolve) => {
29
+ const hijo = spawn(
30
+ 'ffmpeg',
31
+ ['-hide_banner', '-loglevel', 'error', '-i', 'pipe:0', '-c:a', 'libopus', '-b:a', '32k', '-f', 'ogg', 'pipe:1'],
32
+ { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true }
33
+ );
34
+ const chunks = [];
35
+ let fallo = false;
36
+ hijo.stdout.on('data', (chunk) => chunks.push(chunk));
37
+ hijo.on('error', () => {
38
+ fallo = true;
39
+ resolve(null);
40
+ });
41
+ hijo.on('close', (code) => {
42
+ if (fallo) return;
43
+ resolve(code === 0 && chunks.length ? Buffer.concat(chunks) : null);
44
+ });
45
+ hijo.stdin.on('error', () => {});
46
+ hijo.stdin.end(mp3);
47
+ });
48
+ }
@@ -1,30 +1,15 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import path from 'node:path';
4
- import { DEFAULT_PROCESS_ALLOW } from './core.js';
4
+ import { DEFAULT_PROCESS_ALLOW, killPidTree } from './core.js';
5
5
 
6
6
  const MAX_LOG_LINES = 400;
7
7
 
8
8
  function killTree(child) {
9
9
  if (!child || child.killed) return;
10
- const pid = child.pid;
10
+ killPidTree(child.pid);
11
11
  try {
12
- if (process.platform === 'win32' && pid) {
13
- spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
14
- } else {
15
- if (pid) {
16
- try {
17
- process.kill(-pid, 'SIGTERM');
18
- } catch {
19
- /* sin grupo */
20
- }
21
- }
22
- try {
23
- child.kill('SIGTERM');
24
- } catch {
25
- /* ya murio */
26
- }
27
- }
12
+ child.kill('SIGTERM');
28
13
  } catch {
29
14
  /* ya murio */
30
15
  }
@@ -38,6 +38,25 @@ export function createRunManager({ store }) {
38
38
  }
39
39
  }
40
40
  }
41
+ notify(sessionId, type, data, event.id);
42
+ return event;
43
+ }
44
+
45
+ /**
46
+ * Ciclo de vida del run hacia los suscriptores globales (`subscribeAll`).
47
+ *
48
+ * A diferencia de `emit`, esto NO se persiste: es una notificacion, no parte
49
+ * del log del run. Persistirla desalinearia el cursor de `?from=` y ademas la
50
+ * borraria `clearSessionEvents` al empezar el siguiente run. El `id: 0` marca
51
+ * "esto no viene del log".
52
+ */
53
+ function announce(sessionId, phase, extra = {}) {
54
+ const type = phase === 'start' ? 'run_start' : 'run_end';
55
+ notify(sessionId, type, { sessionId, ...extra }, 0);
56
+ }
57
+
58
+ function notify(sessionId, type, data, id) {
59
+ const event = { id, type, data, agentId: null };
41
60
  for (const listener of [...listeners]) {
42
61
  try {
43
62
  listener(sessionId, event);
@@ -45,7 +64,6 @@ export function createRunManager({ store }) {
45
64
  // un listener caido no debe frenar el run
46
65
  }
47
66
  }
48
- return event;
49
67
  }
50
68
 
51
69
  function start(sessionId) {
@@ -54,6 +72,7 @@ export function createRunManager({ store }) {
54
72
  const run = { sessionId, controller, subscribers: new Set(), lastEvent: null, startedAt: Date.now() };
55
73
  runs.set(sessionId, run);
56
74
  store.clearSessionEvents(sessionId);
75
+ announce(sessionId, 'start', { at: run.startedAt });
57
76
  return run;
58
77
  }
59
78
 
@@ -62,6 +81,7 @@ export function createRunManager({ store }) {
62
81
  if (!run) return;
63
82
  run.subscribers.clear();
64
83
  runs.delete(sessionId);
84
+ announce(sessionId, 'end', { durationMs: Date.now() - run.startedAt });
65
85
  }
66
86
 
67
87
  function cancel(sessionId) {
@@ -69,8 +89,10 @@ export function createRunManager({ store }) {
69
89
  }
70
90
 
71
91
  function cancelAll() {
72
- for (const run of runs.values()) run.controller.abort();
92
+ const active = [...runs.values()];
93
+ for (const run of active) run.controller.abort();
73
94
  runs.clear();
95
+ for (const run of active) announce(run.sessionId, 'end', { durationMs: Date.now() - run.startedAt, reason: 'shutdown' });
74
96
  }
75
97
 
76
98
  function isActive(sessionId) {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Vista publica de una sesion, compartida por `/api/sessions` y `/api/state`.
3
+ *
4
+ * Vive aparte para que las dos rutas no diverjan: el cliente reconcilia contra
5
+ * `/api/state` y dibuja el sidebar con esa misma forma, asi que un campo nuevo
6
+ * tiene que salir de un solo lugar.
7
+ */
8
+ export function sessionView(session) {
9
+ return {
10
+ id: session.id,
11
+ title: session.title,
12
+ provider: session.provider,
13
+ model: session.model,
14
+ cwd: session.cwd,
15
+ goal: session.meta?.goal ?? null,
16
+ hasSummary: Boolean(session.meta?.summary),
17
+ createdAt: session.created_at,
18
+ updatedAt: session.updated_at,
19
+ };
20
+ }
21
+
22
+ /** La sesion + su estado de vida, tal como la ve el cliente. */
23
+ export function sessionState(session, ctx, awaiting = 0) {
24
+ return {
25
+ ...sessionView(session),
26
+ running: ctx.runs?.isActive(session.id) ?? false,
27
+ awaiting,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Cuenta los permisos pendientes por sesion.
33
+ *
34
+ * "Trabajando" y "esperandote a vos" son cosas distintas: un run que avanza solo
35
+ * no necesita tu atencion, un permiso si.
36
+ */
37
+ export function awaitingBySession(pending) {
38
+ const counts = new Map();
39
+ for (const ask of pending) {
40
+ counts.set(ask.sessionId, (counts.get(ask.sessionId) ?? 0) + 1);
41
+ }
42
+ return counts;
43
+ }
@@ -3,7 +3,8 @@
3
3
  // del subagente se emiten con `agentId` para que la consola los anide, y su
4
4
  // resumen final se devuelve como resultado de la tool.
5
5
 
6
- import { runAgent, createTools, resolveMode, summarizeToolCall, previewToolResult } from './core.js';
6
+ import { runAgent, createTools, resolveMode } from './core.js';
7
+ import { toolCallPayload, toolResultPayload } from './agentRun.js';
7
8
 
8
9
  const SUBAGENT_PROMPT = [
9
10
  'Sos un subagente de Tamnora. Ejecutá de forma autónoma la tarea encomendada',
@@ -91,15 +92,9 @@ export function createSubagentTool(ctx, options) {
91
92
  if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
92
93
  else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
93
94
  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
- );
95
+ emit('tool_call', { ...toolCallPayload(event), agentId }, agentId);
99
96
  } 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);
97
+ emit('tool_result', { id: event.id, name: event.name, ...toolResultPayload(event), agentId }, agentId);
103
98
  }
104
99
  },
105
100
  });
@@ -0,0 +1,42 @@
1
+ // Config de voz (TTS) efectiva: lo guardado en app.json (la casa) sobre los
2
+ // defaults. La leen la ruta de ajustes y el canal de Telegram en el momento de
3
+ // hablar, asi un cambio no necesita recargar nada.
4
+
5
+ import { VOZ_POR_DEFECTO } from './core.js';
6
+
7
+ export const DEFAULT_TTS = {
8
+ enabled: false,
9
+ voice: VOZ_POR_DEFECTO,
10
+ rate: '+0%',
11
+ announce: { start: true, tools: true, end: true },
12
+ };
13
+
14
+ /** Merge de defaults + lo guardado (con `announce` aplanado a booleans). */
15
+ export function resolveTts(app) {
16
+ const saved = app?.tts && typeof app.tts === 'object' ? app.tts : {};
17
+ const announce = { ...DEFAULT_TTS.announce, ...(saved.announce ?? {}) };
18
+ for (const key of ['start', 'tools', 'end']) announce[key] = announce[key] === true;
19
+ return {
20
+ ...DEFAULT_TTS,
21
+ ...saved,
22
+ announce,
23
+ enabled: saved.enabled === true,
24
+ voice: typeof saved.voice === 'string' && saved.voice ? saved.voice : DEFAULT_TTS.voice,
25
+ rate: typeof saved.rate === 'string' && saved.rate ? saved.rate : DEFAULT_TTS.rate,
26
+ };
27
+ }
28
+
29
+ /** Sanitiza un nombre de voz (ej. "es-AR-ElenaNeural"). */
30
+ export function sanearVoz(value, fallback = VOZ_POR_DEFECTO) {
31
+ const voz = String(value ?? '').trim();
32
+ return /^[a-zA-Z]{2}-[a-zA-Z]{2}-[a-zA-Z]+$/.test(voz) ? voz : fallback;
33
+ }
34
+
35
+ /** Sanitiza una velocidad: "+20%", "-15%", "20%" -> "+20%". */
36
+ export function sanearRate(value, fallback = '+0%') {
37
+ const match = /^([+-]?)(\d+)%$/.exec(String(value ?? '').trim());
38
+ if (!match) return fallback;
39
+ const numero = Math.min(100, Number(match[2]));
40
+ if (numero === 0) return '+0%';
41
+ return `${match[1] === '-' ? '-' : '+'}${numero}%`;
42
+ }
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { sendError } from './http.js';
4
+ import { tunnelState } from './core.js';
3
5
 
4
6
  /** True for 127.x / ::1 / ::ffff:127.x addresses. */
5
7
  export function isLoopback(address) {
@@ -7,6 +9,24 @@ export function isLoopback(address) {
7
9
  return addr === '::1' || addr === '0:0:0:0:0:0:0:1' || addr.startsWith('127.') || addr === '::ffff:127.0.0.1';
8
10
  }
9
11
 
12
+ /**
13
+ * "Local de verdad": el par TCP es loopback (remoteAddress, no localAddress:
14
+ * esa es la direccion donde bindea el server, siempre 127.0.0.1) y no hay un
15
+ * tunel publico activo. Con el tunel abierto las peticiones remotas TAMBIEN
16
+ * llegan desde loopback (el cliente del tunel corre en la misma maquina), asi
17
+ * que la ausencia de tunel es parte del check.
18
+ */
19
+ export function localOk(req) {
20
+ return isLoopback(req?.socket?.remoteAddress) && !tunnelState();
21
+ }
22
+
23
+ /** `localOk` o 403. Devuelve true si la request puede seguir. */
24
+ export function requireLocal(req, res, mensaje = 'Solo disponible en conexiones locales.') {
25
+ if (localOk(req)) return true;
26
+ sendError(res, 403, tunnelState() ? `${mensaje} Hay un túnel público activo.` : mensaje);
27
+ return false;
28
+ }
29
+
10
30
  /** Filesystem roots (drive letters on Windows, `/` elsewhere). */
11
31
  export function rootDirs() {
12
32
  if (process.platform !== 'win32') return ['/'];
@@ -40,6 +60,22 @@ export function resolveExistingDir(input) {
40
60
  return target;
41
61
  }
42
62
 
63
+ /** `resolveExistingDir` con 400 en vez de throw (patron comun en las routes). */
64
+ export function resolveDirOr400(res, raw, fallback) {
65
+ try {
66
+ return resolveExistingDir(raw || fallback);
67
+ } catch (error) {
68
+ sendError(res, 400, error.message);
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** True si `target` es `root` o vive dentro de el (sin salirse por `..`). */
74
+ export function isInside(root, target) {
75
+ const rel = path.relative(root, target);
76
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
77
+ }
78
+
43
79
  /**
44
80
  * Workspace for an HTTP request: the requested `cwd` (loopback-only) or the
45
81
  * server's default. Throws on invalid input.
@@ -47,7 +83,7 @@ export function resolveExistingDir(input) {
47
83
  export function workspaceForRequest({ req, raw, fallback }) {
48
84
  const value = String(raw ?? '').trim();
49
85
  if (!value) return fallback;
50
- if (!isLoopback(req.socket.localAddress)) {
86
+ if (!localOk(req)) {
51
87
  throw new Error('Cambiar la carpeta de trabajo solo está permitido en conexiones locales.');
52
88
  }
53
89
  return resolveExistingDir(value);
@@ -4,12 +4,19 @@ import {
4
4
  createTools,
5
5
  resolveMode,
6
6
  MAX_TOOL_ROUNDS,
7
- summarizeToolCall,
8
- previewToolResult,
9
7
  } from '../lib/core.js';
10
8
  import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
11
9
  import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
12
10
  import { createSubagentTool } from '../lib/tasks.js';
11
+ import {
12
+ combineToolset,
13
+ composeAgentMessages,
14
+ buildUserMessage,
15
+ resolveProviderFor,
16
+ toolCallPayload,
17
+ toolResultPayload,
18
+ } from '../lib/agentRun.js';
19
+ import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/announcer.js';
13
20
 
14
21
  /**
15
22
  * Emit streaming partials at most once per `ms` window, keeping only the
@@ -57,6 +64,13 @@ export function registerChat(route, ctx) {
57
64
  sendJson(res, 200, { ok: true, allow, scope });
58
65
  });
59
66
 
67
+ // Lo pendiente, para que un cliente sin stream abierto (el celular mirando
68
+ // otra sesion) sepa igual que hay un permiso esperando.
69
+ route('GET', '/api/permissions', ({ res, query }) => {
70
+ const sessionId = query.get('sessionId') ?? undefined;
71
+ sendJson(res, 200, { pending: ctx.approvals.list({ sessionId }) });
72
+ });
73
+
60
74
  route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
61
75
  if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
62
76
  ctx.runs.cancel(params.id);
@@ -146,7 +160,14 @@ export function registerChat(route, ctx) {
146
160
  const autoApprove = body.yes ?? ctx.yes;
147
161
  const asked = new Set();
148
162
 
149
- ctx.runs.emit(sessionId, 'start', { sessionId, provider: providerId, model, mode: mode.id, cwd: sessionCwd });
163
+ ctx.runs.emit(sessionId, 'start', {
164
+ sessionId,
165
+ provider: providerId,
166
+ model,
167
+ mode: mode.id,
168
+ cwd: sessionCwd,
169
+ say: fraseInicio(),
170
+ });
150
171
  ctx.runs.emit(sessionId, 'user', { content: input });
151
172
 
152
173
  let mcp = null;
@@ -165,7 +186,7 @@ export function registerChat(route, ctx) {
165
186
  asked.add(askId);
166
187
  askMeta.set(askId, { sessionId });
167
188
  ctx.runs.emit(sessionId, 'permission', { askId, sessionId, name, args });
168
- const allow = await ctx.approvals.ask(askId);
189
+ const allow = await ctx.approvals.ask(askId, { sessionId, name, args });
169
190
  asked.delete(askId);
170
191
  askMeta.delete(askId);
171
192
  ctx.runs.emit(sessionId, 'permission_resolved', { askId, allow });
@@ -194,45 +215,24 @@ export function registerChat(route, ctx) {
194
215
  exclude: admin.definitions.map((d) => d.function.name),
195
216
  emit: emitAgent,
196
217
  });
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
- };
218
+ const approve = autoApprove ? async () => true : (name, args) => onAsk(name, args);
219
+ const { definitions, execute } = combineToolset({ local, admin, tasks, exposeAdmin, approve });
208
220
 
209
- const { provider, config } = ctx.resolveProvider({
210
- ...ctx.providerSettings(providerId),
211
- apiKey: ctx.apiKeyFor(providerId),
221
+ const { provider, config } = resolveProviderFor(ctx, providerId, {
212
222
  baseUrl: body.baseUrl || ctx.providerSettings(providerId).baseUrl,
213
223
  });
214
224
 
215
- const userMessage = images.length
216
- ? {
217
- role: 'user',
218
- content: [
219
- { type: 'text', text: input },
220
- ...images.map((url) => ({ type: 'image_url', image_url: { url } })),
221
- ],
222
- }
223
- : { role: 'user', content: input };
225
+ const userMessage = buildUserMessage(input, images);
224
226
 
225
227
  const sessionRecord = ctx.store.getSession(sessionId);
226
- const goal = sessionRecord?.meta?.goal;
227
- const summary = sessionRecord?.meta?.summary;
228
- const basePrompt = await ctx.systemPromptFor(sessionCwd);
229
- const messages = [
230
- { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
231
- ...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
232
- ...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
233
- ...history,
228
+ const messages = await composeAgentMessages({
229
+ ctx,
230
+ cwd: sessionCwd,
231
+ mode,
232
+ history,
234
233
  userMessage,
235
- ];
234
+ sessionRecord,
235
+ });
236
236
  // Persistimos el mensaje del usuario ya: asi sobrevive a un corte y la
237
237
  // reconexion puede reconstruir el turno desde el historial.
238
238
  ctx.store.addMessage(sessionId, userMessage);
@@ -240,6 +240,7 @@ export function registerChat(route, ctx) {
240
240
 
241
241
  (async () => {
242
242
  const startedAt = Date.now();
243
+ const filtroTools = createFiltroTools();
243
244
  const partials = createPartialThrottle(300, (name, payload) => ctx.runs.emit(sessionId, name, payload));
244
245
  try {
245
246
  const result = await runAgent({
@@ -275,17 +276,15 @@ export function registerChat(route, ctx) {
275
276
  ctx.runs.emit(sessionId, 'tools', { toolCalls: event.toolCalls });
276
277
  } else if (event.type === 'tool_call') {
277
278
  partials.flush();
278
- ctx.runs.emit(sessionId, 'tool_call', {
279
+ const payload = toolCallPayload(event);
280
+ ctx.runs.emit(sessionId, 'tool_call', { ...payload, say: filtroTools(event.name, payload.summary) });
281
+ } else if (event.type === 'tool_result') {
282
+ partials.flush();
283
+ ctx.runs.emit(sessionId, 'tool_result', {
279
284
  id: event.id,
280
285
  name: event.name,
281
- args: event.args,
282
- summary: summarizeToolCall(event.name, event.args),
286
+ ...toolResultPayload(event),
283
287
  });
284
- } else if (event.type === 'tool_result') {
285
- partials.flush();
286
- const { preview, truncated } = previewToolResult(event.name, event.result);
287
- const failed = /^Error|^Permiso denegado/i.test(String(event.result));
288
- ctx.runs.emit(sessionId, 'tool_result', { id: event.id, name: event.name, preview, truncated, failed });
289
288
  } else if (event.type === 'repeat') {
290
289
  partials.flush();
291
290
  ctx.runs.emit(sessionId, 'repeat', { name: event.name, count: event.count });
@@ -304,7 +303,8 @@ export function registerChat(route, ctx) {
304
303
  ctx.store.touchSession(sessionId, { provider: providerId, model, title });
305
304
  const toolCalls = newMessages.filter((message) => message.role === 'tool').length;
306
305
  const durationMs = Date.now() - startedAt;
307
- if (result.stopReason === 'aborted') ctx.runs.emit(sessionId, 'aborted', { durationMs, toolCalls });
306
+ if (result.stopReason === 'aborted')
307
+ ctx.runs.emit(sessionId, 'aborted', { durationMs, toolCalls, say: fraseFin({ stopReason: 'aborted' }) });
308
308
  else
309
309
  ctx.runs.emit(sessionId, 'done', {
310
310
  sessionId,
@@ -316,11 +316,12 @@ export function registerChat(route, ctx) {
316
316
  tokens: result.usage ?? null,
317
317
  toolCalls,
318
318
  durationMs,
319
+ say: fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }),
319
320
  });
320
321
  } catch (error) {
321
322
  partials.flush();
322
- if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', {});
323
- else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error) });
323
+ if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', { say: fraseFin({ stopReason: 'aborted' }) });
324
+ else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error), say: fraseError() });
324
325
  } finally {
325
326
  unsubscribe();
326
327
  sse.close();
@@ -0,0 +1,35 @@
1
+ import { openSse } from '../lib/http.js';
2
+
3
+ /**
4
+ * Canal global de eventos: un SSE por pestaña de la consola.
5
+ *
6
+ * Sirve para que varias ventanas (o la PC y el celular a la vez) se enteren de
7
+ * que un run arranco en una sesion, sin tener cada una su propio stream por
8
+ * sesion abierto.
9
+ *
10
+ * Solo van eventos de ciclo de vida y **los permisos**. El detalle del turno
11
+ * sigue yendo por `/api/sessions/:id/stream`: filtrar es obligatorio, porque
12
+ * `subscribeAll` ve cada parcial de texto de todos los runs y reenviarlos seria
13
+ * duplicar el stream por sesion.
14
+ *
15
+ * Los permisos van aparte justamente porque son lo unico que, si solo viviera
16
+ * en el stream, un cliente no suscrito a esa sesion no tendria forma de saber
17
+ * que esta esperando una decision suya.
18
+ *
19
+ * Al conectar se manda `snapshot` con las sesiones que ya estan corriendo y con
20
+ * los permisos pendientes: es lo que hace el canal auto-sanador. Si el stream se
21
+ * corta y vuelve, el snapshot le dice al cliente que se quedo a medio camino.
22
+ */
23
+ const GLOBAL_EVENTS = new Set(['run_start', 'run_end', 'permission', 'permission_resolved']);
24
+
25
+ export function registerEvents(route, ctx) {
26
+ route('GET', '/api/events', ({ req, res }) => {
27
+ const sse = openSse(req, res);
28
+ const unsubscribe = ctx.runs.subscribeAll((sessionId, event) => {
29
+ if (!GLOBAL_EVENTS.has(event.type)) return;
30
+ sse.event(event.type, event.data);
31
+ });
32
+ sse.event('snapshot', { active: ctx.runs.activeSessions(), permissions: ctx.approvals.list() });
33
+ req.on('close', unsubscribe);
34
+ });
35
+ }
@@ -3,28 +3,20 @@ import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { readJson, sendJson, sendError } from '../lib/http.js';
5
5
  import { staticServerChip } from '../lib/core.js';
6
- import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
6
+ import { isInside, localOk, requireLocal, resolveDirOr400 } from '../lib/workspace.js';
7
7
 
8
8
  const MAX_PREVIEW = 200 * 1024;
9
9
  const IGNORED = new Set(['node_modules', '.git', '.tamnora', '.playwright-mcp', 'dist', '.cache']);
10
10
 
11
- function isInside(root, target) {
12
- const rel = path.relative(root, target);
13
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
14
- }
15
-
16
11
  /** Resolve `?root` + `?path` into a confined filesystem target, or send an error. */
17
- function resolveTarget(req, res, query, fallback) {
18
- if (!isLoopback(req.socket.localAddress)) {
19
- sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
20
- return null;
21
- }
12
+ function resolveTarget(req, res, query, fallback, { allowAnyRoot }) {
22
13
  let root;
23
- try {
24
- root = resolveExistingDir(query.get('root') || fallback);
25
- } catch (error) {
26
- sendError(res, 400, error.message);
27
- return null;
14
+ if (allowAnyRoot) {
15
+ root = resolveDirOr400(res, query.get('root'), fallback);
16
+ if (!root) return null;
17
+ } else {
18
+ // Sin conexion local de verdad: root fijo al workspace, se ignora ?root.
19
+ root = fallback;
28
20
  }
29
21
  const rel = String(query.get('path') ?? '').replace(/^[/\\]+/, '');
30
22
  const target = path.resolve(root, rel);
@@ -39,7 +31,11 @@ const toPosix = (value) => value.split(path.sep).join('/');
39
31
 
40
32
  export function registerFiles(route, ctx) {
41
33
  route('GET', '/api/files', ({ req, res, query }) => {
42
- const resolved = resolveTarget(req, res, query, ctx.cwd);
34
+ const local = localOk(req);
35
+ if (!local && !ctx.authEnabled) {
36
+ return sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
37
+ }
38
+ const resolved = resolveTarget(req, res, query, ctx.cwd, { allowAnyRoot: local });
43
39
  if (!resolved) return;
44
40
 
45
41
  let stat;
@@ -87,7 +83,11 @@ export function registerFiles(route, ctx) {
87
83
  });
88
84
 
89
85
  route('GET', '/api/files/content', async ({ req, res, query }) => {
90
- const resolved = resolveTarget(req, res, query, ctx.cwd);
86
+ const local = localOk(req);
87
+ if (!local && !ctx.authEnabled) {
88
+ return sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
89
+ }
90
+ const resolved = resolveTarget(req, res, query, ctx.cwd, { allowAnyRoot: local });
91
91
  if (!resolved) return;
92
92
 
93
93
  let stat;
@@ -117,19 +117,15 @@ export function registerFiles(route, ctx) {
117
117
  });
118
118
 
119
119
  route('POST', '/api/files/preview', async ({ req, res }) => {
120
- if (!isLoopback(req.socket.localAddress)) {
121
- return sendError(res, 403, 'La vista previa solo está disponible en conexiones locales.');
120
+ if (!requireLocal(req, res, 'La vista previa solo está disponible en conexiones locales.')) {
121
+ return;
122
122
  }
123
123
  if (!ctx.procsEnabled) {
124
124
  return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
125
125
  }
126
126
  const body = await readJson(req);
127
- let root;
128
- try {
129
- root = resolveExistingDir(body.root || ctx.cwd);
130
- } catch (error) {
131
- return sendError(res, 400, error.message);
132
- }
127
+ const root = resolveDirOr400(res, body.root, ctx.cwd);
128
+ if (!root) return;
133
129
  const rel = String(body.path ?? '').replace(/^[/\\]+/, '');
134
130
  const target = path.resolve(root, rel);
135
131
  if (!isInside(root, target)) return sendError(res, 400, 'La ruta queda fuera del workspace.');