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:
parent
9cdff19cb7
commit
02ffda9d4c
|
|
@ -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<FavoriteItemWithStatus[] | null>(null);
|
||||
const previousSlugs = useRef<Set<string>>(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)));
|
||||
|
|
|
|||
|
|
@ -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<FavoriteState> {
|
||||
try {
|
||||
const text = await readFile(favoriteStatePath(), 'utf-8')
|
||||
|
|
@ -288,6 +297,16 @@ function deriveStatus(
|
|||
activeMcpNames: Set<string>,
|
||||
): 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<void> {
|
||||
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)))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user