From 02ffda9d4c422bded5324e9f7289c0b813e7fd96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=87=AF90331?= <90331@sangfor.com> Date: Mon, 18 May 2026 17:38:10 +0800 Subject: [PATCH] fix: cloud favorite status priority and runtime lifecycle - deriveStatus: prioritize favorite lifecycle over local config presence - autoEnableCloudFavorites: skip unloaded items to prevent re-loading - load/unload: sync agent/command favorites to ~/.claude/{agents,commands}/ - /hub toggle: trigger MCP reconnection and agent definition refresh - cloud-enabled menu: trigger load for newly checked items Co-Authored-By: Claude Opus 4.7 --- src/commands/knowledge-hub/cloud-enabled.tsx | 36 ++++++++++++- src/costrict/favorite/favorite.ts | 56 ++++++++++++++++---- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/commands/knowledge-hub/cloud-enabled.tsx b/src/commands/knowledge-hub/cloud-enabled.tsx index 7a5d3e177..30333cb8f 100644 --- a/src/commands/knowledge-hub/cloud-enabled.tsx +++ b/src/commands/knowledge-hub/cloud-enabled.tsx @@ -7,6 +7,8 @@ import { unloadFavoriteItem, type FavoriteItemWithStatus, } from '../../costrict/favorite/favorite.js'; +import { useSetAppState } from '../../state/AppState.js'; +import { getOriginalCwd } from '../../bootstrap/state.js'; import type { LocalJSXCommandCall } from '../../types/command.js'; function CloudEnabledMenu({ @@ -14,6 +16,7 @@ function CloudEnabledMenu({ }: { onDone: (result?: string, options?: { display?: 'skip' | 'system' | 'user' }) => void; }): React.ReactNode { + const setAppState = useSetAppState(); const [items, setItems] = useState(null); const previousSlugs = useRef>(new Set()); const isFirstChange = useRef(true); @@ -27,7 +30,7 @@ function CloudEnabledMenu({ onDone('No cloud favorites found', { display: 'system' }); } else { setItems(data); - previousSlugs.current = new Set(data.map(item => item.slug)); + previousSlugs.current = new Set(data.filter(item => item.status === 'Active').map(item => item.slug)); } }) .catch((error: unknown) => { @@ -75,11 +78,42 @@ function CloudEnabledMenu({ if (toLoad.length === 0 && toUnload.length === 0) return; + const hasMcpChanges = [...toLoad, ...toUnload].some(item => item.itemType === 'mcp'); + const hasAgentChanges = [...toLoad, ...toUnload].some(item => item.itemType === 'agent'); + const results = await Promise.allSettled([ ...toLoad.map(item => loadFavoriteItem(item.slug)), ...toUnload.map(item => unloadFavoriteItem(item.slug)), ]); + // Trigger MCP reconnection if any MCP servers were added or removed + if (hasMcpChanges) { + setAppState(prev => ({ + ...prev, + mcp: { + ...prev.mcp, + pluginReconnectKey: prev.mcp.pluginReconnectKey + 1, + }, + })); + } + + // Refresh agent definitions if any agents were added or removed + if (hasAgentChanges) { + const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = await import( + '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' + ); + getAgentDefinitionsWithOverrides.cache?.clear?.(); + const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd()); + setAppState(prev => ({ + ...prev, + agentDefinitions: { + ...freshAgentDefs, + allAgents: freshAgentDefs.allAgents, + activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents), + }, + })); + } + const failures = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[]; if (failures.length > 0) { const reasons = failures.map(r => (r.reason instanceof Error ? r.reason.message : String(r.reason))); diff --git a/src/costrict/favorite/favorite.ts b/src/costrict/favorite/favorite.ts index f27a9e9df..67b149b70 100644 --- a/src/costrict/favorite/favorite.ts +++ b/src/costrict/favorite/favorite.ts @@ -16,6 +16,7 @@ import { skillChangeDetector } from '../../utils/skills/skillChangeDetector.js' import { parseFrontmatter } from '../../utils/frontmatterParser.js' import { logForDebugging } from '../../utils/debug.js' import type { McpServerConfig } from '../../services/mcp/types.js' +import { clearAgentDefinitionsCache } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' const FAVORITE_PAGE_SIZE = 20 const FAVORITE_MAX_PAGES = 20 @@ -112,6 +113,14 @@ function skillsDir() { return path.join(getClaudeConfigHomeDir(), 'skills') } +function agentsDir() { + return path.join(getClaudeConfigHomeDir(), 'agents') +} + +function commandsDir() { + return path.join(getClaudeConfigHomeDir(), 'commands') +} + async function readState(): Promise { try { const text = await readFile(favoriteStatePath(), 'utf-8') @@ -288,6 +297,16 @@ function deriveStatus( activeMcpNames: Set, ): FavoriteStatus { if (!state) return 'Cloud' + + // Prioritize favorite lifecycle over local config presence + switch (state.lifecycle) { + case 'active': + return 'Active' + case 'unloaded': + return 'Unloaded' + } + + // For 'downloaded' state, fallback to checking local config switch (state.itemType) { case 'skill': if (activeSkillSlugs.has(state.slug)) return 'Active' @@ -302,12 +321,8 @@ function deriveStatus( if (activeMcpNames.has(state.slug)) return 'Active' break } - switch (state.lifecycle) { - case 'unloaded': - return 'Unloaded' - default: - return 'Downloaded' - } + + return 'Downloaded' } async function persistInstalledItem(item: FavoriteItem) { @@ -528,6 +543,7 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) { content, mdPath, ) + // Persist to global config for state tracking saveGlobalConfig(cfg => ({ ...cfg, agents: { @@ -538,6 +554,11 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) { }, }, })) + // Also copy to ~/.claude/agents/ so the file-based loader picks it up + const agentDestDir = agentsDir() + await mkdir(agentDestDir, { recursive: true }) + await copyFile(mdPath, path.join(agentDestDir, `${item.slug}.md`)) + clearAgentDefinitionsCache() break } case 'command': { @@ -547,6 +568,7 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) { content, mdPath, ) + // Persist to global config for state tracking saveGlobalConfig(cfg => ({ ...cfg, commands: { @@ -557,6 +579,12 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) { }, }, })) + // Also copy to ~/.claude/commands/ so the file-based loader picks it up + const commandDestDir = commandsDir() + await mkdir(commandDestDir, { recursive: true }) + await copyFile(mdPath, path.join(commandDestDir, `${item.slug}.md`)) + clearCommandsCache() + skillChangeDetector.notify() break } case 'mcp': { @@ -603,6 +631,9 @@ async function removeItemFromConfig( delete agents[slug] return { ...cfg, agents } }) + // Also remove from ~/.claude/agents/ so the file-based loader stops serving it + await rm(path.join(agentsDir(), `${slug}.md`), { force: true }) + clearAgentDefinitionsCache() break } case 'command': { @@ -611,6 +642,10 @@ async function removeItemFromConfig( delete commands[slug] return { ...cfg, commands } }) + // Also remove from ~/.claude/commands/ so the file-based loader stops serving it + await rm(path.join(commandsDir(), `${slug}.md`), { force: true }) + clearCommandsCache() + skillChangeDetector.notify() break } case 'mcp': { @@ -799,13 +834,16 @@ export async function unloadFavoriteItem(slugOrId: string) { } /** - * Auto-enable all cloud favorite items that are not already active. - * Runs in the background so slow network or many items never blocks startup. + * Auto-enable cloud favorite items that are neither active nor explicitly + * unloaded. Items the user previously unloaded should stay disabled across + * restarts; only newly-downloaded or never-configured items are activated. */ export async function autoEnableCloudFavorites(): Promise { try { const items = await listFavoriteItems() - const toEnable = items.filter(item => item.status !== 'Active') + const toEnable = items.filter( + item => item.status !== 'Active' && item.status !== 'Unloaded', + ) if (toEnable.length === 0) return await Promise.allSettled(toEnable.map(item => loadFavoriteItem(item.slug)))