@danieltmn/tamnora 0.16.1 → 0.18.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 (64) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +58 -4
  3. package/SECURITY.md +8 -0
  4. package/harness/packages/cli/src/commands/mcp.js +31 -4
  5. package/harness/packages/cli/src/commands/repl.js +7 -2
  6. package/harness/packages/cli/src/commands/run.js +5 -2
  7. package/harness/packages/cli/src/commands/tui.js +9 -2
  8. package/harness/packages/cli/src/lib/runtime.js +23 -1
  9. package/harness/packages/cli/test/tunnel.test.js +13 -1
  10. package/harness/packages/core/src/agent/modes.js +3 -0
  11. package/harness/packages/core/src/config/index.js +3 -1
  12. package/harness/packages/core/src/index.js +17 -3
  13. package/harness/packages/core/src/mcp/catalog.js +79 -0
  14. package/harness/packages/core/src/mcp/index.js +14 -2
  15. package/harness/packages/core/src/mcp/registry.js +29 -1
  16. package/harness/packages/core/src/procs/detect.js +59 -8
  17. package/harness/packages/core/src/procs/kill.js +26 -5
  18. package/harness/packages/core/src/redact.js +25 -0
  19. package/harness/packages/core/src/skills/catalog.js +78 -0
  20. package/harness/packages/core/src/skills/remote.js +51 -0
  21. package/harness/packages/core/src/tools/executors.js +93 -2
  22. package/harness/packages/core/src/tools/index.js +27 -6
  23. package/harness/packages/core/src/tools/labels.js +2 -0
  24. package/harness/packages/core/src/tools/mcp.js +91 -0
  25. package/harness/packages/core/src/tools/permissions.js +53 -8
  26. package/harness/packages/core/src/tools/registry.js +14 -7
  27. package/harness/packages/core/src/tools/schema.js +86 -0
  28. package/harness/packages/core/src/tts/edge.js +28 -7
  29. package/harness/packages/core/src/tunnel.js +51 -5
  30. package/harness/packages/core/test/compact.test.js +2 -0
  31. package/harness/packages/core/test/labels.test.js +1 -0
  32. package/harness/packages/core/test/mcp-registry.test.js +37 -1
  33. package/harness/packages/core/test/mcp-routing.test.js +115 -0
  34. package/harness/packages/core/test/procs.test.js +70 -2
  35. package/harness/packages/core/test/redact.test.js +22 -0
  36. package/harness/packages/core/test/skills.test.js +11 -0
  37. package/harness/packages/core/test/tools.test.js +97 -3
  38. package/harness/packages/server/public/app.js +629 -28
  39. package/harness/packages/server/public/index.html +44 -2
  40. package/harness/packages/server/public/styles.css +106 -7
  41. package/harness/packages/server/src/lib/agentTools.js +45 -3
  42. package/harness/packages/server/src/lib/channels/telegram.js +2 -0
  43. package/harness/packages/server/src/lib/procs.js +9 -3
  44. package/harness/packages/server/src/lib/prompt.js +4 -1
  45. package/harness/packages/server/src/lib/questions.js +72 -0
  46. package/harness/packages/server/src/lib/runManager.js +29 -2
  47. package/harness/packages/server/src/lib/scheduler.js +12 -1
  48. package/harness/packages/server/src/lib/tasks.js +3 -1
  49. package/harness/packages/server/src/routes/chat.js +40 -3
  50. package/harness/packages/server/src/routes/mcp.js +54 -2
  51. package/harness/packages/server/src/routes/preview.js +49 -28
  52. package/harness/packages/server/src/routes/procs.js +20 -3
  53. package/harness/packages/server/src/routes/providers.js +12 -0
  54. package/harness/packages/server/src/routes/sessions.js +3 -1
  55. package/harness/packages/server/src/routes/skills.js +48 -1
  56. package/harness/packages/server/src/routes/state.js +8 -1
  57. package/harness/packages/server/src/server.js +37 -1
  58. package/harness/packages/server/test/preview.test.js +111 -0
  59. package/harness/packages/server/test/server.test.js +285 -34
  60. package/harness/packages/server/test/telegram-features.test.js +2 -0
  61. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  62. package/harness/packages/server/test/telegram.test.js +2 -0
  63. package/harness/tamnora.example.json +3 -1
  64. package/package.json +1 -1
@@ -262,7 +262,73 @@ test('keeps a run alive after the client disconnects and can stop it', async ()
262
262
  }
263
263
  });
264
264
 
265
- /** Lee un SSE hasta que `until(data)` se cumpla, y devuelve lo acumulado. */
265
+ test('interrupt cancels the running turn and starts the new message right away', async () => {
266
+ const calls = { n: 0 };
267
+ let abortedFirst = false;
268
+ const provider = {
269
+ id: 'interrupt',
270
+ async streamChat(_config, { onEvent, signal }) {
271
+ calls.n += 1;
272
+ if (calls.n === 1) {
273
+ onEvent?.({ type: 'text', delta: 'trabajando', full: 'trabajando' });
274
+ await new Promise((resolve, reject) => {
275
+ const timer = setTimeout(resolve, 5000);
276
+ const onAbort = () => {
277
+ clearTimeout(timer);
278
+ abortedFirst = true;
279
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
280
+ };
281
+ if (signal?.aborted) return onAbort();
282
+ signal?.addEventListener('abort', onAbort, { once: true });
283
+ });
284
+ }
285
+ onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
286
+ return { content: 'listo', reasoning: '', toolCalls: [] };
287
+ },
288
+ };
289
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
290
+ const json = { 'Content-Type': 'application/json' };
291
+ try {
292
+ const created = await (
293
+ await fetch(`${base}/api/sessions`, {
294
+ method: 'POST',
295
+ headers: json,
296
+ body: JSON.stringify({ model: 'm' }),
297
+ })
298
+ ).json();
299
+ const id = created.session.id;
300
+
301
+ const first = fetch(`${base}/api/chat`, {
302
+ method: 'POST',
303
+ headers: json,
304
+ body: JSON.stringify({ input: 'uno', model: 'm', sessionId: id }),
305
+ })
306
+ .then((res) => res.text())
307
+ .catch(() => null);
308
+ await new Promise((resolve) => setTimeout(resolve, 80));
309
+
310
+ const second = await (
311
+ await fetch(`${base}/api/chat`, {
312
+ method: 'POST',
313
+ headers: json,
314
+ body: JSON.stringify({ input: 'dos', model: 'm', sessionId: id, interrupt: true }),
315
+ })
316
+ ).text();
317
+
318
+ assert.match(second, /event: text/);
319
+ assert.match(second, /listo/);
320
+ assert.equal(abortedFirst, true);
321
+ await first;
322
+
323
+ await new Promise((resolve) => setTimeout(resolve, 60));
324
+ const after = await (await fetch(`${base}/api/sessions/${id}`)).json();
325
+ assert.equal(after.running, false);
326
+ } finally {
327
+ await instance.close();
328
+ }
329
+ });
330
+
331
+ /** Lee un SSE hasta que `until(data)` se cumpla, y devuelve lo acumulado. */
266
332
  async function readSse(url, options, until, timeoutMs = 5000) {
267
333
  const controller = new AbortController();
268
334
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -877,13 +943,74 @@ test('manages MCP servers from the console', async () => {
877
943
  });
878
944
  assert.equal(bad.status, 400);
879
945
 
880
- res = await fetch(`${base}/api/mcp/servers/echo`, { method: 'DELETE' });
881
- assert.equal(res.status, 200);
882
- data = await res.json();
883
- assert.equal(data.servers.length, 0);
884
- } finally {
885
- await instance.close();
886
- }
946
+ res = await fetch(`${base}/api/mcp/servers/echo`, { method: 'DELETE' });
947
+ assert.equal(res.status, 200);
948
+ data = await res.json();
949
+ assert.equal(data.servers.length, 0);
950
+ } finally {
951
+ await instance.close();
952
+ }
953
+ });
954
+
955
+ test('sirve el catálogo de MCP/skills y los instala deshabilitados', async () => {
956
+ const { instance, base } = await startServer();
957
+ const json = { 'Content-Type': 'application/json' };
958
+ try {
959
+ const catalog = await (await fetch(`${base}/api/mcp/catalog`)).json();
960
+ assert.ok(catalog.catalog.some((e) => e.id === 'playwright' && e.installed === false));
961
+
962
+ const add = await fetch(`${base}/api/mcp/import`, {
963
+ method: 'POST',
964
+ headers: json,
965
+ body: JSON.stringify({ catalog: 'playwright' }),
966
+ });
967
+ assert.equal(add.status, 200);
968
+ const added = await add.json();
969
+ assert.equal(added.imported, 'playwright');
970
+ assert.equal(added.servers.find((s) => s.id === 'playwright').enabled, false);
971
+
972
+ const again = await (await fetch(`${base}/api/mcp/catalog`)).json();
973
+ assert.equal(again.catalog.find((e) => e.id === 'playwright').installed, true);
974
+
975
+ const skills = await (await fetch(`${base}/api/skills/catalog`)).json();
976
+ assert.ok(skills.catalog.some((e) => e.id === 'code-review' && e.installed === false));
977
+ const skillAdd = await fetch(`${base}/api/skills`, {
978
+ method: 'POST',
979
+ headers: json,
980
+ body: JSON.stringify({ id: 'code-review' }),
981
+ });
982
+ assert.equal(skillAdd.status, 200);
983
+ const skillData = await skillAdd.json();
984
+ assert.equal(skillData.installed, 'code-review');
985
+ assert.equal(skillData.skills.find((s) => s.id === 'code-review').enabled, false);
986
+
987
+ const unknown = await fetch(`${base}/api/mcp/import`, {
988
+ method: 'POST',
989
+ headers: json,
990
+ body: JSON.stringify({ catalog: 'nope' }),
991
+ });
992
+ assert.equal(unknown.status, 404);
993
+ const unknownSkill = await fetch(`${base}/api/skills`, {
994
+ method: 'POST',
995
+ headers: json,
996
+ body: JSON.stringify({ id: 'nope' }),
997
+ });
998
+ assert.equal(unknownSkill.status, 404);
999
+ } finally {
1000
+ await instance.close();
1001
+ }
1002
+ });
1003
+
1004
+ test('los buscadores devuelven vacío sin query', async () => {
1005
+ const { instance, base } = await startServer();
1006
+ try {
1007
+ const mcp = await (await fetch(`${base}/api/mcp/search`)).json();
1008
+ assert.deepEqual(mcp.results, []);
1009
+ const skills = await (await fetch(`${base}/api/skills/search`)).json();
1010
+ assert.deepEqual(skills.results, []);
1011
+ } finally {
1012
+ await instance.close();
1013
+ }
887
1014
  });
888
1015
 
889
1016
  test('MCP: el env se enmascara y no se pisa al guardar sin cambios', async () => {
@@ -949,6 +1076,36 @@ test('guarda los límites del agente desde Ajustes', async () => {
949
1076
  }
950
1077
  });
951
1078
 
1079
+ test('guarda el modo de carga y las tools fijas de MCP', async () => {
1080
+ const { instance, base } = await startServer();
1081
+ const json = { 'Content-Type': 'application/json' };
1082
+ try {
1083
+ const res = await fetch(`${base}/api/settings`, {
1084
+ method: 'PUT',
1085
+ headers: json,
1086
+ body: JSON.stringify({ mcpMode: 'lazy', mcpPinned: 'mcp__postgres__query, postgres' }),
1087
+ });
1088
+ const data = await res.json();
1089
+ assert.equal(data.settings.mcpMode, 'lazy');
1090
+ assert.deepEqual(data.settings.mcpPinned, ['mcp__postgres__query', 'postgres']);
1091
+
1092
+ const mcp = await (await fetch(`${base}/api/mcp`)).json();
1093
+ assert.equal(mcp.mode, 'lazy');
1094
+ assert.deepEqual(mcp.pinned, ['mcp__postgres__query', 'postgres']);
1095
+ assert.equal(mcp.threshold, 12);
1096
+ assert.equal(mcp.toolCount, null);
1097
+
1098
+ const bad = await fetch(`${base}/api/settings`, {
1099
+ method: 'PUT',
1100
+ headers: json,
1101
+ body: JSON.stringify({ mcpMode: 'nope' }),
1102
+ });
1103
+ assert.equal((await bad.json()).settings.mcpMode, 'auto');
1104
+ } finally {
1105
+ await instance.close();
1106
+ }
1107
+ });
1108
+
952
1109
  test('imports an MCP server from a JSON snippet', async () => {
953
1110
  const { instance, base } = await startServer();
954
1111
  const json = { 'Content-Type': 'application/json' };
@@ -1561,20 +1718,38 @@ test('runs a console shell command and kills a port', async () => {
1561
1718
  });
1562
1719
  assert.equal(missing.status, 400);
1563
1720
 
1564
- const badPort = await fetch(`${base}/api/procs/kill`, {
1565
- method: 'POST',
1566
- headers: json,
1567
- body: JSON.stringify({ port: 0 }),
1568
- });
1569
- assert.equal(badPort.status, 400);
1570
-
1571
- const none = await fetch(`${base}/api/procs/kill`, {
1572
- method: 'POST',
1573
- headers: json,
1574
- body: JSON.stringify({ port: 47555 }),
1575
- });
1576
- assert.equal(none.status, 200);
1577
- assert.deepEqual((await none.json()).pids, []);
1721
+ const badPort = await fetch(`${base}/api/procs/kill`, {
1722
+ method: 'POST',
1723
+ headers: json,
1724
+ body: JSON.stringify({ port: 0 }),
1725
+ });
1726
+ assert.equal(badPort.status, 400);
1727
+
1728
+ const none = await fetch(`${base}/api/procs/kill`, {
1729
+ method: 'POST',
1730
+ headers: json,
1731
+ body: JSON.stringify({ port: 47555 }),
1732
+ });
1733
+ assert.equal(none.status, 200);
1734
+ assert.deepEqual((await none.json()).pids, []);
1735
+
1736
+ const status = await fetch(`${base}/api/ports/47555`);
1737
+ assert.equal(status.status, 200);
1738
+ const info = await status.json();
1739
+ assert.equal(info.port, 47555);
1740
+ assert.equal(info.listening, false);
1741
+ assert.deepEqual(info.pids, []);
1742
+
1743
+ const aliasKill = await fetch(`${base}/api/ports/kill`, {
1744
+ method: 'POST',
1745
+ headers: json,
1746
+ body: JSON.stringify({ port: 47555 }),
1747
+ });
1748
+ assert.equal(aliasKill.status, 200);
1749
+ assert.deepEqual((await aliasKill.json()).pids, []);
1750
+
1751
+ const badStatus = await fetch(`${base}/api/ports/0`);
1752
+ assert.equal(badStatus.status, 400);
1578
1753
  } finally {
1579
1754
  await instance.close();
1580
1755
  }
@@ -1982,18 +2157,89 @@ test('asks for approval before running a gated tool and runs it once approved',
1982
2157
  }
1983
2158
  });
1984
2159
 
1985
- test('rejects unknown permission requests', async () => {
1986
- const { instance, base } = await startServer();
1987
- try {
1988
- const res = await fetch(`${base}/api/permissions/does-not-exist`, {
1989
- method: 'POST',
1990
- headers: { 'Content-Type': 'application/json' },
1991
- body: JSON.stringify({ allow: true }),
1992
- });
1993
- assert.equal(res.status, 404);
1994
- } finally {
1995
- await instance.close();
1996
- }
2160
+ test('rejects unknown permission requests', async () => {
2161
+ const { instance, base } = await startServer();
2162
+ try {
2163
+ const res = await fetch(`${base}/api/permissions/does-not-exist`, {
2164
+ method: 'POST',
2165
+ headers: { 'Content-Type': 'application/json' },
2166
+ body: JSON.stringify({ allow: true }),
2167
+ });
2168
+ assert.equal(res.status, 404);
2169
+ } finally {
2170
+ await instance.close();
2171
+ }
2172
+ });
2173
+
2174
+ test('ask_user muestra un formulario y recibe las respuestas', async () => {
2175
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-askuser-'));
2176
+ let calls = 0;
2177
+ let captured = null;
2178
+ const provider = {
2179
+ id: 'formy',
2180
+ async streamChat(_config, opts) {
2181
+ calls += 1;
2182
+ if (calls === 1) {
2183
+ return {
2184
+ content: '',
2185
+ reasoning: '',
2186
+ toolCalls: [
2187
+ {
2188
+ id: 'q1',
2189
+ function: {
2190
+ name: 'ask_user',
2191
+ arguments: JSON.stringify({
2192
+ questions: [
2193
+ { id: 'nombre', label: '¿Tu nombre?' },
2194
+ { id: 'color', label: 'Color', type: 'select', options: ['rojo', 'verde'] },
2195
+ ],
2196
+ }),
2197
+ },
2198
+ },
2199
+ ],
2200
+ };
2201
+ }
2202
+ captured = opts.messages;
2203
+ return { content: 'Gracias', reasoning: '', toolCalls: [] };
2204
+ },
2205
+ };
2206
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2207
+ try {
2208
+ const res = await fetch(`${base}/api/chat`, {
2209
+ method: 'POST',
2210
+ headers: { 'Content-Type': 'application/json' },
2211
+ body: JSON.stringify({ input: 'preguntame', model: 'm' }),
2212
+ });
2213
+ const reader = res.body.getReader();
2214
+ const decoder = new TextDecoder();
2215
+ let buffer = '';
2216
+ let askId = null;
2217
+ let done = false;
2218
+ while (!done) {
2219
+ const { value } = await reader.read();
2220
+ if (!value) break;
2221
+ buffer += decoder.decode(value, { stream: true });
2222
+ if (!askId && buffer.includes('event: question')) {
2223
+ const match = buffer.match(/"askId":"([0-9a-f-]+)"/);
2224
+ if (match) {
2225
+ askId = match[1];
2226
+ const answer = await fetch(`${base}/api/questions/${askId}`, {
2227
+ method: 'POST',
2228
+ headers: { 'Content-Type': 'application/json' },
2229
+ body: JSON.stringify({ answers: { nombre: 'Ana', color: 'verde' } }),
2230
+ });
2231
+ assert.equal(answer.status, 200);
2232
+ }
2233
+ }
2234
+ if (buffer.includes('event: done')) done = true;
2235
+ }
2236
+ assert.ok(askId, 'emitió el evento question');
2237
+ const toolMsg = (captured ?? []).find((m) => m.role === 'tool');
2238
+ assert.match(String(toolMsg?.content ?? ''), /Ana/);
2239
+ assert.match(String(toolMsg?.content ?? ''), /verde/);
2240
+ } finally {
2241
+ await instance.close();
2242
+ }
1997
2243
  });
1998
2244
 
1999
2245
  async function readEvents(res, onFrame) {
@@ -2237,6 +2483,7 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
2237
2483
  })
2238
2484
  ).text();
2239
2485
  assert.deepEqual(captured.tools, [
2486
+ 'ask_user',
2240
2487
  'console_errors',
2241
2488
  'fetch_url',
2242
2489
  'git_diff',
@@ -2247,9 +2494,13 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
2247
2494
  'list',
2248
2495
  'list_mcp',
2249
2496
  'list_skills',
2497
+ 'offer_choices',
2250
2498
  'preview_url',
2251
2499
  'read',
2252
2500
  'screenshot',
2501
+ 'search_mcp',
2502
+ 'search_skills',
2503
+ 'show_plan',
2253
2504
  'skill',
2254
2505
  'speak',
2255
2506
  'task',
@@ -79,6 +79,8 @@ function makeCtx(cwd, { app = {}, presence } = {}) {
79
79
  effectiveProviderId: () => 'fake',
80
80
  effectiveModel: () => 'm',
81
81
  effectiveMode: () => 'agent',
82
+ effectiveMcpMode: () => 'auto',
83
+ effectiveMcpPinned: () => [],
82
84
  providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
83
85
  apiKeyFor: () => '',
84
86
  customProviderDefs: () => [],
@@ -81,6 +81,8 @@ function makeCtx(cwd, app = {}) {
81
81
  effectiveProviderId: () => 'fake',
82
82
  effectiveModel: () => 'm',
83
83
  effectiveMode: () => 'agent',
84
+ effectiveMcpMode: () => 'auto',
85
+ effectiveMcpPinned: () => [],
84
86
  providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
85
87
  apiKeyFor: () => '',
86
88
  customProviderDefs: () => [],
@@ -48,6 +48,8 @@ function makeCtx(cwd) {
48
48
  effectiveProviderId: () => 'fake',
49
49
  effectiveModel: () => 'm',
50
50
  effectiveMode: () => 'agent',
51
+ effectiveMcpMode: () => 'auto',
52
+ effectiveMcpPinned: () => [],
51
53
  providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
52
54
  apiKeyFor: () => '',
53
55
  customProviderDefs: () => [],
@@ -15,6 +15,8 @@
15
15
  "plugins": [],
16
16
  "tools": [],
17
17
  "permissions": {},
18
+ "mcpMode": "auto",
19
+ "mcpPinned": [],
18
20
  "mcp": [
19
21
  {
20
22
  "id": "echo",
@@ -51,7 +53,7 @@
51
53
  "enabled": true,
52
54
  "allow": ["npm", "node", "npx", "pnpm", "yarn", "bun", "php", "python", "composer"]
53
55
  },
54
- "preview": { "provider": "tunnelmole" },
56
+ "preview": { "provider": "tailscale", "tailscalePort": 8443, "fallback": true },
55
57
  "telegram": { "token": "", "allowedChatIds": [], "transcribeModel": "whisper-1" },
56
58
  "agents": { "maxRounds": 6 }
57
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",