/ utils / plugins / loadPluginAgents.ts
loadPluginAgents.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import { basename } from 'path'
  3  import { isAutoMemoryEnabled } from '../../memdir/paths.js'
  4  import type { AgentColorName } from '../../tools/AgentTool/agentColorManager.js'
  5  import {
  6    type AgentMemoryScope,
  7    loadAgentMemoryPrompt,
  8  } from '../../tools/AgentTool/agentMemory.js'
  9  import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'
 10  import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js'
 11  import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'
 12  import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js'
 13  import { getPluginErrorMessage } from '../../types/plugin.js'
 14  import { logForDebugging } from '../debug.js'
 15  import { EFFORT_LEVELS, parseEffortValue } from '../effort.js'
 16  import {
 17    coerceDescriptionToString,
 18    parseFrontmatter,
 19    parsePositiveIntFromFrontmatter,
 20  } from '../frontmatterParser.js'
 21  import { getFsImplementation, isDuplicatePath } from '../fsOperations.js'
 22  import {
 23    parseAgentToolsFromFrontmatter,
 24    parseSlashCommandToolsFromFrontmatter,
 25  } from '../markdownConfigLoader.js'
 26  import { loadAllPluginsCacheOnly } from './pluginLoader.js'
 27  import {
 28    loadPluginOptions,
 29    substitutePluginVariables,
 30    substituteUserConfigInContent,
 31  } from './pluginOptionsStorage.js'
 32  import type { PluginManifest } from './schemas.js'
 33  import { walkPluginMarkdown } from './walkPluginMarkdown.js'
 34  
 35  const VALID_MEMORY_SCOPES: AgentMemoryScope[] = ['user', 'project', 'local']
 36  
 37  async function loadAgentsFromDirectory(
 38    agentsPath: string,
 39    pluginName: string,
 40    sourceName: string,
 41    pluginPath: string,
 42    pluginManifest: PluginManifest,
 43    loadedPaths: Set<string>,
 44  ): Promise<AgentDefinition[]> {
 45    const agents: AgentDefinition[] = []
 46    await walkPluginMarkdown(
 47      agentsPath,
 48      async (fullPath, namespace) => {
 49        const agent = await loadAgentFromFile(
 50          fullPath,
 51          pluginName,
 52          namespace,
 53          sourceName,
 54          pluginPath,
 55          pluginManifest,
 56          loadedPaths,
 57        )
 58        if (agent) agents.push(agent)
 59      },
 60      { logLabel: 'agents' },
 61    )
 62    return agents
 63  }
 64  
 65  async function loadAgentFromFile(
 66    filePath: string,
 67    pluginName: string,
 68    namespace: string[],
 69    sourceName: string,
 70    pluginPath: string,
 71    pluginManifest: PluginManifest,
 72    loadedPaths: Set<string>,
 73  ): Promise<AgentDefinition | null> {
 74    const fs = getFsImplementation()
 75    if (isDuplicatePath(fs, filePath, loadedPaths)) {
 76      return null
 77    }
 78    try {
 79      const content = await fs.readFile(filePath, { encoding: 'utf-8' })
 80      const { frontmatter, content: markdownContent } = parseFrontmatter(
 81        content,
 82        filePath,
 83      )
 84  
 85      const baseAgentName =
 86        (frontmatter.name as string) || basename(filePath).replace(/\.md$/, '')
 87  
 88      // Apply namespace prefixing like we do for commands
 89      const nameParts = [pluginName, ...namespace, baseAgentName]
 90      const agentType = nameParts.join(':')
 91  
 92      // Parse agent metadata from frontmatter
 93      const whenToUse =
 94        coerceDescriptionToString(frontmatter.description, agentType) ??
 95        coerceDescriptionToString(frontmatter['when-to-use'], agentType) ??
 96        `Agent from ${pluginName} plugin`
 97  
 98      let tools = parseAgentToolsFromFrontmatter(frontmatter.tools)
 99      const skills = parseSlashCommandToolsFromFrontmatter(frontmatter.skills)
100      const color = frontmatter.color as AgentColorName | undefined
101      const modelRaw = frontmatter.model
102      let model: string | undefined
103      if (typeof modelRaw === 'string' && modelRaw.trim().length > 0) {
104        const trimmed = modelRaw.trim()
105        model = trimmed.toLowerCase() === 'inherit' ? 'inherit' : trimmed
106      }
107      const backgroundRaw = frontmatter.background
108      const background =
109        backgroundRaw === 'true' || backgroundRaw === true ? true : undefined
110      // Substitute ${CLAUDE_PLUGIN_ROOT} so agents can reference bundled files,
111      // and ${user_config.X} (non-sensitive only) so they can embed configured
112      // usernames, endpoints, etc. Sensitive refs resolve to a placeholder.
113      let systemPrompt = substitutePluginVariables(markdownContent.trim(), {
114        path: pluginPath,
115        source: sourceName,
116      })
117      if (pluginManifest.userConfig) {
118        systemPrompt = substituteUserConfigInContent(
119          systemPrompt,
120          loadPluginOptions(sourceName),
121          pluginManifest.userConfig,
122        )
123      }
124  
125      // Parse memory scope
126      const memoryRaw = frontmatter.memory as string | undefined
127      let memory: AgentMemoryScope | undefined
128      if (memoryRaw !== undefined) {
129        if (VALID_MEMORY_SCOPES.includes(memoryRaw as AgentMemoryScope)) {
130          memory = memoryRaw as AgentMemoryScope
131        } else {
132          logForDebugging(
133            `Plugin agent file ${filePath} has invalid memory value '${memoryRaw}'. Valid options: ${VALID_MEMORY_SCOPES.join(', ')}`,
134          )
135        }
136      }
137  
138      // Parse isolation mode
139      const isolationRaw = frontmatter.isolation as string | undefined
140      const isolation =
141        isolationRaw === 'worktree' ? ('worktree' as const) : undefined
142  
143      // Parse effort (string level or integer)
144      const effortRaw = frontmatter.effort
145      const effort =
146        effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
147      if (effortRaw !== undefined && effort === undefined) {
148        logForDebugging(
149          `Plugin agent file ${filePath} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
150        )
151      }
152  
153      // permissionMode, hooks, and mcpServers are intentionally NOT parsed for
154      // plugin agents. Plugins are third-party marketplace code; these fields
155      // escalate what the agent can do beyond what the user approved at install
156      // time. For this level of control, define the agent in .claude/agents/
157      // where the user explicitly wrote the frontmatter. (Note: plugins can
158      // still ship hooks and MCP servers at the manifest level — that's the
159      // install-time trust boundary. Per-agent declarations would let a single
160      // agent file buried in agents/ silently add them.) See PR #22558 review.
161      for (const field of ['permissionMode', 'hooks', 'mcpServers'] as const) {
162        if (frontmatter[field] !== undefined) {
163          logForDebugging(
164            `Plugin agent file ${filePath} sets ${field}, which is ignored for plugin agents. Use .claude/agents/ for this level of control.`,
165            { level: 'warn' },
166          )
167        }
168      }
169  
170      // Parse maxTurns
171      const maxTurnsRaw = frontmatter.maxTurns
172      const maxTurns = parsePositiveIntFromFrontmatter(maxTurnsRaw)
173      if (maxTurnsRaw !== undefined && maxTurns === undefined) {
174        logForDebugging(
175          `Plugin agent file ${filePath} has invalid maxTurns '${maxTurnsRaw}'. Must be a positive integer.`,
176        )
177      }
178  
179      // Parse disallowedTools
180      const disallowedTools =
181        frontmatter.disallowedTools !== undefined
182          ? parseAgentToolsFromFrontmatter(frontmatter.disallowedTools)
183          : undefined
184  
185      // If memory is enabled, inject Write/Edit/Read tools for memory access
186      if (isAutoMemoryEnabled() && memory && tools !== undefined) {
187        const toolSet = new Set(tools)
188        for (const tool of [
189          FILE_WRITE_TOOL_NAME,
190          FILE_EDIT_TOOL_NAME,
191          FILE_READ_TOOL_NAME,
192        ]) {
193          if (!toolSet.has(tool)) {
194            tools = [...tools, tool]
195          }
196        }
197      }
198  
199      return {
200        agentType,
201        whenToUse,
202        tools,
203        ...(disallowedTools !== undefined ? { disallowedTools } : {}),
204        ...(skills !== undefined ? { skills } : {}),
205        getSystemPrompt: () => {
206          if (isAutoMemoryEnabled() && memory) {
207            const memoryPrompt = loadAgentMemoryPrompt(agentType, memory)
208            return systemPrompt + '\n\n' + memoryPrompt
209          }
210          return systemPrompt
211        },
212        source: 'plugin' as const,
213        color,
214        model,
215        filename: baseAgentName,
216        plugin: sourceName,
217        ...(background ? { background } : {}),
218        ...(memory ? { memory } : {}),
219        ...(isolation ? { isolation } : {}),
220        ...(effort !== undefined ? { effort } : {}),
221        ...(maxTurns !== undefined ? { maxTurns } : {}),
222      } as AgentDefinition
223    } catch (error) {
224      logForDebugging(`Failed to load agent from ${filePath}: ${error}`, {
225        level: 'error',
226      })
227      return null
228    }
229  }
230  
231  export const loadPluginAgents = memoize(
232    async (): Promise<AgentDefinition[]> => {
233      // Only load agents from enabled plugins
234      const { enabled, errors } = await loadAllPluginsCacheOnly()
235  
236      if (errors.length > 0) {
237        logForDebugging(
238          `Plugin loading errors: ${errors.map(e => getPluginErrorMessage(e)).join(', ')}`,
239        )
240      }
241  
242      // Process plugins in parallel; each plugin has its own loadedPaths scope
243      const perPluginAgents = await Promise.all(
244        enabled.map(async (plugin): Promise<AgentDefinition[]> => {
245          // Track loaded file paths to prevent duplicates within this plugin
246          const loadedPaths = new Set<string>()
247          const pluginAgents: AgentDefinition[] = []
248  
249          // Load agents from default agents directory
250          if (plugin.agentsPath) {
251            try {
252              const agents = await loadAgentsFromDirectory(
253                plugin.agentsPath,
254                plugin.name,
255                plugin.source,
256                plugin.path,
257                plugin.manifest,
258                loadedPaths,
259              )
260              pluginAgents.push(...agents)
261  
262              if (agents.length > 0) {
263                logForDebugging(
264                  `Loaded ${agents.length} agents from plugin ${plugin.name} default directory`,
265                )
266              }
267            } catch (error) {
268              logForDebugging(
269                `Failed to load agents from plugin ${plugin.name} default directory: ${error}`,
270                { level: 'error' },
271              )
272            }
273          }
274  
275          // Load agents from additional paths specified in manifest
276          if (plugin.agentsPaths) {
277            // Process all agentsPaths in parallel. isDuplicatePath is synchronous
278            // (check-and-add), so concurrent access to loadedPaths is safe.
279            const pathResults = await Promise.all(
280              plugin.agentsPaths.map(
281                async (agentPath): Promise<AgentDefinition[]> => {
282                  try {
283                    const fs = getFsImplementation()
284                    const stats = await fs.stat(agentPath)
285  
286                    if (stats.isDirectory()) {
287                      // Load all .md files from directory
288                      const agents = await loadAgentsFromDirectory(
289                        agentPath,
290                        plugin.name,
291                        plugin.source,
292                        plugin.path,
293                        plugin.manifest,
294                        loadedPaths,
295                      )
296  
297                      if (agents.length > 0) {
298                        logForDebugging(
299                          `Loaded ${agents.length} agents from plugin ${plugin.name} custom path: ${agentPath}`,
300                        )
301                      }
302                      return agents
303                    } else if (stats.isFile() && agentPath.endsWith('.md')) {
304                      // Load single agent file
305                      const agent = await loadAgentFromFile(
306                        agentPath,
307                        plugin.name,
308                        [],
309                        plugin.source,
310                        plugin.path,
311                        plugin.manifest,
312                        loadedPaths,
313                      )
314                      if (agent) {
315                        logForDebugging(
316                          `Loaded agent from plugin ${plugin.name} custom file: ${agentPath}`,
317                        )
318                        return [agent]
319                      }
320                    }
321                    return []
322                  } catch (error) {
323                    logForDebugging(
324                      `Failed to load agents from plugin ${plugin.name} custom path ${agentPath}: ${error}`,
325                      { level: 'error' },
326                    )
327                    return []
328                  }
329                },
330              ),
331            )
332            for (const agents of pathResults) {
333              pluginAgents.push(...agents)
334            }
335          }
336          return pluginAgents
337        }),
338      )
339  
340      const allAgents = perPluginAgents.flat()
341      logForDebugging(`Total plugin agents loaded: ${allAgents.length}`)
342      return allAgents
343    },
344  )
345  
346  export function clearPluginAgentCache(): void {
347    loadPluginAgents.cache?.clear?.()
348  }