@danieltmn/tamnora 0.24.2 → 0.25.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 (83) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/harness/packages/cli/src/commands/repl.js +18 -2
  3. package/harness/packages/cli/src/commands/run.js +5 -1
  4. package/harness/packages/cli/src/commands/tui.js +5 -1
  5. package/harness/packages/cli/src/lib/runtime.js +2 -0
  6. package/harness/packages/cli/src/tui/app.js +22 -2
  7. package/harness/packages/cli/src/tui/layout.js +1 -1
  8. package/harness/packages/core/src/agent/runAgent.js +5 -2
  9. package/harness/packages/core/src/agents/index.js +141 -0
  10. package/harness/packages/core/src/config/index.js +1 -0
  11. package/harness/packages/core/src/index.js +10 -0
  12. package/harness/packages/core/src/providers/index.js +3 -1
  13. package/harness/packages/core/src/providers/ollama.js +4 -1
  14. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  15. package/harness/packages/core/src/providers/openrouter.js +5 -1
  16. package/harness/packages/core/src/providers/presets.js +4 -3
  17. package/harness/packages/core/src/providers/reasoning.js +58 -0
  18. package/harness/packages/core/test/agents.test.js +111 -0
  19. package/harness/packages/core/test/reasoning.test.js +43 -0
  20. package/harness/packages/server/public/app.js +868 -834
  21. package/harness/packages/server/public/appearance.js +4 -0
  22. package/harness/packages/server/public/boot.js +49 -0
  23. package/harness/packages/server/public/chat.js +1 -0
  24. package/harness/packages/server/public/chrome.js +946 -852
  25. package/harness/packages/server/public/commands.js +448 -405
  26. package/harness/packages/server/public/core.js +229 -216
  27. package/harness/packages/server/public/favicon.ico +0 -0
  28. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  29. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  30. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  31. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  32. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  33. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  34. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  35. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  36. package/harness/packages/server/public/icons/logo.png +0 -0
  37. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/icons/tamnora.ico +0 -0
  40. package/harness/packages/server/public/icons (1).zip +0 -0
  41. package/harness/packages/server/public/index.html +161 -101
  42. package/harness/packages/server/public/landing/boot.js +16 -0
  43. package/harness/packages/server/public/landing/index.html +123 -59
  44. package/harness/packages/server/public/landing/landing.js +11 -9
  45. package/harness/packages/server/public/logo.png +0 -0
  46. package/harness/packages/server/public/markdown.js +1 -1
  47. package/harness/packages/server/public/sessions.js +1 -0
  48. package/harness/packages/server/public/settings.js +1322 -1080
  49. package/harness/packages/server/public/settingsNav.js +82 -0
  50. package/harness/packages/server/public/styles.css +2807 -2525
  51. package/harness/packages/server/public/system.js +2 -2
  52. package/harness/packages/server/src/lib/agents.js +74 -0
  53. package/harness/packages/server/src/lib/appearance.js +4 -0
  54. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  55. package/harness/packages/server/src/lib/prompt.js +11 -3
  56. package/harness/packages/server/src/lib/restart.js +20 -0
  57. package/harness/packages/server/src/lib/scheduler.js +4 -0
  58. package/harness/packages/server/src/lib/securityHeaders.js +53 -0
  59. package/harness/packages/server/src/lib/sessionView.js +1 -0
  60. package/harness/packages/server/src/lib/skills.js +13 -0
  61. package/harness/packages/server/src/lib/tasks.js +71 -23
  62. package/harness/packages/server/src/routes/agents.js +88 -0
  63. package/harness/packages/server/src/routes/chat.js +9 -1
  64. package/harness/packages/server/src/routes/providers.js +7 -0
  65. package/harness/packages/server/src/routes/reload.js +46 -0
  66. package/harness/packages/server/src/routes/sessions.js +3 -1
  67. package/harness/packages/server/src/server.js +13 -1
  68. package/harness/packages/server/test/accessibility.test.js +72 -0
  69. package/harness/packages/server/test/agents.test.js +194 -0
  70. package/harness/packages/server/test/appearance.test.js +4 -0
  71. package/harness/packages/server/test/consoleModules.test.js +133 -57
  72. package/harness/packages/server/test/consoleTabs.test.js +228 -0
  73. package/harness/packages/server/test/docsSync.test.js +73 -0
  74. package/harness/packages/server/test/landingLogin.test.js +89 -0
  75. package/harness/packages/server/test/markdownSecurity.test.js +80 -0
  76. package/harness/packages/server/test/reload.test.js +95 -0
  77. package/harness/packages/server/test/securityHeaders.test.js +109 -0
  78. package/harness/packages/server/test/settingsNav.test.js +90 -0
  79. package/harness/packages/server/test/themeCss.js +110 -0
  80. package/harness/packages/server/test/themes.test.js +72 -0
  81. package/harness/scripts/restart.mjs +101 -0
  82. package/harness/tamnora.example.json +1 -0
  83. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { livePermission, reloadPane, renderSessions, resolvePermissionCard, sendPresence } from './chat.js';
2
2
  import { providerSelect, setSummaryText, yesInput } from './chrome.js';
3
3
  import { toast } from './commands.js';
4
- import { $, api, clear, el, openStream, showConfirm, state, storage, streamPost } from './core.js';
4
+ import { $, api, clear, el, emptyState, openStream, showConfirm, state, storage, streamPost } from './core.js';
5
5
  import { diffState, localState, pollDelayMs } from './reconcile.js';
6
6
  import { followRun } from './runStream.js';
7
7
  import { closeTab, renderTabs, showStandalonePermission } from './sessions.js';
@@ -116,7 +116,7 @@ export async function refreshRuns() {
116
116
  });
117
117
  runsList.appendChild(li);
118
118
  }
119
- if (!data.runs.length) runsList.appendChild(el('li', 'muted', 'Sin runs.'));
119
+ if (!data.runs.length) runsList.appendChild(emptyState('Sin runs todavía', 'Las evaluaciones que corras desde la pestaña eval quedan guardadas acá.'));
120
120
  }
121
121
 
122
122
  export async function openRun(id) {
@@ -0,0 +1,74 @@
1
+ import path from 'node:path';
2
+ import { rm } from 'node:fs/promises';
3
+ import {
4
+ homeDir,
5
+ loadAgents,
6
+ mergeAgents,
7
+ buildAgentsIndex,
8
+ writeAgentFile,
9
+ } from './core.js';
10
+
11
+ export const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
12
+
13
+ /** Agent roots, lowest priority first: casa agents, then the workspace's. */
14
+ export function agentRoots(cwd) {
15
+ return [path.join(homeDir(), 'agents'), path.join(cwd, '.tamnora', 'agents')];
16
+ }
17
+
18
+ export function allAgents(cwd) {
19
+ const home = path.join(homeDir(), 'agents');
20
+ return mergeAgents(agentRoots(cwd).map((root) => loadAgents(root).map((a) => ({ ...a, root, home: root === home }))));
21
+ }
22
+
23
+ function disabledIds(store) {
24
+ const raw = store.getSetting('disabledAgents');
25
+ return new Set(Array.isArray(raw) ? raw.map(String) : []);
26
+ }
27
+
28
+ export function agentsView(ctx) {
29
+ const disabled = disabledIds(ctx.store);
30
+ return allAgents(ctx.cwd).map((agent) => ({
31
+ id: agent.id,
32
+ name: agent.name || agent.id,
33
+ description: agent.description || '',
34
+ model: agent.model || '',
35
+ provider: agent.provider || '',
36
+ mode: agent.mode || '',
37
+ effort: agent.effort || '',
38
+ maxRounds: agent.maxRounds || 0,
39
+ tools: agent.tools,
40
+ skills: agent.skills,
41
+ enabled: !disabled.has(agent.id),
42
+ removable: Boolean(agent.home),
43
+ }));
44
+ }
45
+
46
+ export function findAgent(ctx, id) {
47
+ return allAgents(ctx.cwd).find((agent) => agent.id === id) ?? null;
48
+ }
49
+
50
+ export function enabledAgents(ctx) {
51
+ const disabled = disabledIds(ctx.store);
52
+ return allAgents(ctx.cwd).filter((agent) => !disabled.has(agent.id));
53
+ }
54
+
55
+ export function agentsIndexPrompt(ctx) {
56
+ return buildAgentsIndex(enabledAgents(ctx));
57
+ }
58
+
59
+ export async function writeAgent(id, data) {
60
+ const dir = path.join(homeDir(), 'agents', String(id));
61
+ await writeAgentFile(dir, { ...data, id });
62
+ return dir;
63
+ }
64
+
65
+ export async function removeAgent(id) {
66
+ await rm(path.join(homeDir(), 'agents', String(id)), { recursive: true, force: true });
67
+ }
68
+
69
+ export function setAgentEnabled(store, id, enabled) {
70
+ const current = new Set(Array.isArray(store.getSetting('disabledAgents')) ? store.getSetting('disabledAgents') : []);
71
+ if (enabled) current.delete(id);
72
+ else current.add(id);
73
+ store.setSetting('disabledAgents', [...current]);
74
+ }
@@ -11,10 +11,14 @@ export const THEME_IDS = [
11
11
  'solarized-dark',
12
12
  'bosque',
13
13
  'vino',
14
+ 'arena',
15
+ 'hielo',
14
16
  'vercel',
15
17
  'dark-plus',
16
18
  'github',
17
19
  'github-dark',
20
+ 'mocha',
21
+ 'ebano',
18
22
  ];
19
23
 
20
24
  export const FONT_IDS = ['clasico', 'sistema', 'plex', 'inter', 'jetbrains', 'fira', 'source'];
@@ -140,6 +140,7 @@ export async function run(T, chatId, input, images = []) {
140
140
  maxDurationMs: T.ctx.effectiveMaxDurationMs?.() ?? 0,
141
141
  maxOutputTokens: T.ctx.effectiveMaxOutputTokens?.() ?? T.ctx.config.maxOutputTokens ?? 0,
142
142
  promptCaching: T.ctx.effectivePromptCaching?.() ?? T.ctx.config.promptCaching === true,
143
+ reasoningEffort: T.ctx.effectiveEffort?.() ?? '',
143
144
  onEvent(event) {
144
145
  if (event.type === 'text') {
145
146
  full = event.full;
@@ -33,12 +33,13 @@ function workspacePreamble(cwd) {
33
33
  * @param {string[]} opts.files - instruction file names (e.g. ['AGENTS.md'])
34
34
  * @param {() => string} [opts.profileText] - user profile block, re-read on every call
35
35
  * @param {() => string} [opts.skillsText] - skills block, re-read on every call
36
+ * @param {() => string} [opts.agentsText] - subagent catalog block, re-read on every call
36
37
  * @param {() => string} [opts.mcpText] - MCP tool index block, re-read on every call
37
38
  * @param {() => string} [opts.scopeText] - strict-scope block, re-read on every call
38
39
  * @param {string[]} [opts.ruleFiles] - glob patterns of project rule files
39
40
  * @param {boolean} [opts.memory] - inject the project memory block
40
41
  */
41
- export function createPromptBuilder({ cwd, files, profileText, skillsText, mcpText, scopeText, ruleFiles = [], memory = false }) {
42
+ export function createPromptBuilder({ cwd, files, profileText, skillsText, agentsText, mcpText, scopeText, ruleFiles = [], memory = false }) {
42
43
  const cache = new Map();
43
44
 
44
45
  async function buildFor(workspace) {
@@ -52,15 +53,22 @@ export function createPromptBuilder({ cwd, files, profileText, skillsText, mcpTe
52
53
  return text;
53
54
  }
54
55
 
55
- return async function systemPromptFor(workspace = cwd) {
56
+ return async function systemPromptFor(workspace = cwd, overrides = {}) {
56
57
  const key = workspace || cwd;
57
58
  if (!cache.has(key)) cache.set(key, buildFor(key));
58
59
  const base = await cache.get(key);
59
60
  const parts = [base];
60
61
  const profile = typeof profileText === 'function' ? String(profileText() ?? '').trim() : '';
61
62
  if (profile) parts.push(profile);
62
- const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
63
+ // Un subagente puede reemplazar (o vaciar) el bloque de skills y el catálogo
64
+ // de otros subagentes: las tools no se heredan automáticamente.
65
+ const skillsSource = overrides.skillsText !== undefined ? overrides.skillsText : skillsText;
66
+ const extra =
67
+ typeof skillsSource === 'function' ? String(skillsSource() ?? '').trim() : String(skillsSource ?? '').trim();
63
68
  if (extra) parts.push(extra);
69
+ const agentsSource = overrides.agentsText !== undefined ? overrides.agentsText : agentsText;
70
+ const agents = typeof agentsSource === 'function' ? String(agentsSource() ?? '').trim() : String(agentsSource ?? '').trim();
71
+ if (agents) parts.push(agents);
64
72
  const mcp = typeof mcpText === 'function' ? String(mcpText() ?? '').trim() : '';
65
73
  if (mcp) parts.push(mcp);
66
74
  if (memory) {
@@ -0,0 +1,20 @@
1
+ // Lanza el script detached que espera la muerte de este server y lo relanza con
2
+ // el mismo comando con que arranco. Devolver rapido es el punto: el server tiene
3
+ // que soltar el puerto antes de que el nuevo intente entrar.
4
+ import { spawn } from 'node:child_process';
5
+ import path from 'node:path';
6
+ import { packageRoot, homeDir } from './core.js';
7
+
8
+ export function spawnRestart({ pid = process.pid, argv = process.argv.slice(1), home } = {}) {
9
+ const root = packageRoot();
10
+ if (!root) return { ok: false, error: 'no encontre el paquete del harness' };
11
+ const script = path.join(root, 'harness', 'scripts', 'restart.mjs');
12
+ const args = [script, '--pid', String(pid), '--argv', JSON.stringify(argv), '--home', home || homeDir()];
13
+ try {
14
+ const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', windowsHide: true });
15
+ child.unref();
16
+ return { ok: true, pid: child.pid };
17
+ } catch (error) {
18
+ return { ok: false, error: String(error?.message ?? error) };
19
+ }
20
+ }
@@ -13,6 +13,7 @@ import {
13
13
  } from './agentRun.js';
14
14
  import { createSubagentTool } from './tasks.js';
15
15
  import { createRulesInjector } from './rules.js';
16
+ import { enabledAgents } from './agents.js';
16
17
 
17
18
  function scheduleSessionId(ctx, schedule) {
18
19
  const payload = schedule.payload ?? {};
@@ -74,6 +75,8 @@ async function runAgentSchedule(ctx, schedule) {
74
75
  providerId,
75
76
  model,
76
77
  parentMode: mode,
78
+ effort: ctx.effectiveEffort?.() ?? '',
79
+ agents: enabledAgents(ctx),
77
80
  permissions: ctx.config.permissions,
78
81
  mcp,
79
82
  mcpMode: ctx.effectiveMcpMode(),
@@ -109,6 +112,7 @@ async function runAgentSchedule(ctx, schedule) {
109
112
  maxDurationMs: ctx.effectiveMaxDurationMs?.() || 0,
110
113
  maxOutputTokens: ctx.effectiveMaxOutputTokens?.() || 0,
111
114
  promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
115
+ reasoningEffort: ctx.effectiveEffort?.() ?? '',
112
116
  onEvent(event) {
113
117
  if (event.type === 'text') emit('text', { full: event.full });
114
118
  else if (event.type === 'reasoning') emit('reasoning', { full: event.full });
@@ -0,0 +1,53 @@
1
+ // Cabeceras de seguridad de la consola. La consola es una SPA sin build: sus
2
+ // scripts son modulos propios, asi que `script-src 'self'` alcanza y un XSS por
3
+ // un hueco del renderer de markdown no puede cargar ni ejecutar codigo ajeno.
4
+
5
+ const FONTS_CSS = 'https://fonts.googleapis.com';
6
+ const FONTS_FILES = 'https://fonts.gstatic.com';
7
+
8
+ export const CSP_DIRECTIVES = {
9
+ 'default-src': ["'self'"],
10
+ 'script-src': ["'self'"],
11
+ 'style-src': ["'self'", "'unsafe-inline'", FONTS_CSS],
12
+ 'font-src': ["'self'", FONTS_FILES, 'data:'],
13
+ 'img-src': ["'self'", 'data:', 'blob:'],
14
+ 'media-src': ["'self'", 'blob:', 'data:'],
15
+ 'connect-src': ["'self'"],
16
+ // La pestaña preview embebe el dev server del usuario (local o por tunel).
17
+ 'frame-src': ['http:', 'https:'],
18
+ 'frame-ancestors': ["'self'"],
19
+ 'manifest-src': ["'self'"],
20
+ 'worker-src': ["'self'", 'blob:'],
21
+ 'object-src': ["'none'"],
22
+ 'base-uri': ["'self'"],
23
+ 'form-action': ["'self'"],
24
+ };
25
+
26
+ export function buildCsp(directives = CSP_DIRECTIVES) {
27
+ return Object.entries(directives)
28
+ .map(([name, sources]) => `${name} ${sources.join(' ')}`)
29
+ .join('; ');
30
+ }
31
+
32
+ const CSP_VALUE = buildCsp();
33
+
34
+ export const CSP_MODES = ['enforce', 'report-only', 'off'];
35
+
36
+ export function cspMode(config) {
37
+ const mode = config?.server?.csp;
38
+ return CSP_MODES.includes(mode) ? mode : 'enforce';
39
+ }
40
+
41
+ /**
42
+ * Setea las cabeceras en `res` antes de `writeHead` (que las mezcla). La CSP solo
43
+ * va en lo que no es API: en un JSON o un SSE no protege nada.
44
+ */
45
+ export function applySecurityHeaders(res, { api = false, csp = 'enforce' } = {}) {
46
+ res.setHeader('X-Content-Type-Options', 'nosniff');
47
+ res.setHeader('Referrer-Policy', 'same-origin');
48
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
49
+ res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=(), usb=()');
50
+ if (api || csp === 'off') return;
51
+ const name = csp === 'report-only' ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy';
52
+ res.setHeader(name, CSP_VALUE);
53
+ }
@@ -12,6 +12,7 @@ export function sessionView(session) {
12
12
  provider: session.provider,
13
13
  model: session.model,
14
14
  mode: session.meta?.mode ?? null,
15
+ effort: session.meta?.effort ?? null,
15
16
  cwd: session.cwd,
16
17
  goal: session.meta?.goal ?? null,
17
18
  hasSummary: Boolean(session.meta?.summary),
@@ -57,6 +57,19 @@ export function skillsBlock(ctx) {
57
57
  return eager ? skillsPrompt(ctx) : skillsIndexPrompt(ctx);
58
58
  }
59
59
 
60
+ /**
61
+ * Bloque de skills acotado a las que declara un subagente. `ids === null`
62
+ * significa "sin restricción" (todas las habilitadas); un array vacío no
63
+ * inyecta skills.
64
+ */
65
+ export function skillsBlockFor(ctx, ids = null) {
66
+ if (!Array.isArray(ids)) return skillsBlock(ctx);
67
+ const wanted = new Set(ids.map(String));
68
+ const selected = enabledSkills(ctx).filter((skill) => wanted.has(skill.id));
69
+ if (!selected.length) return '';
70
+ return ctx.config?.skills?.mode === 'eager' ? buildSkillsPrompt(selected) : buildSkillsIndex(selected);
71
+ }
72
+
60
73
  export async function writeSkill(id, { name, description, when, body }) {
61
74
  const dir = path.join(homeDir(), 'skills', id);
62
75
  await mkdir(dir, { recursive: true });
@@ -2,16 +2,13 @@
2
2
  // contexto propio y un toolset acotado (solo lectura por defecto). Los eventos
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
+ //
6
+ // Un subagente puede venir de una definicion del proyecto (`agents/*.md`): en
7
+ // ese caso aporta su propio modelo/proveedor, modo, tools, skills y esfuerzo.
5
8
 
6
- import { runAgent, createTools, resolveMode } from './core.js';
9
+ import { runAgent, createTools, resolveMode, normalizeEffort, buildSubagentPrompt } from './core.js';
7
10
  import { toolCallPayload, toolResultPayload } from './agentRun.js';
8
-
9
- const SUBAGENT_PROMPT = [
10
- 'Sos un subagente de Tamnora. Ejecutá de forma autónoma la tarea encomendada',
11
- 'usando las herramientas disponibles y devolvé un resumen conciso y concreto de',
12
- 'lo que encontraste o hiciste (archivos, hallazgos, decisiones). No pidas',
13
- 'confirmaciones: si algo no es posible, explicalo en el resumen.',
14
- ].join(' ');
11
+ import { skillsBlockFor } from './skills.js';
15
12
 
16
13
  function definition(name, description, parameters, required = []) {
17
14
  return {
@@ -34,9 +31,16 @@ function clampMode(requested, parentMode) {
34
31
  return { id: requested.id, tools, writeExtensions };
35
32
  }
36
33
 
34
+ function intersect(list, allowed) {
35
+ if (!Array.isArray(list) || !list.length) return null;
36
+ const set = new Set(allowed);
37
+ return list.filter((item) => set.has(item));
38
+ }
39
+
37
40
  /**
38
41
  * Crea la tool `task` para una sesión/run y la función `execute`.
39
42
  * `emit(type, data, agentId)` persiste y difunde el evento (via RunManager).
43
+ * `agents` es el catálogo de subagentes habilitados del workspace.
40
44
  */
41
45
  export function createSubagentTool(ctx, options) {
42
46
  const {
@@ -44,6 +48,7 @@ export function createSubagentTool(ctx, options) {
44
48
  cwd,
45
49
  providerId,
46
50
  model,
51
+ effort = '',
47
52
  permissions = {},
48
53
  mcp = null,
49
54
  mcpMode = 'auto',
@@ -52,15 +57,26 @@ export function createSubagentTool(ctx, options) {
52
57
  exclude = [],
53
58
  emit,
54
59
  parentMode = null,
60
+ agents = [],
55
61
  } = options;
56
62
 
63
+ const available = new Map(agents.map((agent) => [agent.id, agent]));
64
+ const agentParam = {
65
+ type: 'string',
66
+ description: available.size
67
+ ? `Id del subagente (ver el catálogo del system prompt): ${[...available.keys()].join(', ')}`
68
+ : 'Id del subagente (no hay subagentes definidos)',
69
+ };
70
+ if (available.size) agentParam.enum = [...available.keys()];
71
+
57
72
  const definitions = [
58
73
  definition(
59
74
  'task',
60
- 'Delega una subtarea a un subagente con su propio contexto (solo lectura por defecto). Devolvé un prompt claro y autocontenido; el subagente responde con un resumen.',
75
+ 'Delega una subtarea a un subagente con su propio contexto. Si hay subagentes definidos, elegí uno con "agent"; si no, se crea uno ad-hoc de solo lectura. Devolvé un prompt claro y autocontenido; el subagente responde con un resumen.',
61
76
  {
62
77
  description: { type: 'string', description: 'Título corto de la subtarea' },
63
78
  prompt: { type: 'string', description: 'Instrucción completa y autocontenida para el subagente' },
79
+ agent: agentParam,
64
80
  mode: { type: 'string', enum: ['plan', 'agent'], description: 'plan (solo lectura, default) o agent' },
65
81
  tools: { type: 'array', items: { type: 'string' }, description: 'Limitar las tools del subagente' },
66
82
  },
@@ -76,15 +92,31 @@ export function createSubagentTool(ctx, options) {
76
92
  const description = String(args.description ?? prompt).slice(0, 80).trim() || 'subtarea';
77
93
  if (!prompt) return 'La subtarea necesita "prompt".';
78
94
 
79
- const agentId = ctx.store.createAgent({ sessionId, kind: 'task', title: description, model });
80
- const mode = clampMode(resolveMode(args.mode ?? 'plan'), parentMode);
81
- emit('agent_start', { agentId, description, mode: mode.id, model }, agentId);
95
+ const wanted = String(args.agent ?? '').trim();
96
+ const spec = wanted ? available.get(wanted) : null;
97
+ if (wanted && !spec) {
98
+ const ids = [...available.keys()].join(', ');
99
+ return ids ? `No existe el subagente "${wanted}". Disponibles: ${ids}.` : 'No hay subagentes definidos.';
100
+ }
101
+
102
+ const subProviderId = spec?.provider || providerId;
103
+ const subModel = spec?.model || model;
104
+ const subEffort = normalizeEffort(spec?.effort) || normalizeEffort(effort);
105
+ const rawMode = resolveMode(spec?.mode || args.mode || 'plan');
106
+ const mode = clampMode(rawMode, parentMode);
107
+
108
+ // Precedencia: tools de la definicion > tools pedidas > tools del modo.
109
+ const declared = Array.isArray(spec?.tools) && spec.tools.length ? spec.tools : null;
110
+ const requested = declared ?? (Array.isArray(args.tools) && args.tools.length ? args.tools : (mode.tools ?? undefined));
111
+ let enabled = requested ? requested.filter((tool) => !exclude.includes(tool)) : undefined;
112
+ if (enabled && mode.tools) enabled = intersect(enabled, mode.tools) ?? [];
113
+ else if (!enabled && mode.tools) enabled = [...mode.tools];
114
+ if (!enabled) enabled = undefined;
115
+
116
+ const title = spec ? spec.name || spec.id : description;
117
+ const agentId = ctx.store.createAgent({ sessionId, kind: 'task', title, model: subModel });
118
+ emit('agent_start', { agentId, description, mode: mode.id, model: subModel, agent: spec?.id ?? null }, agentId);
82
119
 
83
- const requested =
84
- Array.isArray(args.tools) && args.tools.length
85
- ? args.tools.filter((tool) => !exclude.includes(tool))
86
- : (mode.tools ?? undefined);
87
- const enabled = mode.tools ? requested.filter((tool) => mode.tools.includes(tool)) : requested;
88
120
  const local = createTools({
89
121
  root: cwd,
90
122
  enabled,
@@ -96,29 +128,45 @@ export function createSubagentTool(ctx, options) {
96
128
  mcpPinned,
97
129
  signal,
98
130
  });
99
- const basePrompt = await ctx.systemPromptFor(cwd);
131
+ // Un subagente con skills declaradas ve solo esas skills (arbol de contexto
132
+ // acotado); sin declaracion hereda el bloque general.
133
+ const skillsText = spec ? skillsBlockFor(ctx, spec.skills) : undefined;
134
+ const basePrompt = await ctx.systemPromptFor(cwd, {
135
+ skillsText: spec ? skillsText : undefined,
136
+ agentsText: '',
137
+ });
138
+ const systemPrompt = buildSubagentPrompt(
139
+ {
140
+ id: spec?.id ?? 'task',
141
+ name: spec?.name ?? description,
142
+ description: spec?.description ?? '',
143
+ body: spec?.body ?? '',
144
+ },
145
+ { basePrompt }
146
+ );
100
147
  const messages = [
101
- { role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
148
+ { role: 'system', content: `${systemPrompt}\n\nTarea: ${description}` },
102
149
  { role: 'user', content: prompt },
103
150
  ];
104
151
  const { provider, config } = ctx.resolveProvider({
105
- ...ctx.providerSettings(providerId),
106
- apiKey: ctx.apiKeyFor(providerId),
152
+ ...ctx.providerSettings(subProviderId),
153
+ apiKey: ctx.apiKeyFor(subProviderId),
107
154
  });
108
155
 
109
156
  try {
110
157
  const result = await runAgent({
111
158
  provider,
112
159
  config,
113
- model,
160
+ model: subModel,
114
161
  messages,
115
162
  tools: local.definitions,
116
163
  execute: local.execute,
117
164
  redactExtra: ctx.secretExtra?.() ?? [],
118
165
  signal,
119
- maxRounds: ctx.config.agents?.maxRounds ?? 6,
166
+ maxRounds: spec?.maxRounds || ctx.config.agents?.maxRounds || 6,
120
167
  maxOutputTokens: ctx.config.agents?.maxOutputTokens ?? ctx.config.maxOutputTokens ?? 0,
121
168
  promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
169
+ reasoningEffort: subEffort,
122
170
  onEvent(event) {
123
171
  if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
124
172
  else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
@@ -0,0 +1,88 @@
1
+ import { readJson, sendJson, sendError } from '../lib/http.js';
2
+ import { normalizeEffort, resolveMode } from '../lib/core.js';
3
+ import {
4
+ AGENT_ID_PATTERN,
5
+ agentsView,
6
+ findAgent,
7
+ writeAgent,
8
+ removeAgent,
9
+ setAgentEnabled,
10
+ } from '../lib/agents.js';
11
+
12
+ function cleanList(value) {
13
+ if (value === undefined || value === null) return null;
14
+ if (!Array.isArray(value)) return null;
15
+ return value.map((item) => String(item).trim()).filter(Boolean);
16
+ }
17
+
18
+ export function registerAgents(route, ctx) {
19
+ route('GET', '/api/agents', ({ res }) => {
20
+ sendJson(res, 200, { agents: agentsView(ctx) });
21
+ });
22
+
23
+ route('GET', '/api/agents/:id', ({ res, params }) => {
24
+ const agent = findAgent(ctx, params.id);
25
+ if (!agent) return sendError(res, 404, 'Subagente no encontrado.');
26
+ sendJson(res, 200, {
27
+ agent: {
28
+ id: agent.id,
29
+ name: agent.name || agent.id,
30
+ description: agent.description || '',
31
+ model: agent.model || '',
32
+ provider: agent.provider || '',
33
+ mode: agent.mode || '',
34
+ effort: agent.effort || '',
35
+ maxRounds: agent.maxRounds || 0,
36
+ tools: agent.tools,
37
+ skills: agent.skills,
38
+ body: agent.body,
39
+ removable: Boolean(agent.home),
40
+ },
41
+ });
42
+ });
43
+
44
+ route('PUT', '/api/agents/:id', async ({ req, res, params }) => {
45
+ const id = String(params.id);
46
+ if (!AGENT_ID_PATTERN.test(id)) return sendError(res, 400, 'Id de subagente inválido (a-z, 0-9, - y _).');
47
+ const body = await readJson(req);
48
+ if (!String(body.body ?? '').trim() && !String(body.description ?? '').trim()) {
49
+ return sendError(res, 400, 'El subagente necesita un cuerpo o una descripción.');
50
+ }
51
+ const mode = body.mode ? resolveMode(String(body.mode)).id : '';
52
+ const maxRounds = Number(body.maxRounds);
53
+ try {
54
+ await writeAgent(id, {
55
+ name: String(body.name ?? id).trim() || id,
56
+ description: String(body.description ?? '').trim(),
57
+ model: String(body.model ?? '').trim(),
58
+ provider: String(body.provider ?? '').trim(),
59
+ mode,
60
+ effort: normalizeEffort(body.effort),
61
+ maxRounds: Number.isFinite(maxRounds) && maxRounds > 0 ? Math.round(maxRounds) : 0,
62
+ tools: cleanList(body.tools),
63
+ skills: cleanList(body.skills),
64
+ body: String(body.body ?? ''),
65
+ });
66
+ } catch (error) {
67
+ return sendError(res, 500, `No se pudo guardar el subagente: ${error.message}`);
68
+ }
69
+ sendJson(res, 200, { agents: agentsView(ctx) });
70
+ });
71
+
72
+ route('DELETE', '/api/agents/:id', async ({ res, params }) => {
73
+ const agent = findAgent(ctx, params.id);
74
+ if (!agent) return sendError(res, 404, 'Subagente no encontrado.');
75
+ if (!agent.home) return sendError(res, 400, 'Ese subagente vive en el workspace y no se borra desde acá.');
76
+ await removeAgent(agent.id);
77
+ setAgentEnabled(ctx.store, agent.id, true);
78
+ sendJson(res, 200, { agents: agentsView(ctx) });
79
+ });
80
+
81
+ route('POST', '/api/agents/:id/toggle', async ({ res, params }) => {
82
+ const agent = findAgent(ctx, params.id);
83
+ if (!agent) return sendError(res, 404, 'Subagente no encontrado.');
84
+ const enabled = !agentsView(ctx).find((item) => item.id === agent.id)?.enabled;
85
+ setAgentEnabled(ctx.store, agent.id, enabled);
86
+ sendJson(res, 200, { id: agent.id, enabled, agents: agentsView(ctx) });
87
+ });
88
+ }
@@ -3,11 +3,13 @@ import {
3
3
  runAgent,
4
4
  createTools,
5
5
  resolveMode,
6
+ normalizeEffort,
6
7
  MAX_TOOL_ROUNDS,
7
8
  } from '../lib/core.js';
8
9
  import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
9
10
  import { workspaceForRequest, resolveExistingDir, localOk } from '../lib/workspace.js';
10
11
  import { createSubagentTool } from '../lib/tasks.js';
12
+ import { enabledAgents } from '../lib/agents.js';
11
13
  import {
12
14
  combineToolset,
13
15
  combineLifecycle,
@@ -162,6 +164,8 @@ export function registerChat(route, ctx) {
162
164
  const model = body.model || session?.model || ctx.effectiveModel();
163
165
  if (!model) return sendError(res, 400, 'Falta "model" (en el cuerpo, en Ajustes o en tamnora.json).');
164
166
  const mode = resolveMode(body.mode ?? session?.meta?.mode ?? ctx.store.getSetting('activeMode'));
167
+ // Esfuerzo de razonamiento: cuerpo > sesión > default global/config.
168
+ const effort = normalizeEffort(body.effort ?? session?.meta?.effort ?? ctx.effectiveEffort?.());
165
169
 
166
170
  if (!sessionId) {
167
171
  try {
@@ -174,7 +178,7 @@ export function registerChat(route, ctx) {
174
178
  provider: providerId,
175
179
  model,
176
180
  cwd: sessionCwd,
177
- meta: { mode: mode.id },
181
+ meta: { mode: mode.id, effort },
178
182
  });
179
183
  }
180
184
 
@@ -209,6 +213,7 @@ export function registerChat(route, ctx) {
209
213
  provider: providerId,
210
214
  model,
211
215
  mode: mode.id,
216
+ effort,
212
217
  cwd: sessionCwd,
213
218
  say: fraseInicio(),
214
219
  });
@@ -271,6 +276,8 @@ export function registerChat(route, ctx) {
271
276
  providerId,
272
277
  model,
273
278
  parentMode: mode,
279
+ effort,
280
+ agents: enabledAgents(ctx),
274
281
  permissions: ctx.config.permissions,
275
282
  mcp,
276
283
  mcpMode: ctx.effectiveMcpMode(),
@@ -347,6 +354,7 @@ export function registerChat(route, ctx) {
347
354
  maxDurationMs: Number(body.maxDurationMs) || ctx.effectiveMaxDurationMs?.() || 0,
348
355
  maxOutputTokens: Number(body.maxOutputTokens) || ctx.effectiveMaxOutputTokens?.() || 0,
349
356
  promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
357
+ reasoningEffort: effort,
350
358
  stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
351
359
  contextTokens,
352
360
  onEvent(event) {
@@ -7,6 +7,7 @@ import {
7
7
  readApp,
8
8
  writeApp,
9
9
  maskSecret,
10
+ normalizeEffort,
10
11
  } from '../lib/core.js';
11
12
  import { resolveAppearance, sanitizeAppearance } from '../lib/appearance.js';
12
13
  import { readJson, sendJson, sendError } from '../lib/http.js';
@@ -26,6 +27,7 @@ export function providerList(ctx) {
26
27
  baseUrl: row?.baseUrl || '',
27
28
  defaultBaseUrl: meta.defaultBaseUrl,
28
29
  requiresKey: row?.requiresKey ?? meta.requiresKey,
30
+ reasoning: meta.reasoning ?? 'none',
29
31
  custom: false,
30
32
  builtin: true,
31
33
  stored: Boolean(row),
@@ -44,6 +46,7 @@ export function providerList(ctx) {
44
46
  baseUrl: row.baseUrl,
45
47
  defaultBaseUrl: '',
46
48
  requiresKey: row.requiresKey ?? true,
49
+ reasoning: 'none',
47
50
  custom: true,
48
51
  builtin: false,
49
52
  stored: true,
@@ -83,6 +86,7 @@ export function registerProviders(route, ctx) {
83
86
  maxDurationMs: ctx.effectiveMaxDurationMs(),
84
87
  maxOutputTokens: ctx.effectiveMaxOutputTokens(),
85
88
  promptCaching: ctx.effectivePromptCaching(),
89
+ effort: ctx.effectiveEffort(),
86
90
  defaultCwd: ctx.store.getSetting('defaultCwd') || '',
87
91
  strictScope: ctx.effectiveStrictScope(),
88
92
  mcpMode: ctx.effectiveMcpMode(),
@@ -101,6 +105,9 @@ export function registerProviders(route, ctx) {
101
105
  if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
102
106
  if (body.defaultCwd !== undefined) ctx.store.setSetting('defaultCwd', String(body.defaultCwd ?? '').trim() || null);
103
107
  if (body.strictScope !== undefined) ctx.store.setSetting('strictScope', Boolean(body.strictScope));
108
+ if (body.effort !== undefined) {
109
+ ctx.store.setSetting('activeEffort', normalizeEffort(body.effort) || null);
110
+ }
104
111
  if (body.promptCaching !== undefined) ctx.store.setSetting('promptCaching', Boolean(body.promptCaching));
105
112
  if (body.mcpMode !== undefined) {
106
113
  const mode = String(body.mcpMode);