@danieltmn/tamnora 0.25.0 → 0.26.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 (110) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +13 -25
  3. package/SECURITY.md +3 -4
  4. package/harness/packages/cli/src/cli.js +0 -3
  5. package/harness/packages/cli/src/commands/bridge.js +0 -1
  6. package/harness/packages/cli/src/commands/repl.js +20 -4
  7. package/harness/packages/cli/src/commands/run.js +6 -2
  8. package/harness/packages/cli/src/commands/server.js +1 -1
  9. package/harness/packages/cli/src/commands/tui.js +6 -2
  10. package/harness/packages/cli/src/lib/runtime.js +2 -0
  11. package/harness/packages/cli/src/lib/util.js +2 -2
  12. package/harness/packages/cli/src/tui/app.js +24 -4
  13. package/harness/packages/cli/src/tui/layout.js +1 -1
  14. package/harness/packages/cli/test/tunnel.test.js +5 -32
  15. package/harness/packages/core/src/agent/runAgent.js +5 -2
  16. package/harness/packages/core/src/agents/index.js +113 -0
  17. package/harness/packages/core/src/config/app.js +0 -4
  18. package/harness/packages/core/src/config/home.js +1 -20
  19. package/harness/packages/core/src/config/index.js +6 -8
  20. package/harness/packages/core/src/extensions/load.js +22 -0
  21. package/harness/packages/core/src/frontmatter.js +32 -0
  22. package/harness/packages/core/src/hooks/index.js +2 -16
  23. package/harness/packages/core/src/index.js +12 -14
  24. package/harness/packages/core/src/plugins/index.js +2 -16
  25. package/harness/packages/core/src/procs/project.js +68 -0
  26. package/harness/packages/core/src/procs/static.js +17 -0
  27. package/harness/packages/core/src/providers/index.js +3 -1
  28. package/harness/packages/core/src/providers/ollama.js +4 -1
  29. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  30. package/harness/packages/core/src/providers/openrouter.js +5 -1
  31. package/harness/packages/core/src/providers/presets.js +4 -3
  32. package/harness/packages/core/src/providers/reasoning.js +58 -0
  33. package/harness/packages/core/src/rules/index.js +8 -27
  34. package/harness/packages/core/src/skills/index.js +8 -19
  35. package/harness/packages/core/src/storage/sqlite.js +595 -690
  36. package/harness/packages/core/src/tunnel.js +9 -147
  37. package/harness/packages/core/test/agents.test.js +111 -0
  38. package/harness/packages/core/test/home.test.js +2 -35
  39. package/harness/packages/core/test/procs.test.js +3 -64
  40. package/harness/packages/core/test/reasoning.test.js +43 -0
  41. package/harness/packages/core/test/storage.test.js +1 -12
  42. package/harness/packages/server/public/app.js +39 -19
  43. package/harness/packages/server/public/appearance.js +2 -56
  44. package/harness/packages/server/public/boot.js +2 -2
  45. package/harness/packages/server/public/cards.js +2 -21
  46. package/harness/packages/server/public/chat.js +1 -16
  47. package/harness/packages/server/public/chatFormat.js +0 -11
  48. package/harness/packages/server/public/chrome.js +95 -5
  49. package/harness/packages/server/public/commands.js +1 -2
  50. package/harness/packages/server/public/core.js +1 -1
  51. package/harness/packages/server/public/explorer.js +7 -10
  52. package/harness/packages/server/public/icons (1).zip +0 -0
  53. package/harness/packages/server/public/index.html +1228 -1237
  54. package/harness/packages/server/public/landing/boot.js +2 -3
  55. package/harness/packages/server/public/landing/index.html +3 -367
  56. package/harness/packages/server/public/sessions.js +1 -0
  57. package/harness/packages/server/public/settings.js +1255 -1155
  58. package/harness/packages/server/public/settingsNav.js +1 -1
  59. package/harness/packages/server/public/styles.css +2341 -2867
  60. package/harness/packages/server/public/system.js +356 -495
  61. package/harness/packages/server/public/voice.js +6 -22
  62. package/harness/packages/server/src/auth.js +4 -15
  63. package/harness/packages/server/src/cli.js +1 -1
  64. package/harness/packages/server/src/lib/agents.js +60 -0
  65. package/harness/packages/server/src/lib/appearance.js +3 -22
  66. package/harness/packages/server/src/lib/channels/telegram/commands.js +1 -1
  67. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  68. package/harness/packages/server/src/lib/definitions.js +40 -0
  69. package/harness/packages/server/src/lib/prompt.js +11 -3
  70. package/harness/packages/server/src/lib/scheduler.js +4 -0
  71. package/harness/packages/server/src/lib/sessionView.js +1 -0
  72. package/harness/packages/server/src/lib/skills.js +27 -21
  73. package/harness/packages/server/src/lib/tasks.js +71 -23
  74. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  75. package/harness/packages/server/src/lib/workspace.js +9 -11
  76. package/harness/packages/server/src/routes/agents.js +88 -0
  77. package/harness/packages/server/src/routes/chat.js +9 -44
  78. package/harness/packages/server/src/routes/preview.js +5 -22
  79. package/harness/packages/server/src/routes/procs.js +2 -4
  80. package/harness/packages/server/src/routes/providers.js +7 -0
  81. package/harness/packages/server/src/routes/sessions.js +3 -1
  82. package/harness/packages/server/src/routes/tts.js +1 -1
  83. package/harness/packages/server/src/server.js +8 -5
  84. package/harness/packages/server/test/agents.test.js +194 -0
  85. package/harness/packages/server/test/appearance-route.test.js +3 -3
  86. package/harness/packages/server/test/appearance.test.js +12 -38
  87. package/harness/packages/server/test/chatFormat.test.js +0 -8
  88. package/harness/packages/server/test/consoleTabs.test.js +2 -2
  89. package/harness/packages/server/test/ops.test.js +8 -56
  90. package/harness/packages/server/test/preview.test.js +2 -42
  91. package/harness/packages/server/test/security.test.js +194 -370
  92. package/harness/packages/server/test/settingsNav.test.js +2 -2
  93. package/harness/packages/server/test/tts.test.js +4 -4
  94. package/harness/tamnora.example.json +2 -1
  95. package/package.json +2 -2
  96. package/harness/packages/cli/src/commands/eval.js +0 -186
  97. package/harness/packages/core/src/eval/dataset.js +0 -47
  98. package/harness/packages/core/src/eval/judge.js +0 -39
  99. package/harness/packages/core/src/eval/report.js +0 -48
  100. package/harness/packages/core/src/eval/runTask.js +0 -62
  101. package/harness/packages/core/src/eval/runner.js +0 -153
  102. package/harness/packages/core/src/eval/scorers.js +0 -217
  103. package/harness/packages/core/src/eval/task.js +0 -59
  104. package/harness/packages/core/src/procs/detect.js +0 -149
  105. package/harness/packages/core/test/eval.test.js +0 -144
  106. package/harness/packages/server/src/lib/advisor.js +0 -198
  107. package/harness/packages/server/src/routes/advisor.js +0 -79
  108. package/harness/packages/server/src/routes/eval.js +0 -100
  109. package/harness/packages/server/test/advisor.test.js +0 -249
  110. package/harness/scripts/example-dataset.jsonl +0 -4
@@ -1,12 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { readFileSync, statSync } from 'node:fs';
3
- import { bridgeStatePath } from './config/home.js';
4
- import { pidAlive, killPidTree } from './pid.js';
2
+ import { killPidTree } from './pid.js';
5
3
 
6
4
  /**
7
- * Acceso remoto. Proveedores:
8
- * - tailscale: VPN mesh privada + `tailscale serve` (recomendado; sin exposicion).
9
- * - tunnelmole: tunel publico gratis (URL aleatoria); solo para `preview`.
5
+ * Acceso remoto por tailscale: VPN mesh privada + `tailscale serve`, que
6
+ * publica HTTPS dentro del tailnet reenviando a loopback. No hay proveedor
7
+ * publico: lo unico que sale de la maquina es lo que ya esta en la red privada
8
+ * del usuario.
10
9
  */
11
10
 
12
11
  function killTree(child) {
@@ -14,147 +13,11 @@ function killTree(child) {
14
13
  killPidTree(child.pid);
15
14
  }
16
15
 
17
- /**
18
- * Estado del bridge (escrito por `tamnora bridge`), si el supervisor sigue
19
- * vivo. Lo consulta el server para saber que hay un tunel publico activo y
20
- * ajustar el modelo de confianza (guards locales, cookie Secure).
21
- * Cache de 1 s + invalidacion por mtime del archivo: se lee en cada request
22
- * sensible, pero un bridge.json nuevo (o borrado) se ve al instante.
23
- */
24
- let tunnelCache = { key: null, mtime: null, at: 0, state: null };
25
- export function tunnelState() {
26
- let statePath;
27
- try {
28
- statePath = bridgeStatePath();
29
- } catch {
30
- return null;
31
- }
32
- const now = Date.now();
33
- let mtime = null;
34
- try {
35
- mtime = statSync(statePath).mtimeMs;
36
- } catch {
37
- tunnelCache = { key: statePath, mtime: null, at: now, state: null };
38
- return null;
39
- }
40
- if (tunnelCache.key === statePath && tunnelCache.mtime === mtime && now - tunnelCache.at < 1000) {
41
- return tunnelCache.state;
42
- }
43
- let state = null;
44
- try {
45
- const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
46
- // Tailscale es una red privada que entra por loopback: no es un tunel
47
- // publico, asi que no debe degradar los guards locales (`localOk`).
48
- if (parsed?.provider !== 'tailscale' && parsed?.pid && pidAlive(parsed.pid)) state = parsed;
49
- } catch {
50
- /* sin bridge */
51
- }
52
- tunnelCache = { key: statePath, mtime, at: now, state };
53
- return state;
54
- }
55
-
56
- /** True si hay un tunel publico activo hacia este server. */
57
- export function tunnelActive() {
58
- return Boolean(tunnelState());
59
- }
60
-
61
- export function parseTunnelmoleUrls(text) {
62
- const urls = [...new Set(String(text).match(/https?:\/\/[a-z0-9-]+\.tunnelmole\.net/gi) || [])];
63
- return {
64
- https: urls.find((u) => u.startsWith('https')) || '',
65
- http: urls.find((u) => u.startsWith('http:')) || '',
66
- };
67
- }
68
-
69
- function spawnTunnel(bin, args, opts = {}) {
70
- const log = opts.log || (() => {});
71
- const parse = opts.parse || (() => ({ https: '', http: '' }));
72
- const timeoutMs = opts.timeoutMs || 90000;
73
- const label = opts.label || bin;
74
- const useShell = process.platform === 'win32';
75
- return new Promise((resolve, reject) => {
76
- let child;
77
- try {
78
- // En Windows `npx` es un shim .cmd y exige shell; ahi se manda un unico
79
- // comando (ver shellCommandLine): con array de args Node concatena sin
80
- // escapar y emite el warning DEP0190.
81
- const [cmd, cmdArgs] = useShell ? [shellCommandLine(bin, args), []] : [bin, args];
82
- child = (opts.spawnImpl || spawn)(cmd, cmdArgs, {
83
- stdio: ['ignore', 'pipe', 'pipe'],
84
- windowsHide: true,
85
- shell: useShell,
86
- detached: !useShell,
87
- });
88
- } catch (err) {
89
- reject(new Error(`no se pudo iniciar ${label}: ${err.message}`));
90
- return;
91
- }
92
- let buf = '';
93
- let settled = false;
94
- const timer = setTimeout(() => {
95
- if (settled) return;
96
- settled = true;
97
- killTree(child);
98
- reject(new Error(`${label} no devolvio URL (${Math.round(timeoutMs / 1000)} s).`));
99
- }, timeoutMs);
100
- const feed = (data) => {
101
- buf += String(data);
102
- if (opts.verbose) process.stdout.write(String(data));
103
- const urls = parse(buf);
104
- const url = urls.https || urls.http;
105
- if (url) {
106
- if (settled) return;
107
- settled = true;
108
- clearTimeout(timer);
109
- log(`${label}: ${url} -> localhost:${opts.port}`);
110
- resolve({ url, provider: opts.provider, pid: child.pid, child, stop: () => killTree(child) });
111
- }
112
- };
113
- child.stdout.on('data', feed);
114
- child.stderr.on('data', feed);
115
- child.on('error', (err) => {
116
- if (settled) return;
117
- settled = true;
118
- clearTimeout(timer);
119
- reject(new Error(`${label}: ${err.message}`));
120
- });
121
- child.on('exit', (code) => {
122
- if (settled) return;
123
- settled = true;
124
- clearTimeout(timer);
125
- reject(new Error(`${label} termino (codigo ${code})${buf ? `: ${buf.slice(0, 200).trim()}` : ''}`));
126
- });
127
- });
128
- }
129
-
130
- // Tunel publico para el feature `preview` (no lo usa `bridge`).
131
- export function startTunnelmole(port, opts = {}) {
132
- return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
133
- ...opts,
134
- port,
135
- provider: 'tunnelmole',
136
- label: 'tunnelmole',
137
- parse: parseTunnelmoleUrls,
138
- });
139
- }
140
-
141
16
  /**
142
17
  * Tailscale: VPN mesh (WireGuard). `tailscale serve` publica HTTPS dentro del
143
- * tailnet reenviando a loopback, asi que no hay exposicion publica y las
144
- * peticiones siguen siendo "locales" para el server. Proveedor recomendado.
18
+ * tailnet reenviando a loopback, asi que las peticiones siguen siendo "locales"
19
+ * para el server.
145
20
  */
146
- // Con `shell: true` Node concatena los args sin escapar (warning DEP0190): se
147
- // arma un unico comando con args validados contra metacaracteres del shell.
148
- const SAFE_SHELL_ARG = /^[^&|<>^%!"\r\n]+$/;
149
-
150
- function shellCommandLine(bin, args) {
151
- for (const arg of args) {
152
- if (!SAFE_SHELL_ARG.test(String(arg))) {
153
- throw new Error(`argumento no permitido para ${bin}: "${arg}"`);
154
- }
155
- }
156
- return [bin, ...args].join(' ');
157
- }
158
21
 
159
22
  function runTailscale(args, opts = {}) {
160
23
  const timeoutMs = opts.timeoutMs || 15000;
@@ -312,12 +175,11 @@ export function stopTailscaleServe(opts = {}) {
312
175
  * `opts.preview` usa el `serve` de un puerto HTTPS propio (8443) para no pisar el
313
176
  * bridge de la consola en 443.
314
177
  */
315
- export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
316
- const p = String(provider || 'tunnelmole').toLowerCase();
178
+ export async function startTunnel(port, provider = 'tailscale', opts = {}) {
179
+ const p = String(provider || 'tailscale').toLowerCase();
317
180
  if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
318
181
  if (p === 'tailscale' || p === 'ts') {
319
182
  return opts.preview ? startTailscalePreview(port, opts) : startTailscaleServe(port, opts);
320
183
  }
321
- if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
322
184
  throw new Error(`proveedor de tunel no soportado: ${provider}`);
323
185
  }
@@ -0,0 +1,111 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import {
8
+ AGENT_FILE,
9
+ parseAgentMarkdown,
10
+ loadAgents,
11
+ mergeAgents,
12
+ buildAgentsIndex,
13
+ buildSubagentPrompt,
14
+ writeAgentFile,
15
+ } from '../src/agents/index.js';
16
+
17
+ test('parseAgentMarkdown reads frontmatter, lists and the body', () => {
18
+ const parsed = parseAgentMarkdown(
19
+ '---\nname: Revisor\ndescription: Busca bugs\nmodel: claude\nprovider: anthropic\nmode: plan\neffort: high\nmaxRounds: 4\ntools: read, grep\nskills: [code-review, tests]\n---\n\n# Rol\nMirá el diff.',
20
+ 'revisor'
21
+ );
22
+ assert.equal(parsed.id, 'revisor');
23
+ assert.equal(parsed.name, 'Revisor');
24
+ assert.equal(parsed.description, 'Busca bugs');
25
+ assert.equal(parsed.model, 'claude');
26
+ assert.equal(parsed.provider, 'anthropic');
27
+ assert.equal(parsed.mode, 'plan');
28
+ assert.equal(parsed.effort, 'high');
29
+ assert.equal(parsed.maxRounds, 4);
30
+ assert.deepEqual(parsed.tools, ['read', 'grep']);
31
+ assert.deepEqual(parsed.skills, ['code-review', 'tests']);
32
+ assert.match(parsed.body, /Mirá el diff/);
33
+
34
+ const plain = parseAgentMarkdown('Solo cuerpo.', 'x');
35
+ assert.equal(plain.name, 'x');
36
+ assert.equal(plain.tools, null);
37
+ assert.equal(plain.skills, null);
38
+ assert.equal(plain.body, 'Solo cuerpo.');
39
+ });
40
+
41
+ test('loadAgents scans directories and standalone .md files', async () => {
42
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-agents-'));
43
+ await mkdir(path.join(root, 'revisor'));
44
+ await writeFile(path.join(root, 'revisor', AGENT_FILE), '---\nname: Revisor\n---\nCuerpo.');
45
+ await writeFile(path.join(root, 'tests.md'), '---\nname: Tests\n---\nCuerpo tests.');
46
+ await writeFile(path.join(root, 'ignorado.txt'), 'no es un agente');
47
+
48
+ const agents = loadAgents(root);
49
+ assert.deepEqual(
50
+ agents.map((agent) => agent.id),
51
+ ['revisor', 'tests']
52
+ );
53
+ assert.equal(agents.find((agent) => agent.id === 'revisor').name, 'Revisor');
54
+ assert.equal(loadAgents(path.join(root, 'nope')).length, 0);
55
+ });
56
+
57
+ test('mergeAgents lets the workspace override the casa agent', async () => {
58
+ const home = await mkdtemp(path.join(tmpdir(), 'tamnora-agents-home-'));
59
+ const workspace = await mkdtemp(path.join(tmpdir(), 'tamnora-agents-ws-'));
60
+ await mkdir(path.join(home, 'demo'));
61
+ await writeFile(path.join(home, 'demo', AGENT_FILE), '---\nname: Demo casa\n---\nCasa.');
62
+ await mkdir(path.join(workspace, 'demo'));
63
+ await writeFile(path.join(workspace, 'demo', AGENT_FILE), '---\nname: Demo workspace\n---\nWorkspace.');
64
+
65
+ const merged = mergeAgents([loadAgents(home), loadAgents(workspace)]);
66
+ assert.equal(merged.length, 1);
67
+ assert.equal(merged[0].name, 'Demo workspace');
68
+ });
69
+
70
+ test('buildAgentsIndex catalogs subagents and buildSubagentPrompt includes instructions', () => {
71
+ assert.equal(buildAgentsIndex([]), '');
72
+ const index = buildAgentsIndex([
73
+ { id: 'revisor', name: 'Revisor', description: 'Busca bugs' },
74
+ { id: 'docs', name: 'Docs' },
75
+ ]);
76
+ assert.match(index, /task\(\{ agent: "<id>"/);
77
+ assert.match(index, /revisor: Revisor — Busca bugs/);
78
+ assert.match(index, /docs: Docs/);
79
+
80
+ const prompt = buildSubagentPrompt({ id: 'revisor', name: 'Revisor', description: 'Busca bugs', body: 'Mirá el diff.' }, { basePrompt: 'BASE' });
81
+ assert.match(prompt, /BASE/);
82
+ assert.match(prompt, /subagente "Revisor"/);
83
+ assert.match(prompt, /Mirá el diff\./);
84
+ });
85
+
86
+ test('writeAgentFile round-trips through parseAgentMarkdown', async () => {
87
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-agents-write-'));
88
+ await writeAgentFile(path.join(root, 'revisor'), {
89
+ id: 'revisor',
90
+ name: 'Revisor',
91
+ description: 'Busca bugs',
92
+ model: 'gpt',
93
+ provider: 'openai',
94
+ mode: 'plan',
95
+ effort: 'low',
96
+ maxRounds: 3,
97
+ tools: ['read'],
98
+ skills: null,
99
+ body: '# Rol\nMirá el diff.',
100
+ });
101
+ const text = await readFile(path.join(root, 'revisor', AGENT_FILE), 'utf8');
102
+ const parsed = parseAgentMarkdown(text, 'revisor');
103
+ assert.equal(parsed.name, 'Revisor');
104
+ assert.equal(parsed.effort, 'low');
105
+ assert.equal(parsed.maxRounds, 3);
106
+ assert.deepEqual(parsed.tools, ['read']);
107
+ assert.equal(parsed.skills, null);
108
+ assert.match(parsed.body, /Mirá el diff/);
109
+
110
+ await assert.rejects(() => writeAgentFile(path.join(root, '..'), { id: '../escape', body: 'x' }), /inválido/);
111
+ });
@@ -1,18 +1,9 @@
1
1
  import { test, afterEach } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
3
+ import { mkdtemp, readFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
- import {
7
- setHome,
8
- homeDir,
9
- homePath,
10
- databaseFile,
11
- ensureApp,
12
- readApp,
13
- writeApp,
14
- migrateLegacyDb,
15
- } from '../src/index.js';
6
+ import { setHome, homeDir, homePath, ensureApp, readApp, writeApp } from '../src/index.js';
16
7
 
17
8
  const prevHome = process.env.TAMNORA_HOME;
18
9
 
@@ -47,27 +38,3 @@ test('ensureApp creates app.json with a secret and roundtrips', async () => {
47
38
  writeApp(raw);
48
39
  assert.equal(readApp().port, 4599);
49
40
  });
50
-
51
- test('readApp migra los proveedores retirados (tunnelmole/relay) a tailscale', async () => {
52
- const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
53
- setHome(dir);
54
- for (const provider of ['tunnelmole', 'relay']) {
55
- const app = ensureApp();
56
- app.tunnel = { ...app.tunnel, provider };
57
- writeApp(app);
58
- assert.equal(readApp().tunnel.provider, 'tailscale', provider);
59
- }
60
- });
61
-
62
- test('migrateLegacyDb copies the legacy project database to the global home', async () => {
63
- const home = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
64
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-cwd-'));
65
- setHome(home);
66
-
67
- await mkdir(path.join(cwd, '.tamnora'), { recursive: true });
68
- await writeFile(path.join(cwd, '.tamnora', 'tamnora.db'), 'legacy-db');
69
-
70
- const target = migrateLegacyDb(cwd);
71
- assert.equal(target, databaseFile());
72
- assert.equal(await readFile(target, 'utf8'), 'legacy-db');
73
- });
@@ -5,7 +5,7 @@ import net from 'node:net';
5
5
  import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
6
6
  import { tmpdir } from 'node:os';
7
7
  import path from 'node:path';
8
- import { detectProcs, loadProjectProcs, packageManager, DEFAULT_PROCESS_ALLOW, PROCS_FILE } from '../src/procs/detect.js';
8
+ import { loadProjectProcs, PROCS_FILE } from '../src/procs/project.js';
9
9
  import { killPort, portInfo } from '../src/procs/kill.js';
10
10
 
11
11
  async function waitForPort(port, attempts = 50) {
@@ -55,49 +55,9 @@ test('killPort terminates the process listening on a port', async (t) => {
55
55
  assert.ok(child.exitCode !== null || child.signalCode, 'el hijo debería haber terminado');
56
56
  });
57
57
 
58
- test('detectProcs finds node dev scripts with the right package manager', async () => {
58
+ test('loadProjectProcs devuelve [] si el proyecto no declara procesos', async () => {
59
59
  const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
60
- await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite', test: 'x' } }));
61
- await writeFile(path.join(dir, 'pnpm-lock.yaml'), '');
62
- assert.equal(packageManager(dir), 'pnpm');
63
-
64
- const chips = detectProcs(dir);
65
- const dev = chips.find((c) => c.command === 'pnpm' && c.args.includes('dev'));
66
- assert.ok(dev);
67
- assert.equal(dev.cwd, dir);
68
- assert.ok(DEFAULT_PROCESS_ALLOW.includes('pnpm'));
69
- });
70
-
71
- test('detectProcs falls back to npm and detects php entrypoints', async () => {
72
- const nodeDir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
73
- await writeFile(path.join(nodeDir, 'package.json'), JSON.stringify({ scripts: { start: 'node app.js' } }));
74
- assert.equal(packageManager(nodeDir), 'npm');
75
- assert.ok(detectProcs(nodeDir).some((c) => c.command === 'npm' && c.args.includes('start')));
76
-
77
- const phpDir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
78
- await mkdir(path.join(phpDir, 'public'));
79
- await writeFile(path.join(phpDir, 'public', 'index.php'), '<?php');
80
- const chips = detectProcs(phpDir);
81
- const php = chips.find((c) => c.command === 'php');
82
- assert.ok(php);
83
- assert.equal(php.args[0], '-S');
84
- });
85
-
86
- test('detectProcs offers a static server for plain HTML sites', async () => {
87
- const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
88
- await writeFile(path.join(dir, 'index.html'), '<h1>hola</h1>');
89
- const chips = detectProcs(dir);
90
- const staticChip = chips.find((c) => c.port && c.args.some((a) => a.endsWith('static-server.mjs')));
91
- assert.ok(staticChip);
92
- assert.equal(staticChip.cwd, dir);
93
- assert.ok(staticChip.args.includes(dir));
94
- });
95
-
96
- test('detectProcs does not add the static chip when there is a dev script', async () => {
97
- const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
98
- await writeFile(path.join(dir, 'index.html'), '<h1>hola</h1>');
99
- await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
100
- assert.ok(!detectProcs(dir).some((c) => c.args.some((a) => String(a).endsWith('static-server.mjs'))));
60
+ assert.deepEqual(loadProjectProcs(dir), []);
101
61
  });
102
62
 
103
63
  test('loadProjectProcs reads .tamnora/procs.json and confines dir to the root', async () => {
@@ -124,27 +84,6 @@ test('loadProjectProcs reads .tamnora/procs.json and confines dir to the root',
124
84
  assert.ok(chips.every((c) => c.source === 'project'));
125
85
  });
126
86
 
127
- test('detectProcs puts project chips first and dedupes against auto-detection', async () => {
128
- const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
129
- await mkdir(path.join(dir, '.tamnora'), { recursive: true });
130
- await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
131
- await writeFile(
132
- path.join(dir, PROCS_FILE),
133
- JSON.stringify({
134
- processes: [
135
- { label: 'backend', command: 'node', args: ['server.js'], port: 3000 },
136
- { label: 'dev repetido', command: 'npm', args: ['run', 'dev'] },
137
- ],
138
- })
139
- );
140
-
141
- const chips = detectProcs(dir);
142
- assert.equal(chips[0].source, 'project');
143
- assert.equal(chips[0].command, 'node');
144
- const devChips = chips.filter((c) => c.command === 'npm' && c.args.join(' ') === 'run dev');
145
- assert.equal(devChips.length, 1, 'el chip manual gana al autodetectado');
146
- });
147
-
148
87
  test('portInfo reports whether a port is listening', async (t) => {
149
88
  const free = await portInfo(47599);
150
89
  assert.equal(free.listening, false);
@@ -0,0 +1,43 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { normalizeEffort, applyReasoningEffort, EFFORT_LEVELS } from '../src/providers/reasoning.js';
4
+
5
+ test('normalizeEffort accepts the common vocabulary and aliases', () => {
6
+ for (const level of EFFORT_LEVELS) assert.equal(normalizeEffort(level), level);
7
+ assert.equal(normalizeEffort(' HIGH '), 'high');
8
+ assert.equal(normalizeEffort('none'), 'off');
9
+ assert.equal(normalizeEffort('minimal'), 'low');
10
+ assert.equal(normalizeEffort('xhigh'), 'max');
11
+ assert.equal(normalizeEffort('ultra'), 'max');
12
+ assert.equal(normalizeEffort('auto'), '');
13
+ assert.equal(normalizeEffort(''), '');
14
+ assert.equal(normalizeEffort(null), '');
15
+ assert.equal(normalizeEffort('inventado'), '');
16
+ });
17
+
18
+ test('applyReasoningEffort maps the level to each provider shape', () => {
19
+ const deepseek = applyReasoningEffort({}, 'high', { reasoning: 'deepseek' });
20
+ assert.equal(deepseek.reasoning_effort, 'high');
21
+ assert.deepEqual(deepseek.thinking, { type: 'enabled' });
22
+
23
+ const off = applyReasoningEffort({}, 'off', { reasoning: 'deepseek' });
24
+ assert.equal(off.reasoning_effort, 'none');
25
+ assert.deepEqual(off.thinking, { type: 'disabled' });
26
+
27
+ const openai = applyReasoningEffort({}, 'max', { reasoning: 'openai' });
28
+ assert.equal(openai.reasoning_effort, 'high', 'OpenAI no llega a max');
29
+
30
+ const openrouter = applyReasoningEffort({}, 'max', { reasoning: 'effort' });
31
+ assert.equal(openrouter.reasoning_effort, 'max');
32
+
33
+ const ollama = applyReasoningEffort({}, 'low', { reasoning: 'think' });
34
+ assert.equal(ollama.think, 'low');
35
+ const ollamaOff = applyReasoningEffort({}, 'off', { reasoning: 'think' });
36
+ assert.equal(ollamaOff.think, false);
37
+ });
38
+
39
+ test('applyReasoningEffort leaves unsupported providers untouched', () => {
40
+ assert.deepEqual(applyReasoningEffort({ model: 'x' }, 'high', { reasoning: 'none' }), { model: 'x' });
41
+ assert.deepEqual(applyReasoningEffort({ model: 'x' }, 'high', {}), { model: 'x' });
42
+ assert.deepEqual(applyReasoningEffort({ model: 'x' }, '', { reasoning: 'effort' }), { model: 'x' });
43
+ });
@@ -2,7 +2,7 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { Store } from '../src/storage/sqlite.js';
4
4
 
5
- test('Store persists sessions, messages and runs', () => {
5
+ test('Store persists sessions and messages', () => {
6
6
  const store = new Store(':memory:');
7
7
 
8
8
  const sessionId = store.createSession({ title: 'test', provider: 'openrouter', model: 'm' });
@@ -27,17 +27,6 @@ test('Store persists sessions, messages and runs', () => {
27
27
 
28
28
  assert.equal(store.latestSession().id, sessionId);
29
29
 
30
- const runId = store.createRun({ dataset: 'd.jsonl', config: { models: ['m'] } });
31
- store.addRunResult({ runId, taskId: 't1', model: 'm', passed: true, output: 'ok' });
32
- store.finishRun(runId, { summary: { total: 1 } });
33
-
34
- const run = store.getRun(runId);
35
- assert.equal(run.status, 'done');
36
- assert.equal(run.summary.total, 1);
37
- const results = store.getRunResults(runId);
38
- assert.equal(results.length, 1);
39
- assert.equal(results[0].passed, 1);
40
-
41
30
  store.close();
42
31
  });
43
32
 
@@ -26,12 +26,16 @@ import {
26
26
  agentControlsSlot,
27
27
  buildAppearanceControls,
28
28
  closeCreateFolder,
29
+ closeEffortMenu,
29
30
  closeFolderPicker,
30
31
  closeModeMenu,
31
32
  closeSettingsModal,
32
33
  createFolder,
33
34
  cwdPill,
34
35
  cycleTheme,
36
+ effortMenu,
37
+ effortPill,
38
+ effortSelect,
35
39
  filterFolders,
36
40
  folderGoto,
37
41
  folderList,
@@ -60,6 +64,7 @@ import {
60
64
  providerSelect,
61
65
  renderModePill,
62
66
  setCwd,
67
+ setEffort,
63
68
  setMode,
64
69
  settingsModal,
65
70
  statusbarEl,
@@ -87,7 +92,7 @@ import {
87
92
  PREVIEW_DEVICE_KEY,
88
93
  PREVIEW_URL_KEY,
89
94
  applyTreeFilter,
90
- detectProcs,
95
+ loadProcChips,
91
96
  explorerFilter,
92
97
  explorerMode,
93
98
  filePreviewState,
@@ -114,6 +119,9 @@ import { wireModelPicker } from './models.js';
114
119
  import { restoreTabs, startNewSession } from './sessions.js';
115
120
  import {
116
121
  addPerfilFieldRow,
122
+ agentForm,
123
+ agentModal,
124
+ closeAgentForm,
117
125
  closeMcpForm,
118
126
  closeMemoryForm,
119
127
  closeProviderForm,
@@ -133,6 +141,7 @@ import {
133
141
  mcpPinnedEl,
134
142
  memoryForm,
135
143
  memoryModal,
144
+ openAgentForm,
136
145
  openMcpForm,
137
146
  openMemoryForm,
138
147
  openProviderForm,
@@ -155,7 +164,7 @@ import {
155
164
  setStatus,
156
165
  skillForm,
157
166
  skillModal,
158
- submitAsesor,
167
+ submitAgent,
159
168
  submitChannel,
160
169
  submitMcp,
161
170
  submitMemory,
@@ -164,7 +173,6 @@ import {
164
173
  submitSchedule,
165
174
  submitSkill,
166
175
  syncMcpFormFields,
167
- testAsesor,
168
176
  testChannel,
169
177
  testProvider,
170
178
  vozPitchEl,
@@ -174,15 +182,11 @@ import {
174
182
  import {
175
183
  brandVerEl,
176
184
  dismissUpdate,
177
- evalRunBtn,
178
- evalTasks,
179
185
  followEvents,
180
186
  forceUpdateCheck,
181
187
  reconcile,
182
- refreshRuns,
183
188
  refreshUpdate,
184
189
  renderAlertSignals,
185
- runEval,
186
190
  runUpdate,
187
191
  watchLifecycle,
188
192
  } from './system.js';
@@ -335,6 +339,25 @@ document.addEventListener('keydown', (event) => {
335
339
  if (event.key === 'Escape') closeModeMenu();
336
340
  });
337
341
  modelPill?.addEventListener('click', () => openSettingsModal());
342
+ effortPill?.addEventListener('click', (event) => {
343
+ event.stopPropagation();
344
+ if (!effortMenu) return;
345
+ effortMenu.hidden = !effortMenu.hidden;
346
+ effortPill.setAttribute('aria-expanded', effortMenu.hidden ? 'false' : 'true');
347
+ });
348
+ effortMenu?.addEventListener('click', async (event) => {
349
+ const item = event.target.closest('.ctrl-menu-item');
350
+ if (!item) return;
351
+ closeEffortMenu();
352
+ await setEffort(item.dataset.effort || '');
353
+ });
354
+ effortSelect?.addEventListener('change', () => setEffort(effortSelect.value));
355
+ document.addEventListener('click', (event) => {
356
+ if (effortMenu && !effortMenu.hidden && !event.target.closest('.ctrl-wrap')) closeEffortMenu();
357
+ });
358
+ document.addEventListener('keydown', (event) => {
359
+ if (event.key === 'Escape') closeEffortMenu();
360
+ });
338
361
  yesInput?.addEventListener('change', renderModePill);
339
362
  strictInput?.addEventListener('change', () =>
340
363
  api('/api/settings', { method: 'PUT', body: { strictScope: strictInput.checked } }).catch(() => null)
@@ -619,8 +642,6 @@ $('#voz-save')?.addEventListener('click', submitVoz);
619
642
  $('#voz-test')?.addEventListener('click', testVoz);
620
643
  vozRateEl?.addEventListener('input', renderVozSliders);
621
644
  vozPitchEl?.addEventListener('input', renderVozSliders);
622
- $('#asesor-save')?.addEventListener('click', submitAsesor);
623
- $('#asesor-test')?.addEventListener('click', testAsesor);
624
645
  $('#mcp-close').addEventListener('click', closeMcpForm);
625
646
  $('#mf-cancel').addEventListener('click', closeMcpForm);
626
647
  $('#mf-transport').addEventListener('change', syncMcpFormFields);
@@ -637,6 +658,14 @@ $('#asesor-test')?.addEventListener('click', testAsesor);
637
658
  if (event.target === skillModal) closeSkillForm();
638
659
  });
639
660
 
661
+ $('#agent-new')?.addEventListener('click', () => openAgentForm());
662
+ $('#agent-close')?.addEventListener('click', closeAgentForm);
663
+ $('#af-cancel')?.addEventListener('click', closeAgentForm);
664
+ agentForm?.addEventListener('submit', submitAgent);
665
+ agentModal?.addEventListener('click', (event) => {
666
+ if (event.target === agentModal) closeAgentForm();
667
+ });
668
+
640
669
  $('#memory-new')?.addEventListener('click', () => openMemoryForm());
641
670
  $('#memory-close')?.addEventListener('click', closeMemoryForm);
642
671
  $('#mem-cancel')?.addEventListener('click', closeMemoryForm);
@@ -656,15 +685,6 @@ $('#asesor-test')?.addEventListener('click', testAsesor);
656
685
  });
657
686
  }
658
687
 
659
- evalTasks.value = [
660
- '{"id":"math","input":"¿Cuánto es 17 * 23? Respondé solo el número.","expect":{"equals":"391"}}',
661
- '{"id":"regex","input":"Escribí una oración que contenga un número.","expect":{"regex":"\\\\d"}}',
662
- ].join('\n');
663
-
664
- evalRunBtn.addEventListener('click', runEval);
665
-
666
- $('#runs-refresh').addEventListener('click', refreshRuns);
667
-
668
688
  $('#file-preview-toggle')?.addEventListener('click', async () => {
669
689
  const toggle = $('#file-preview-toggle');
670
690
  const code = $('#file-code');
@@ -759,7 +779,7 @@ $('#preview-device')?.addEventListener('change', (event) => storage.set(PREVIEW_
759
779
  syncPreviewTunnel({ load: true });
760
780
  })();
761
781
 
762
- $('#procs-detect')?.addEventListener('click', detectProcs);
782
+ $('#procs-detect')?.addEventListener('click', loadProcChips);
763
783
 
764
784
  gitFilterEl?.addEventListener('input', () => {
765
785
  if (explorerMode === 'git' && gitEntries.length) renderGitList();