/ src / hooks / useManagePlugins.ts
useManagePlugins.ts
  1  import { useCallback, useEffect } from 'react'
  2  import type { Command } from '../commands.js'
  3  import { useNotifications } from '../context/notifications.js'
  4  import {
  5    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  6    logEvent,
  7  } from '../services/analytics/index.js'
  8  import { reinitializeLspServerManager } from '../services/lsp/manager.js'
  9  import { useAppState, useSetAppState } from '../state/AppState.js'
 10  import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
 11  import { count } from '../utils/array.js'
 12  import { logForDebugging } from '../utils/debug.js'
 13  import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
 14  import { toError } from '../utils/errors.js'
 15  import { logError } from '../utils/log.js'
 16  import { loadPluginAgents } from '../utils/plugins/loadPluginAgents.js'
 17  import { getPluginCommands } from '../utils/plugins/loadPluginCommands.js'
 18  import { loadPluginHooks } from '../utils/plugins/loadPluginHooks.js'
 19  import { loadPluginLspServers } from '../utils/plugins/lspPluginIntegration.js'
 20  import { loadPluginMcpServers } from '../utils/plugins/mcpPluginIntegration.js'
 21  import { detectAndUninstallDelistedPlugins } from '../utils/plugins/pluginBlocklist.js'
 22  import { getFlaggedPlugins } from '../utils/plugins/pluginFlagging.js'
 23  import { loadAllPlugins } from '../utils/plugins/pluginLoader.js'
 24  
 25  /**
 26   * Hook to manage plugin state and synchronize with AppState.
 27   *
 28   * On mount: loads all plugins, runs delisting enforcement, surfaces flagged-
 29   * plugin notifications, populates AppState.plugins. This is the initial
 30   * Layer-3 load — subsequent refresh goes through /reload-plugins.
 31   *
 32   * On needsRefresh: shows a notification directing the user to /reload-plugins.
 33   * Does NOT auto-refresh. All Layer-3 swap (commands, agents, hooks, MCP)
 34   * goes through refreshActivePlugins() via /reload-plugins for one consistent
 35   * mental model. See Outline: declarative-settings-hXHBMDIf4b PR 5c.
 36   */
 37  export function useManagePlugins({
 38    enabled = true,
 39  }: {
 40    enabled?: boolean
 41  } = {}) {
 42    const setAppState = useSetAppState()
 43    const needsRefresh = useAppState(s => s.plugins.needsRefresh)
 44    const { addNotification } = useNotifications()
 45  
 46    // Initial plugin load. Runs once on mount. NOT used for refresh — all
 47    // post-mount refresh goes through /reload-plugins → refreshActivePlugins().
 48    // Unlike refreshActivePlugins, this also runs delisting enforcement and
 49    // flagged-plugin notifications (session-start concerns), and does NOT bump
 50    // mcp.pluginReconnectKey (MCP effects fire on their own mount).
 51    const initialPluginLoad = useCallback(async () => {
 52      try {
 53        // Load all plugins - capture errors array
 54        const { enabled, disabled, errors } = await loadAllPlugins()
 55  
 56        // Detect delisted plugins, auto-uninstall them, and record as flagged.
 57        await detectAndUninstallDelistedPlugins()
 58  
 59        // Notify if there are flagged plugins pending dismissal
 60        const flagged = getFlaggedPlugins()
 61        if (Object.keys(flagged).length > 0) {
 62          addNotification({
 63            key: 'plugin-delisted-flagged',
 64            text: 'Plugins flagged. Check /plugins',
 65            color: 'warning',
 66            priority: 'high',
 67          })
 68        }
 69  
 70        // Load commands, agents, and hooks with individual error handling
 71        // Errors are added to the errors array for user visibility in Doctor UI
 72        let commands: Command[] = []
 73        let agents: AgentDefinition[] = []
 74  
 75        try {
 76          commands = await getPluginCommands()
 77        } catch (error) {
 78          const errorMessage =
 79            error instanceof Error ? error.message : String(error)
 80          errors.push({
 81            type: 'generic-error',
 82            source: 'plugin-commands',
 83            error: `Failed to load plugin commands: ${errorMessage}`,
 84          })
 85        }
 86  
 87        try {
 88          agents = await loadPluginAgents()
 89        } catch (error) {
 90          const errorMessage =
 91            error instanceof Error ? error.message : String(error)
 92          errors.push({
 93            type: 'generic-error',
 94            source: 'plugin-agents',
 95            error: `Failed to load plugin agents: ${errorMessage}`,
 96          })
 97        }
 98  
 99        try {
100          await loadPluginHooks()
101        } catch (error) {
102          const errorMessage =
103            error instanceof Error ? error.message : String(error)
104          errors.push({
105            type: 'generic-error',
106            source: 'plugin-hooks',
107            error: `Failed to load plugin hooks: ${errorMessage}`,
108          })
109        }
110  
111        // Load MCP server configs per plugin to get an accurate count.
112        // LoadedPlugin.mcpServers is not populated by loadAllPlugins — it's a
113        // cache slot that extractMcpServersFromPlugins fills later, which races
114        // with this metric. Calling loadPluginMcpServers directly (as
115        // cli/handlers/plugins.ts does) gives the correct count and also
116        // warms the cache for the MCP connection manager.
117        //
118        // Runs BEFORE setAppState so any errors pushed by these loaders make it
119        // into AppState.plugins.errors (Doctor UI), not just telemetry.
120        const mcpServerCounts = await Promise.all(
121          enabled.map(async p => {
122            if (p.mcpServers) return Object.keys(p.mcpServers).length
123            const servers = await loadPluginMcpServers(p, errors)
124            if (servers) p.mcpServers = servers
125            return servers ? Object.keys(servers).length : 0
126          }),
127        )
128        const mcp_count = mcpServerCounts.reduce((sum, n) => sum + n, 0)
129  
130        // LSP: the primary fix for issue #15521 is in refresh.ts (via
131        // performBackgroundPluginInstallations → refreshActivePlugins, which
132        // clears caches first). This reinit is defensive — it reads the same
133        // memoized loadAllPlugins() result as the original init unless a cache
134        // invalidation happened between main.tsx:3203 and REPL mount (e.g.
135        // seed marketplace registration or policySettings hot-reload).
136        const lspServerCounts = await Promise.all(
137          enabled.map(async p => {
138            if (p.lspServers) return Object.keys(p.lspServers).length
139            const servers = await loadPluginLspServers(p, errors)
140            if (servers) p.lspServers = servers
141            return servers ? Object.keys(servers).length : 0
142          }),
143        )
144        const lsp_count = lspServerCounts.reduce((sum, n) => sum + n, 0)
145        reinitializeLspServerManager()
146  
147        // Update AppState - merge errors to preserve LSP errors
148        setAppState(prevState => {
149          // Keep existing LSP/non-plugin-loading errors (source 'lsp-manager' or 'plugin:*')
150          const existingLspErrors = prevState.plugins.errors.filter(
151            e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'),
152          )
153          // Deduplicate: remove existing LSP errors that are also in new errors
154          const newErrorKeys = new Set(
155            errors.map(e =>
156              e.type === 'generic-error'
157                ? `generic-error:${e.source}:${e.error}`
158                : `${e.type}:${e.source}`,
159            ),
160          )
161          const filteredExisting = existingLspErrors.filter(e => {
162            const key =
163              e.type === 'generic-error'
164                ? `generic-error:${e.source}:${e.error}`
165                : `${e.type}:${e.source}`
166            return !newErrorKeys.has(key)
167          })
168          const mergedErrors = [...filteredExisting, ...errors]
169  
170          return {
171            ...prevState,
172            plugins: {
173              ...prevState.plugins,
174              enabled,
175              disabled,
176              commands,
177              errors: mergedErrors,
178            },
179          }
180        })
181  
182        logForDebugging(
183          `Loaded plugins - Enabled: ${enabled.length}, Disabled: ${disabled.length}, Commands: ${commands.length}, Agents: ${agents.length}, Errors: ${errors.length}`,
184        )
185  
186        // Count component types across enabled plugins
187        const hook_count = enabled.reduce((sum, p) => {
188          if (!p.hooksConfig) return sum
189          return (
190            sum +
191            Object.values(p.hooksConfig).reduce(
192              (s, matchers) =>
193                s + (matchers?.reduce((h, m) => h + m.hooks.length, 0) ?? 0),
194              0,
195            )
196          )
197        }, 0)
198  
199        return {
200          enabled_count: enabled.length,
201          disabled_count: disabled.length,
202          inline_count: count(enabled, p => p.source.endsWith('@inline')),
203          marketplace_count: count(enabled, p => !p.source.endsWith('@inline')),
204          error_count: errors.length,
205          skill_count: commands.length,
206          agent_count: agents.length,
207          hook_count,
208          mcp_count,
209          lsp_count,
210          // Ant-only: which plugins are enabled, to correlate with RSS/FPS.
211          // Kept separate from base metrics so it doesn't flow into
212          // logForDiagnosticsNoPII.
213          ant_enabled_names:
214            process.env.USER_TYPE === 'ant' && enabled.length > 0
215              ? (enabled
216                  .map(p => p.name)
217                  .sort()
218                  .join(
219                    ',',
220                  ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
221              : undefined,
222        }
223      } catch (error) {
224        // Only plugin loading errors should reach here - log for monitoring
225        const errorObj = toError(error)
226        logError(errorObj)
227        logForDebugging(`Error loading plugins: ${error}`)
228        // Set empty state on error, but preserve LSP errors and add the new error
229        setAppState(prevState => {
230          // Keep existing LSP/non-plugin-loading errors
231          const existingLspErrors = prevState.plugins.errors.filter(
232            e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'),
233          )
234          const newError = {
235            type: 'generic-error' as const,
236            source: 'plugin-system',
237            error: errorObj.message,
238          }
239          return {
240            ...prevState,
241            plugins: {
242              ...prevState.plugins,
243              enabled: [],
244              disabled: [],
245              commands: [],
246              errors: [...existingLspErrors, newError],
247            },
248          }
249        })
250  
251        return {
252          enabled_count: 0,
253          disabled_count: 0,
254          inline_count: 0,
255          marketplace_count: 0,
256          error_count: 1,
257          skill_count: 0,
258          agent_count: 0,
259          hook_count: 0,
260          mcp_count: 0,
261          lsp_count: 0,
262          load_failed: true,
263          ant_enabled_names: undefined,
264        }
265      }
266    }, [setAppState, addNotification])
267  
268    // Load plugins on mount and emit telemetry
269    useEffect(() => {
270      if (!enabled) return
271      void initialPluginLoad().then(metrics => {
272        const { ant_enabled_names, ...baseMetrics } = metrics
273        const allMetrics = {
274          ...baseMetrics,
275          has_custom_plugin_cache_dir: !!process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR,
276        }
277        logEvent('tengu_plugins_loaded', {
278          ...allMetrics,
279          ...(ant_enabled_names !== undefined && {
280            enabled_names: ant_enabled_names,
281          }),
282        })
283        logForDiagnosticsNoPII('info', 'tengu_plugins_loaded', allMetrics)
284      })
285    }, [initialPluginLoad, enabled])
286  
287    // Plugin state changed on disk (background reconcile, /plugin menu,
288    // external settings edit). Show a notification; user runs /reload-plugins
289    // to apply. The previous auto-refresh here had a stale-cache bug (only
290    // cleared loadAllPlugins, downstream memoized loaders returned old data)
291    // and was incomplete (no MCP, no agentDefinitions). /reload-plugins
292    // handles all of that correctly via refreshActivePlugins().
293    useEffect(() => {
294      if (!enabled || !needsRefresh) return
295      addNotification({
296        key: 'plugin-reload-pending',
297        text: 'Plugins changed. Run /reload-plugins to activate.',
298        color: 'suggestion',
299        priority: 'low',
300      })
301      // Do NOT auto-refresh. Do NOT reset needsRefresh — /reload-plugins
302      // consumes it via refreshActivePlugins().
303    }, [enabled, needsRefresh, addNotification])
304  }