@danieltmn/tamnora 0.8.0 → 0.9.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +89 -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 +4665 -3975
  21. package/harness/packages/server/public/index.html +62 -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
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { mkdir } from 'node:fs/promises';
3
3
  import { readJson, sendJson, sendError } from '../lib/http.js';
4
- import { isLoopback, rootDirs, listSubdirs, resolveExistingDir } from '../lib/workspace.js';
4
+ import { requireLocal, rootDirs, listSubdirs, resolveDirOr400 } from '../lib/workspace.js';
5
5
 
6
6
  const INVALID_NAME = /[\\/:*?"<>|]/;
7
7
 
@@ -12,8 +12,8 @@ function validFolderName(name) {
12
12
 
13
13
  export function registerFolders(route) {
14
14
  route('GET', '/api/folders', ({ req, res, query }) => {
15
- if (!isLoopback(req.socket.localAddress)) {
16
- return sendError(res, 403, 'El navegador de carpetas solo está disponible en conexiones locales.');
15
+ if (!requireLocal(req, res, 'El navegador de carpetas solo está disponible en conexiones locales.')) {
16
+ return;
17
17
  }
18
18
 
19
19
  const raw = String(query.get('path') ?? '').trim();
@@ -26,12 +26,8 @@ export function registerFolders(route) {
26
26
  });
27
27
  }
28
28
 
29
- let dir;
30
- try {
31
- dir = resolveExistingDir(raw);
32
- } catch (error) {
33
- return sendError(res, 400, error.message);
34
- }
29
+ const dir = resolveDirOr400(res, raw);
30
+ if (!dir) return;
35
31
 
36
32
  let dirs;
37
33
  try {
@@ -49,8 +45,8 @@ export function registerFolders(route) {
49
45
  });
50
46
 
51
47
  route('POST', '/api/folders', async ({ req, res }) => {
52
- if (!isLoopback(req.socket.localAddress)) {
53
- return sendError(res, 403, 'Crear carpetas solo está disponible en conexiones locales.');
48
+ if (!requireLocal(req, res, 'Crear carpetas solo está disponible en conexiones locales.')) {
49
+ return;
54
50
  }
55
51
  const body = await readJson(req);
56
52
  const name = String(body.name ?? '').trim();
@@ -61,12 +57,8 @@ export function registerFolders(route) {
61
57
  if (!String(body.parent ?? '').trim()) {
62
58
  return sendError(res, 400, 'Elegí la carpeta contenedora primero.');
63
59
  }
64
- let parent;
65
- try {
66
- parent = resolveExistingDir(body.parent);
67
- } catch (error) {
68
- return sendError(res, 400, error.message);
69
- }
60
+ const parent = resolveDirOr400(res, body.parent);
61
+ if (!parent) return;
70
62
 
71
63
  const target = path.join(parent, name);
72
64
  try {
@@ -1,25 +1,17 @@
1
1
  import path from 'node:path';
2
2
  import { gitStatus, gitDiff } from '../lib/core.js';
3
3
  import { sendJson, sendError } from '../lib/http.js';
4
- import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
5
-
6
- function isInside(root, target) {
7
- const rel = path.relative(root, target);
8
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
9
- }
4
+ import { isInside, localOk, requireLocal, resolveDirOr400 } from '../lib/workspace.js';
10
5
 
11
6
  export function registerGit(route, ctx) {
12
7
  const resolveRoot = (req, res, raw) => {
13
- if (!isLoopback(req.socket.localAddress)) {
14
- sendError(res, 403, 'El estado de git solo está disponible en conexiones locales.');
15
- return null;
16
- }
17
- try {
18
- return resolveExistingDir(raw || ctx.cwd);
19
- } catch (error) {
20
- sendError(res, 400, error.message);
8
+ if (!ctx.authEnabled && !requireLocal(req, res, 'El estado de git solo está disponible en conexiones locales.')) {
21
9
  return null;
22
10
  }
11
+ // Con auth (sesion ya validada) se permite por el tunel, pero el cwd
12
+ // pedido queda confinado al workspace salvo conexion local de verdad.
13
+ const root = resolveDirOr400(res, localOk(req) ? raw : null, ctx.cwd);
14
+ return root;
23
15
  };
24
16
 
25
17
  route('GET', '/api/git/status', async ({ req, res, query }) => {
@@ -1,6 +1,6 @@
1
1
  import { detectProcs, killPort } from '../lib/core.js';
2
2
  import { readJson, sendJson, sendError } from '../lib/http.js';
3
- import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
3
+ import { requireLocal, resolveDirOr400 } from '../lib/workspace.js';
4
4
 
5
5
  export function registerProcs(route, ctx) {
6
6
  route('GET', '/api/procs', ({ res }) => {
@@ -8,24 +8,19 @@ export function registerProcs(route, ctx) {
8
8
  });
9
9
 
10
10
  route('GET', '/api/procs/detect', ({ res, query }) => {
11
- let dir;
12
- try {
13
- dir = resolveExistingDir(query.get('cwd') || ctx.cwd);
14
- } catch (error) {
15
- return sendError(res, 400, error.message);
16
- }
11
+ const dir = resolveDirOr400(res, query.get('cwd'), ctx.cwd);
12
+ if (!dir) return;
17
13
  sendJson(res, 200, { cwd: dir, chips: detectProcs(dir) });
18
14
  });
19
15
 
20
16
  route('POST', '/api/procs/start', async ({ req, res }) => {
17
+ if (!ctx.authEnabled && !requireLocal(req, res, 'Iniciar procesos solo está disponible en conexiones locales.')) {
18
+ return;
19
+ }
21
20
  if (!ctx.procsEnabled) return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
22
21
  const body = await readJson(req);
23
- let dir;
24
- try {
25
- dir = resolveExistingDir(body.cwd || ctx.cwd);
26
- } catch (error) {
27
- return sendError(res, 400, error.message);
28
- }
22
+ const dir = resolveDirOr400(res, body.cwd, ctx.cwd);
23
+ if (!dir) return;
29
24
  try {
30
25
  const proc = ctx.procs.start({ cwd: dir, command: body.command, args: body.args, port: body.port });
31
26
  sendJson(res, 201, { proc });
@@ -40,14 +35,17 @@ export function registerProcs(route, ctx) {
40
35
  sendJson(res, 200, data);
41
36
  });
42
37
 
43
- route('POST', '/api/procs/:id/stop', ({ res, params }) => {
38
+ route('POST', '/api/procs/:id/stop', ({ req, res, params }) => {
39
+ if (!ctx.authEnabled && !requireLocal(req, res, 'Detener procesos solo está disponible en conexiones locales.')) {
40
+ return;
41
+ }
44
42
  if (!ctx.procs.stop(params.id)) return sendError(res, 404, 'Proceso no encontrado.');
45
43
  sendJson(res, 200, { ok: true });
46
44
  });
47
45
 
48
46
  route('POST', '/api/procs/kill', async ({ req, res }) => {
49
- if (!isLoopback(req.socket.localAddress)) {
50
- return sendError(res, 403, 'Matar procesos solo está disponible en conexiones locales.');
47
+ if (!requireLocal(req, res, 'Matar procesos solo está disponible en conexiones locales.')) {
48
+ return;
51
49
  }
52
50
  const body = await readJson(req);
53
51
  try {
@@ -1,27 +1,16 @@
1
1
  import { runAgent } from '../lib/core.js';
2
2
  import { readJson, sendJson, sendError } from '../lib/http.js';
3
3
  import { workspaceForRequest } from '../lib/workspace.js';
4
-
5
- function view(session) {
6
- return {
7
- id: session.id,
8
- title: session.title,
9
- provider: session.provider,
10
- model: session.model,
11
- cwd: session.cwd,
12
- goal: session.meta?.goal ?? null,
13
- hasSummary: Boolean(session.meta?.summary),
14
- createdAt: session.created_at,
15
- updatedAt: session.updated_at,
16
- };
17
- }
4
+ import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
18
5
 
19
6
  const COMPACT_INSTRUCTION =
20
7
  'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
21
8
 
22
9
  export function registerSessions(route, ctx) {
23
10
  route('GET', '/api/sessions', ({ res }) => {
24
- sendJson(res, 200, { sessions: ctx.store.listSessions(50).map(view) });
11
+ const awaiting = awaitingBySession(ctx.approvals.list());
12
+ const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
13
+ sendJson(res, 200, { sessions });
25
14
  });
26
15
 
27
16
  route('GET', '/api/search', ({ res, query }) => {
@@ -38,7 +27,7 @@ export function registerSessions(route, ctx) {
38
27
  return text.replace(/\s+/g, ' ').trim().slice(0, 200);
39
28
  };
40
29
  sendJson(res, 200, {
41
- sessions: ctx.store.searchSessions(q, limit).map(view),
30
+ sessions: ctx.store.searchSessions(q, limit).map(sessionView),
42
31
  messages: ctx.store.searchMessages(q, limit).map((m) => ({
43
32
  id: m.id,
44
33
  sessionId: m.sessionId,
@@ -66,17 +55,21 @@ export function registerSessions(route, ctx) {
66
55
  cwd,
67
56
  meta,
68
57
  });
69
- sendJson(res, 201, { session: view(ctx.store.getSession(id)) });
58
+ sendJson(res, 201, { session: sessionView(ctx.store.getSession(id)) });
70
59
  });
71
60
 
72
61
  route('GET', '/api/sessions/:id', ({ res, params }) => {
73
62
  const session = ctx.store.getSession(params.id);
74
63
  if (!session) return sendError(res, 404, 'Sesión no encontrada.');
75
64
  sendJson(res, 200, {
76
- session: view(session),
65
+ session: sessionView(session),
77
66
  tokens: ctx.store.sessionTokens(params.id),
78
67
  messages: ctx.store.getMessages(params.id),
79
68
  running: ctx.runs?.isActive(params.id) ?? false,
69
+ // Un permiso pendiente NO se reconstruye desde los mensajes (no son
70
+ // mensajes), asi que va aparte: sin esto, recargar la pagina dejaba el run
71
+ // esperando una decision que no se podia tomar.
72
+ permissions: ctx.approvals.list({ sessionId: params.id }),
80
73
  });
81
74
  });
82
75
 
@@ -104,7 +97,7 @@ export function registerSessions(route, ctx) {
104
97
  if ('title' in body) patch.title = String(body.title || '').slice(0, 80) || session.title;
105
98
  if ('goal' in body) patch.meta = { goal: body.goal ? String(body.goal) : undefined };
106
99
  ctx.store.touchSession(params.id, patch);
107
- sendJson(res, 200, { session: view(ctx.store.getSession(params.id)) });
100
+ sendJson(res, 200, { session: sessionView(ctx.store.getSession(params.id)) });
108
101
  });
109
102
 
110
103
  route('DELETE', '/api/sessions/:id/messages', ({ res, params }) => {
@@ -1,21 +1,17 @@
1
1
  import { createTools } from '../lib/core.js';
2
2
  import { readJson, sendJson, sendError } from '../lib/http.js';
3
- import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
3
+ import { requireLocal, resolveDirOr400 } from '../lib/workspace.js';
4
4
 
5
5
  export function registerShell(route, ctx) {
6
6
  route('POST', '/api/shell', async ({ req, res }) => {
7
- if (!isLoopback(req.socket.localAddress)) {
8
- return sendError(res, 403, 'El shell solo está disponible en conexiones locales.');
7
+ if (!requireLocal(req, res, 'El shell solo está disponible en conexiones locales.')) {
8
+ return;
9
9
  }
10
10
  const body = await readJson(req);
11
11
  const command = String(body.command ?? '').trim();
12
12
  if (!command) return sendError(res, 400, 'Falta "command".');
13
- let root;
14
- try {
15
- root = resolveExistingDir(body.cwd || ctx.cwd);
16
- } catch (error) {
17
- return sendError(res, 400, error.message);
18
- }
13
+ const root = resolveDirOr400(res, body.cwd, ctx.cwd);
14
+ if (!root) return;
19
15
  const tools = createTools({ root, enabled: ['bash'], yes: true });
20
16
  const output = await tools.execute('bash', { command });
21
17
  sendJson(res, 200, { command, cwd: root, output: String(output ?? '') });
@@ -0,0 +1,32 @@
1
+ import { sendJson } from '../lib/http.js';
2
+ import { sessionState, awaitingBySession } from '../lib/sessionView.js';
3
+
4
+ /**
5
+ * Snapshot de todo lo que el cliente necesita para reconciliar, en un solo
6
+ * request.
7
+ *
8
+ * Este endpoint es la **verdad**: los streams SSE son un atajo de latencia que
9
+ * se cae en cualquier enlace movil (y que iOS corta cuando manda la pestana a
10
+ * background). Por eso no lleva mensajes ni tool results — solo liveness, que es
11
+ * chico y se puede pedir seguido. El detalle de una sesion sigue bajo demanda
12
+ * en `/api/sessions/:id`.
13
+ */
14
+ export function registerState(route, ctx) {
15
+ route('GET', '/api/state', ({ res }) => {
16
+ const permissions = ctx.approvals.list();
17
+ const awaiting = awaitingBySession(permissions);
18
+ const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
19
+ sendJson(res, 200, {
20
+ sessions,
21
+ permissions: permissions.map(({ askId, sessionId, name, args, requestedAt }) => ({
22
+ askId,
23
+ sessionId,
24
+ name,
25
+ args,
26
+ requestedAt,
27
+ })),
28
+ // Para que el cliente pueda detectar que el server va en otro reloj.
29
+ now: Date.now(),
30
+ });
31
+ });
32
+ }
@@ -0,0 +1,82 @@
1
+ // Voz (TTS): sintesis bajo demanda de la consola + ajustes persistidos en
2
+ // app.json (la casa). La config no necesita recarga: cada consumidor la lee
3
+ // cuando va a hablar.
4
+
5
+ import { sintetizar, listarVoces, MAX_TEXTO } from '../lib/core.js';
6
+ import { readApp, writeApp } from '../lib/core.js';
7
+ import { ffmpegDisponible } from '../lib/ffmpeg.js';
8
+ import { resolveTts, sanearVoz, sanearRate } from '../lib/ttsSettings.js';
9
+ import { sendJson, sendError, readJson } from '../lib/http.js';
10
+
11
+ const cacheVoces = { at: 0, data: null, filtro: 'es-' };
12
+
13
+ async function catalogoVoces(filtro) {
14
+ const now = Date.now();
15
+ if (cacheVoces.data && cacheVoces.filtro === filtro && now - cacheVoces.at < 600000) {
16
+ return cacheVoces.data;
17
+ }
18
+ const voces = await listarVoces(filtro);
19
+ cacheVoces.at = now;
20
+ cacheVoces.data = voces;
21
+ cacheVoces.filtro = filtro;
22
+ return voces;
23
+ }
24
+
25
+ export function registerTts(route, ctx) {
26
+ route('GET', '/api/tts/settings', async ({ res }) => {
27
+ const config = resolveTts(ctx.app);
28
+ sendJson(res, 200, { ...config, ffmpeg: await ffmpegDisponible() });
29
+ });
30
+
31
+ route('PUT', '/api/tts/settings', async ({ req, res }) => {
32
+ const body = await readJson(req);
33
+ const app = readApp();
34
+ const actual = resolveTts(ctx.app);
35
+ const next = { ...actual };
36
+
37
+ if ('enabled' in body) next.enabled = body.enabled === true;
38
+ if ('voice' in body) next.voice = sanearVoz(body.voice, actual.voice);
39
+ if ('rate' in body) next.rate = sanearRate(body.rate, actual.rate);
40
+ if (body.announce && typeof body.announce === 'object') {
41
+ const announce = { ...actual.announce };
42
+ for (const key of ['start', 'tools', 'end']) {
43
+ if (key in body.announce) announce[key] = body.announce[key] === true;
44
+ }
45
+ next.announce = announce;
46
+ }
47
+
48
+ app.tts = next;
49
+ writeApp(app);
50
+ ctx.app = app;
51
+ sendJson(res, 200, { ...next, ffmpeg: await ffmpegDisponible() });
52
+ });
53
+
54
+ route('GET', '/api/tts/voices', async ({ res, query }) => {
55
+ const filtro = /^[a-zA-Z]{2}-?$/.test(String(query.get('filter') ?? 'es-')) ? query.get('filter') : 'es-';
56
+ try {
57
+ const voces = await catalogoVoces(filtro);
58
+ sendJson(res, 200, { voices: voces });
59
+ } catch (error) {
60
+ sendError(res, 502, `No pude listar las voces: ${error?.message ?? error}`);
61
+ }
62
+ });
63
+
64
+ route('GET', '/api/tts', async ({ res, query }) => {
65
+ const config = resolveTts(ctx.app);
66
+ const texto = String(query.get('texto') ?? '').trim().slice(0, MAX_TEXTO);
67
+ if (!texto) return sendError(res, 400, 'Falta "texto".');
68
+ const voz = sanearVoz(query.get('voice'), config.voice);
69
+ const rate = sanearRate(query.get('rate'), config.rate);
70
+ try {
71
+ const mp3 = await sintetizar(texto, { voice: voz, rate });
72
+ res.writeHead(200, {
73
+ 'Content-Type': 'audio/mpeg',
74
+ 'Content-Length': mp3.length,
75
+ 'Cache-Control': 'no-store',
76
+ });
77
+ res.end(mp3);
78
+ } catch (error) {
79
+ sendError(res, 502, `No pude sintetizar: ${error?.message ?? error}`);
80
+ }
81
+ });
82
+ }
@@ -34,10 +34,13 @@ import { registerProviders } from './routes/providers.js';
34
34
  import { registerMcp } from './routes/mcp.js';
35
35
  import { registerSkills } from './routes/skills.js';
36
36
  import { registerChannels } from './routes/channels.js';
37
+ import { registerTts } from './routes/tts.js';
37
38
  import { registerProcs } from './routes/procs.js';
38
39
  import { registerShell } from './routes/shell.js';
39
40
  import { registerPreview } from './routes/preview.js';
40
41
  import { registerChat } from './routes/chat.js';
42
+ import { registerEvents } from './routes/events.js';
43
+ import { registerState } from './routes/state.js';
41
44
  import { registerEval } from './routes/eval.js';
42
45
 
43
46
  const VERSION = readPackageVersion();
@@ -220,10 +223,13 @@ export async function createTamnoraServer({
220
223
  registerMcp(route, ctx);
221
224
  registerSkills(route, ctx);
222
225
  registerChannels(route, ctx);
226
+ registerTts(route, ctx);
223
227
  registerProcs(route, ctx);
224
228
  registerShell(route, ctx);
225
229
  const preview = registerPreview(route, ctx);
226
230
  registerChat(route, ctx);
231
+ registerEvents(route, ctx);
232
+ registerState(route, ctx);
227
233
  registerEval(route, ctx);
228
234
 
229
235
  function matchRoute(method, pathname) {
@@ -247,6 +253,10 @@ export async function createTamnoraServer({
247
253
 
248
254
  const serveStatic = createStaticHandler(staticDir ?? DEFAULT_PUBLIC_DIR);
249
255
 
256
+ // Los SSE (turno, stream de sesion, canal global) son conexiones que no
257
+ // terminan solas: sin registrarlas, `server.close()` espera indefinidamente y
258
+ // `tamnora server --stop` queda colgado.
259
+ const sockets = new Set();
250
260
  const server = http.createServer(async (req, res) => {
251
261
  const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
252
262
 
@@ -286,6 +296,11 @@ export async function createTamnoraServer({
286
296
  }
287
297
  });
288
298
 
299
+ server.on('connection', (socket) => {
300
+ sockets.add(socket);
301
+ socket.on('close', () => sockets.delete(socket));
302
+ });
303
+
289
304
  const listen = (options = {}) =>
290
305
  new Promise((resolve, reject) => {
291
306
  server.once('error', reject);
@@ -315,12 +330,17 @@ export async function createTamnoraServer({
315
330
  } catch {
316
331
  // ignore MCP shutdown errors
317
332
  }
333
+ // Cortar las conexiones abiertas ANTES de esperar al server, y la base
334
+ // DESPUES: un run abortado termina en otro tick y emite `aborted`.
335
+ for (const socket of sockets) socket.destroy();
336
+ sockets.clear();
337
+ await new Promise((resolve) => server.close(() => resolve()));
338
+ await new Promise((resolve) => setTimeout(resolve, 50));
318
339
  try {
319
340
  store.close();
320
341
  } catch {
321
342
  // already closed
322
343
  }
323
- await new Promise((resolve) => server.close(() => resolve()));
324
344
  };
325
345
 
326
346
  return { server, config, store, ctx, listen, close, staticDir: staticDir ?? DEFAULT_PUBLIC_DIR };
@@ -0,0 +1,51 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { fraseTool, fraseInicio, fraseFin, fraseError, createFiltroTools } from '../src/lib/announcer.js';
4
+
5
+ test('fraseTool arma verbos con el resumen de la tool', () => {
6
+ assert.equal(fraseTool('read', 'src/app.js'), 'Leyendo src/app.js');
7
+ assert.equal(fraseTool('write', 'a/b.txt'), 'Escribiendo a/b.txt');
8
+ assert.equal(fraseTool('bash', 'npm test'), 'Ejecutando npm test');
9
+ assert.equal(fraseTool('grep', '"foo" *.js'), 'Buscando "foo" *.js');
10
+ });
11
+
12
+ test('fraseTool sin resumen, tools mudas y MCP', () => {
13
+ assert.equal(fraseTool('read', ''), 'Leyendo');
14
+ assert.equal(fraseTool('desconocida', 'x'), '');
15
+ assert.equal(fraseTool('git_status', ''), 'Mirando el estado de git');
16
+ assert.equal(fraseTool('git_commit', 'feat: algo'), 'Commiteando: feat: algo');
17
+ assert.equal(fraseTool('mcp__db__query', 'x'), 'Usando una herramienta de db');
18
+ });
19
+
20
+ test('fraseTool recorta frases largas sin cortar palabras', () => {
21
+ const frase = fraseTool('bash', 'x'.repeat(400));
22
+ assert.ok(frase.length <= 200, frase.length);
23
+ assert.ok(frase.endsWith('…'));
24
+ });
25
+
26
+ test('fraseFin resume el cierre del turno', () => {
27
+ assert.equal(fraseFin({ stopReason: 'aborted' }), 'Cancelado.');
28
+ assert.match(fraseFin({ stopReason: 'end_turn', rounds: 3, toolCalls: 5, durationMs: 12000 }), /^Listo\. 3 rondas, 5 herramientas, 12 segundos\.$/);
29
+ assert.equal(fraseFin({ stopReason: 'end_turn', rounds: 1, toolCalls: 0, durationMs: 500 }), 'Listo. Respondí sin usar herramientas.');
30
+ assert.match(
31
+ fraseFin({ stopReason: 'max_rounds', rounds: 8, toolCalls: 12, durationMs: 60000 }),
32
+ /corte de seguridad tras 8 rondas, 60 segundos/
33
+ );
34
+ assert.match(fraseInicio(), /Empecé/);
35
+ assert.match(fraseError(), /error/);
36
+ });
37
+
38
+ test('createFiltroTools deduplica y aplica la ventana entre frases', () => {
39
+ const filtro = createFiltroTools({ minGapMs: 60000 });
40
+ assert.equal(filtro('read', 'a.js'), 'Leyendo a.js');
41
+ assert.equal(filtro('read', 'b.js'), '');
42
+ assert.equal(filtro('write', 'b.js'), '');
43
+ const ahora = Date.now();
44
+ const original = Date.now;
45
+ Date.now = () => ahora + 61000;
46
+ try {
47
+ assert.equal(filtro('write', 'b.js'), 'Escribiendo b.js');
48
+ } finally {
49
+ Date.now = original;
50
+ }
51
+ });
@@ -0,0 +1,146 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { diffState, pollDelayMs, localState, summarizeArgs, humanSize } from '../public/reconcile.js';
4
+
5
+ const pane = (over = {}) => ({ working: false, attached: false, ...over });
6
+ const ask = (over = {}) => ({ askId: 'a1', sessionId: 's1', name: 'bash', args: {}, requestedAt: 1, ...over });
7
+
8
+ test('engancha una sesion abierta que esta trabajando sin stream propio', () => {
9
+ const diff = diffState({ panes: [{ id: 's1', working: false, attached: false }], asks: [] }, {
10
+ sessions: [{ id: 's1', running: true, awaiting: 0 }],
11
+ permissions: [],
12
+ });
13
+ assert.deepEqual(diff.toAttach, ['s1']);
14
+ });
15
+
16
+ test('engancha aunque no haya run si hay un permiso pendiente', () => {
17
+ // El permiso no se reconstruye desde los mensajes: vive en /api/state.
18
+ const diff = diffState({ panes: [{ id: 's1', working: false, attached: false }], asks: [] }, {
19
+ sessions: [{ id: 's1', running: false, awaiting: 1 }],
20
+ permissions: [ask()],
21
+ });
22
+ assert.deepEqual(diff.toAttach, ['s1']);
23
+ });
24
+
25
+ test('desengancha una sesion que figuraba activa y ya no', () => {
26
+ const diff = diffState({ panes: [{ id: 's1', working: true, attached: true }], asks: [] }, {
27
+ sessions: [{ id: 's1', running: false, awaiting: 0 }],
28
+ permissions: [],
29
+ });
30
+ assert.deepEqual(diff.toDetach, ['s1']);
31
+ });
32
+
33
+ test('no toca una sesion que ya esta enganchada', () => {
34
+ const diff = diffState({ panes: [{ id: 's1', working: true, attached: true }], asks: [] }, {
35
+ sessions: [{ id: 's1', running: true, awaiting: 0 }],
36
+ permissions: [],
37
+ });
38
+ assert.deepEqual(diff.toAttach, []);
39
+ assert.deepEqual(diff.toDetach, []);
40
+ });
41
+
42
+ test('detecta permisos nuevos y resueltos', () => {
43
+ const diff = diffState({ panes: [], asks: ['a1', 'a2'] }, {
44
+ sessions: [],
45
+ permissions: [ask({ askId: 'a1' }), ask({ askId: 'a3' })],
46
+ });
47
+ assert.deepEqual(diff.newAsks.map((a) => a.askId), ['a3']);
48
+ assert.deepEqual(diff.resolvedAsks, ['a2']);
49
+ });
50
+
51
+ test('un permiso repetido no vuelve a notificarse', () => {
52
+ const state = { panes: [], asks: [] };
53
+ const remote = { sessions: [], permissions: [ask()] };
54
+ const first = diffState(state, remote);
55
+ assert.equal(first.newAsks.length, 1);
56
+ const second = diffState({ panes: [], asks: ['a1'] }, remote);
57
+ assert.equal(second.newAsks.length, 0);
58
+ });
59
+
60
+ test('noPropone ninguna accion cuando nada cambio', () => {
61
+ // Esto importa: un re-render por nada borra la tarjeta de permiso.
62
+ const local = { panes: [{ id: 's1', working: true, attached: true }], asks: ['a1'] };
63
+ const remote = { sessions: [{ id: 's1', running: true, awaiting: 1 }], permissions: [ask()] };
64
+ const diff = diffState(local, remote);
65
+ assert.deepEqual(diff.toAttach, []);
66
+ assert.deepEqual(diff.toDetach, []);
67
+ assert.deepEqual(diff.gone, []);
68
+ assert.deepEqual(diff.newAsks, []);
69
+ assert.deepEqual(diff.resolvedAsks, []);
70
+ });
71
+
72
+ test('avisa las sesiones que el server ya no conoce', () => {
73
+ const diff = diffState({ panes: [{ id: 'borrada', working: false, attached: false }], asks: [] }, {
74
+ sessions: [{ id: 's1', running: false, awaiting: 0 }],
75
+ permissions: [],
76
+ });
77
+ assert.deepEqual(diff.gone, ['borrada']);
78
+ });
79
+
80
+ test('ignora las sesiones que el usuario no tiene abiertas', () => {
81
+ const diff = diffState({ panes: [], asks: [] }, {
82
+ sessions: [{ id: 's1', running: true, awaiting: 0 }],
83
+ permissions: [],
84
+ });
85
+ assert.deepEqual(diff.toAttach, []);
86
+ assert.deepEqual(diff.remoteActive, ['s1']);
87
+ });
88
+
89
+ test('la cadencia se apura cuando algo espera tu decision', () => {
90
+ const idle = { sessions: [{ running: false, awaiting: 0 }], permissions: [] };
91
+ const running = { sessions: [{ running: true, awaiting: 0 }], permissions: [] };
92
+ const waiting = { sessions: [{ running: false, awaiting: 1 }], permissions: [ask()] };
93
+ assert.equal(pollDelayMs(idle), 25000);
94
+ assert.equal(pollDelayMs(running), 10000);
95
+ assert.equal(pollDelayMs(waiting), 5000);
96
+ });
97
+
98
+ test('localState aplana los Map del cliente', () => {
99
+ const flat = localState({ panes: new Map([['s1', pane({ working: true, attached: true })]]), asks: new Set(['a1']) });
100
+ assert.deepEqual(flat, { panes: [{ id: 's1', working: true, attached: true }], asks: ['a1'] });
101
+ });
102
+
103
+ test('tolera un remoto vacio o corrupto sin romper', () => {
104
+ const diff = diffState({ panes: [{ id: 's1', working: false, attached: false }], asks: [] }, {});
105
+ assert.deepEqual(diff.toAttach, []);
106
+ assert.deepEqual(diff.pending, []);
107
+ });
108
+
109
+ test('el resumen de un permiso muestra el comando, no el JSON', () => {
110
+ const command = 'powershell -NoProfile -Command "Get-ChildItem -Recurse"';
111
+ assert.equal(summarizeArgs('bash', { command }), command);
112
+ });
113
+
114
+ test('el resumen de write muestra ruta y tamano, no el archivo entero', () => {
115
+ // Esto era medio archivo en pantalla con los \n escapados a la vista.
116
+ const summary = summarizeArgs('write', { path: 'AGENTS.md', content: 'x'.repeat(1840) });
117
+ assert.equal(summary, 'AGENTS.md · 1.8 kB');
118
+ assert.ok(!summary.includes('\\n'));
119
+ assert.ok(summary.length < 60);
120
+ });
121
+
122
+ test('el resumen de read y fetch_url muestra la ruta y la url', () => {
123
+ assert.equal(summarizeArgs('read', { path: 'src/app.js' }), 'src/app.js');
124
+ assert.equal(summarizeArgs('fetch_url', { url: 'https://x.dev/a' }), 'https://x.dev/a');
125
+ });
126
+
127
+ test('el resumen de task usa la descripcion', () => {
128
+ assert.equal(summarizeArgs('task', { description: 'auditar el repo', prompt: 'busca TODOs' }), 'auditar el repo');
129
+ });
130
+
131
+ test('una tool desconocida cae a JSON en una sola linea', () => {
132
+ const summary = summarizeArgs('git_diff', { path: 'a.js', staged: false, note: 'uno\ndos' });
133
+ assert.ok(!summary.includes('\\n'), 'no debe mostrar saltos escapados');
134
+ assert.match(summary, /git_diff|a\.js/);
135
+ });
136
+
137
+ test('sin argumentos el resumen queda vacio', () => {
138
+ assert.equal(summarizeArgs('git_status', {}), '');
139
+ assert.equal(summarizeArgs('git_status', null), '');
140
+ });
141
+
142
+ test('humanSize usa la unidad que corresponde', () => {
143
+ assert.equal(humanSize('hola'), '4 B');
144
+ assert.equal(humanSize('x'.repeat(2048)), '2.0 kB');
145
+ assert.equal(humanSize('x'.repeat(3 * 1024 * 1024)), '3.0 MB');
146
+ });