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 <noreply@anthropic.com>
This commit is contained in:
林凯90331 2026-05-18 17:38:10 +08:00
parent 9cdff19cb7
commit 02ffda9d4c
2 changed files with 82 additions and 10 deletions

View File

@ -7,6 +7,8 @@ import {
unloadFavoriteItem, unloadFavoriteItem,
type FavoriteItemWithStatus, type FavoriteItemWithStatus,
} from '../../costrict/favorite/favorite.js'; } from '../../costrict/favorite/favorite.js';
import { useSetAppState } from '../../state/AppState.js';
import { getOriginalCwd } from '../../bootstrap/state.js';
import type { LocalJSXCommandCall } from '../../types/command.js'; import type { LocalJSXCommandCall } from '../../types/command.js';
function CloudEnabledMenu({ function CloudEnabledMenu({
@ -14,6 +16,7 @@ function CloudEnabledMenu({
}: { }: {
onDone: (result?: string, options?: { display?: 'skip' | 'system' | 'user' }) => void; onDone: (result?: string, options?: { display?: 'skip' | 'system' | 'user' }) => void;
}): React.ReactNode { }): React.ReactNode {
const setAppState = useSetAppState();
const [items, setItems] = useState<FavoriteItemWithStatus[] | null>(null); const [items, setItems] = useState<FavoriteItemWithStatus[] | null>(null);
const previousSlugs = useRef<Set<string>>(new Set()); const previousSlugs = useRef<Set<string>>(new Set());
const isFirstChange = useRef(true); const isFirstChange = useRef(true);
@ -27,7 +30,7 @@ function CloudEnabledMenu({
onDone('No cloud favorites found', { display: 'system' }); onDone('No cloud favorites found', { display: 'system' });
} else { } else {
setItems(data); 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) => { .catch((error: unknown) => {
@ -75,11 +78,42 @@ function CloudEnabledMenu({
if (toLoad.length === 0 && toUnload.length === 0) return; 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([ const results = await Promise.allSettled([
...toLoad.map(item => loadFavoriteItem(item.slug)), ...toLoad.map(item => loadFavoriteItem(item.slug)),
...toUnload.map(item => unloadFavoriteItem(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[]; const failures = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[];
if (failures.length > 0) { if (failures.length > 0) {
const reasons = failures.map(r => (r.reason instanceof Error ? r.reason.message : String(r.reason))); const reasons = failures.map(r => (r.reason instanceof Error ? r.reason.message : String(r.reason)));

View File

@ -16,6 +16,7 @@ import { skillChangeDetector } from '../../utils/skills/skillChangeDetector.js'
import { parseFrontmatter } from '../../utils/frontmatterParser.js' import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { logForDebugging } from '../../utils/debug.js' import { logForDebugging } from '../../utils/debug.js'
import type { McpServerConfig } from '../../services/mcp/types.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_PAGE_SIZE = 20
const FAVORITE_MAX_PAGES = 20 const FAVORITE_MAX_PAGES = 20
@ -112,6 +113,14 @@ function skillsDir() {
return path.join(getClaudeConfigHomeDir(), 'skills') return path.join(getClaudeConfigHomeDir(), 'skills')
} }
function agentsDir() {
return path.join(getClaudeConfigHomeDir(), 'agents')
}
function commandsDir() {
return path.join(getClaudeConfigHomeDir(), 'commands')
}
async function readState(): Promise<FavoriteState> { async function readState(): Promise<FavoriteState> {
try { try {
const text = await readFile(favoriteStatePath(), 'utf-8') const text = await readFile(favoriteStatePath(), 'utf-8')
@ -288,6 +297,16 @@ function deriveStatus(
activeMcpNames: Set<string>, activeMcpNames: Set<string>,
): FavoriteStatus { ): FavoriteStatus {
if (!state) return 'Cloud' 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) { switch (state.itemType) {
case 'skill': case 'skill':
if (activeSkillSlugs.has(state.slug)) return 'Active' if (activeSkillSlugs.has(state.slug)) return 'Active'
@ -302,12 +321,8 @@ function deriveStatus(
if (activeMcpNames.has(state.slug)) return 'Active' if (activeMcpNames.has(state.slug)) return 'Active'
break break
} }
switch (state.lifecycle) {
case 'unloaded': return 'Downloaded'
return 'Unloaded'
default:
return 'Downloaded'
}
} }
async function persistInstalledItem(item: FavoriteItem) { async function persistInstalledItem(item: FavoriteItem) {
@ -528,6 +543,7 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
content, content,
mdPath, mdPath,
) )
// Persist to global config for state tracking
saveGlobalConfig(cfg => ({ saveGlobalConfig(cfg => ({
...cfg, ...cfg,
agents: { 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 break
} }
case 'command': { case 'command': {
@ -547,6 +568,7 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
content, content,
mdPath, mdPath,
) )
// Persist to global config for state tracking
saveGlobalConfig(cfg => ({ saveGlobalConfig(cfg => ({
...cfg, ...cfg,
commands: { 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 break
} }
case 'mcp': { case 'mcp': {
@ -603,6 +631,9 @@ async function removeItemFromConfig(
delete agents[slug] delete agents[slug]
return { ...cfg, agents } 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 break
} }
case 'command': { case 'command': {
@ -611,6 +642,10 @@ async function removeItemFromConfig(
delete commands[slug] delete commands[slug]
return { ...cfg, commands } 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 break
} }
case 'mcp': { case 'mcp': {
@ -799,13 +834,16 @@ export async function unloadFavoriteItem(slugOrId: string) {
} }
/** /**
* Auto-enable all cloud favorite items that are not already active. * Auto-enable cloud favorite items that are neither active nor explicitly
* Runs in the background so slow network or many items never blocks startup. * 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<void> { export async function autoEnableCloudFavorites(): Promise<void> {
try { try {
const items = await listFavoriteItems() 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 if (toEnable.length === 0) return
await Promise.allSettled(toEnable.map(item => loadFavoriteItem(item.slug))) await Promise.allSettled(toEnable.map(item => loadFavoriteItem(item.slug)))