Files
openchamber/packages/ui/src/stores/useSkillsStore.ts
T
Bohdan Triapitsyn f0ced9d361 fix: reject instance-served responses that outlive their instance
MCP status and skills were cleared on a switch but their in-flight requests
were not, so a response for the previous instance could still write itself over
the new one's — the same race the quota store already guards. Both now carry a
generation.

Usage also claimed an instance as loaded before it had answered, so a load that
failed on a cold or briefly unreachable instance was never attempted again, and
the previous instance's display mode and provider selection survived a switch —
which decided what the new instance was even asked for.
2026-09-03 11:49:34 +03:00

816 lines
29 KiB
TypeScript

import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import {
startConfigUpdate,
finishConfigUpdate,
updateConfigUpdateMessage,
} from "@/lib/configUpdate";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { runtimeFetch } from "@/lib/runtime-fetch";
import { runBackgroundNetworkTask } from "@/lib/background-network";
import { noteDeferredRestartFromPayload } from "@/lib/opencode/deferredRestart";
import { useProjectsStore } from "@/stores/useProjectsStore";
import { opencodeClient } from '@/lib/opencode/client';
import { filterSkillsByRuntimeFlags } from './skillVisibility';
// Prefer the active project path so Settings/Skills discovery matches the
// project selector (and Commands/Agents). Falling back only to the session
// directory misses repository-local `.agents/skills` when the client directory
// is unset or points elsewhere while an active project exists.
/**
* Directory a call operates on. Settings can browse another project without
* moving the app, so every entry point takes one; omitting it means the project
* the app is currently on.
*/
const resolveDirectory = (directory?: string | null): string | null => {
if (directory !== undefined) {
const trimmed = directory?.trim();
return trimmed ? trimmed : null;
}
return getRequestDirectory();
};
const getRequestDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
if (activeProject?.path?.trim()) {
return activeProject.path.trim();
}
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) {
return clientDir.trim();
}
} catch (err) {
console.warn('[SkillsStore] Error resolving config directory:', err);
}
return null;
};
export type SkillScope = 'user' | 'project';
export type SkillSource = 'opencode' | 'claude' | 'agents';
export interface SupportingFile {
name: string;
path: string;
fullPath: string;
}
interface SkillSources {
md: {
exists: boolean;
path: string | null;
dir: string | null;
fields: string[];
scope?: SkillScope | null;
source?: SkillSource | null;
supportingFiles: SupportingFile[];
// Actual content values
name?: string;
description?: string;
instructions?: string;
};
projectMd?: { exists: boolean; path: string | null };
claudeMd?: { exists: boolean; path: string | null };
userMd?: { exists: boolean; path: string | null };
userClaudeMd?: { exists: boolean; path: string | null };
userAgentsMd?: { exists: boolean; path: string | null };
}
export interface DiscoveredSkill {
name: string;
path: string;
scope: SkillScope;
source: SkillSource;
description?: string;
/** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */
group?: string;
/** Authoritative server flag: skill lives under a managed root and can be renamed in place. */
renamable?: boolean;
}
/** Parse the domain group folder from a skill file path.
* e.g. "~/.config/opencode/skills/automation-ai/ai-production/SKILL.md" → "automation-ai"
* e.g. "~/.config/opencode/skills/theme-system/SKILL.md" → undefined (flat)
*/
function parseSkillGroup(path: string): string | undefined {
const normalizedPath = path.replace(/\\/g, '/');
const idx = normalizedPath.lastIndexOf('/skills/');
if (idx === -1) return undefined;
const relative = normalizedPath.substring(idx + '/skills/'.length);
const parts = relative.split('/');
// Grouped layout: <group>/<name>/SKILL.md → parts.length >= 3
// Flat layout: <name>/SKILL.md → parts.length == 2
return parts.length >= 3 ? parts[0] : undefined;
}
// Raw skill response from API before transformation
interface RawSkillResponse {
name: string;
path: string;
scope?: SkillScope;
source?: SkillSource;
renamable?: boolean;
sources?: {
md?: {
description?: string;
};
};
}
export interface SkillConfig {
name: string;
description: string;
instructions?: string;
scope?: SkillScope;
source?: SkillSource;
targetPath?: string;
supportingFiles?: Array<{ path: string; content: string }>;
}
export interface PendingFile {
path: string;
content: string;
}
export interface SkillDraft {
name: string;
scope: SkillScope;
source?: SkillSource;
description: string;
instructions?: string;
pendingFiles?: PendingFile[];
}
interface SkillDetail {
name: string;
sources: SkillSources;
scope?: SkillScope | null;
source?: SkillSource | null;
}
interface SkillsStore {
selectedSkillName: string | null;
/** Skills of the project the app is on. Chat and autocompletes read this one. */
skills: DiscoveredSkill[];
/** Every directory loaded so far, including the ambient one. */
skillsByDirectory: Record<string, DiscoveredSkill[]>;
isLoading: boolean;
skillDraft: SkillDraft | null;
setSelectedSkill: (name: string | null) => void;
setSkillDraft: (draft: SkillDraft | null) => void;
loadSkills: (directory?: string | null) => Promise<boolean>;
getSkillDetail: (name: string, directory?: string | null) => Promise<SkillDetail | null>;
createSkill: (config: SkillConfig, directory?: string | null) => Promise<boolean>;
updateSkill: (name: string, config: Partial<SkillConfig>, directory?: string | null) => Promise<boolean>;
renameSkill: (name: string, newName: string, directory?: string | null) => Promise<boolean>;
deleteSkill: (name: string, directory?: string | null) => Promise<boolean>;
getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined;
/**
* Skills are discovered on the connected instance and cached by directory,
* which two instances can share — so a switch must drop the caches rather
* than report the previous instance's skills for the new one.
*/
resetForRuntimeSwitch: () => void;
// Supporting files
readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<string | null>;
writeSupportingFile: (skillName: string, filePath: string, content: string, directory?: string | null) => Promise<boolean>;
deleteSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<boolean>;
}
declare global {
interface Window {
__zustand_skills_store__?: UseBoundStore<StoreApi<SkillsStore>>;
}
}
const CONFIG_EVENT_SOURCE = "useSkillsStore";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const SKILLS_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
const skillsLastLoadedAt = new Map<string, number>();
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
// Bumped on every runtime switch. Skills are discovered on the connected
// instance and cached by directory, which two instances can share, so a load
// already in flight for the previous instance must not write into the new one.
let skillsGeneration = 0;
const getSkillsCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
};
export const invalidateSkillsLoadCache = (directory: string | null = getRequestDirectory()) => {
skillsLastLoadedAt.delete(getSkillsCacheKey(directory));
};
const upsertSkillLocal = (
set: (updater: (state: SkillsStore) => Partial<SkillsStore>) => void,
get: () => SkillsStore,
name: string,
config: Partial<SkillConfig>,
directory: string | null,
) => {
const cacheKey = getSkillsCacheKey(directory);
const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory());
const current = get().skillsByDirectory[cacheKey] ?? [];
const existing = current.find((skill) => skill.name === name);
const path = config.targetPath ?? existing?.path ?? '';
const nextSkill: DiscoveredSkill = {
...existing,
name,
path,
scope: config.scope ?? existing?.scope ?? 'user',
source: config.source ?? existing?.source ?? 'opencode',
description: config.description ?? existing?.description ?? '',
group: parseSkillGroup(path),
};
const nextSkills = current.some((skill) => skill.name === name)
? current.map((skill) => (skill.name === name ? nextSkill : skill))
: [...current, nextSkill];
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: nextSkills },
};
if (isAmbient) next.skills = nextSkills;
return next;
});
};
const removeSkillLocal = (
set: (state: Partial<SkillsStore>) => void,
get: () => SkillsStore,
name: string,
) => {
const nextState: Partial<SkillsStore> = {
skills: get().skills.filter((skill) => skill.name !== name),
};
if (get().selectedSkillName === name) {
nextState.selectedSkillName = null;
}
set(nextState);
};
const MAX_HEALTH_WAIT_MS = 20000;
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
const FAST_HEALTH_POLL_ATTEMPTS = 4;
const SLOW_HEALTH_POLL_BASE_MS = 800;
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
const SLOW_HEALTH_POLL_MAX_MS = 2000;
const EMPTY_SKILLS: DiscoveredSkill[] = [];
/**
* Skills of one project. Returns a stored array so components can select it
* directly; an omitted directory means the project the app is on.
*/
export const selectSkillsForDirectory = (
state: Pick<SkillsStore, 'skillsByDirectory'>,
directory?: string | null,
): DiscoveredSkill[] => {
const cacheKey = getSkillsCacheKey(resolveDirectory(directory));
return state.skillsByDirectory[cacheKey] ?? EMPTY_SKILLS;
};
export const useSkillsStore = create<SkillsStore>()(
devtools(
persist(
(set, get) => ({
selectedSkillName: null,
skills: [],
skillsByDirectory: {},
isLoading: false,
skillDraft: null,
resetForRuntimeSwitch: () => {
skillsGeneration += 1;
skillsLastLoadedAt.clear();
skillsLoadInFlight.clear();
set({ skills: [], skillsByDirectory: {}, isLoading: false });
},
setSelectedSkill: (name: string | null) => {
set({ selectedSkillName: name });
},
setSkillDraft: (draft: SkillDraft | null) => {
set({ skillDraft: draft });
},
loadSkills: async (requestedDirectory?: string | null) => {
const directory = resolveDirectory(requestedDirectory);
const cacheKey = getSkillsCacheKey(directory);
const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory());
const now = Date.now();
const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedSkills = (get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : [])).length > 0;
if (hasCachedSkills && now - loadedAt < SKILLS_LOAD_CACHE_TTL_MS) {
return true;
}
const inFlight = skillsLoadInFlight.get(cacheKey);
if (inFlight) {
return inFlight;
}
const generation = skillsGeneration;
const request = (async () => {
set({ isLoading: true });
// Failure must never look like an empty project. The mirror is the
// fallback so a directory loaded before this map existed still counts.
const previousSkills = get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : []);
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runBackgroundNetworkTask(() => runtimeFetch(`/api/config/skills${queryParams}`, {
priority: 'low',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}));
if (!response.ok) {
throw new Error(`Failed to list skills: ${response.status}`);
}
const data = await response.json();
const rawSkills: RawSkillResponse[] = data.skills || [];
const configSkills: DiscoveredSkill[] = rawSkills.map((s) => ({
name: s.name,
path: s.path,
scope: s.scope ?? 'user',
source: s.source ?? 'opencode',
description: s.sources?.md?.description || '',
group: parseSkillGroup(s.path),
renamable: s.renamable === true,
}));
// OpenCode loads a narrower set than this scan finds, and the
// rules live in server-side env flags the browser cannot read.
// The route reports them; `filterSkillsByRuntimeFlags` mirrors
// OpenCode's discovery, including the `.agents`-wins dedup that
// matters when `.claude/skills` are symlinks back into it.
//
// Deliberately not OpenCode's own skill endpoint: measured
// against 1.18.14 it lists only global and builtin skills and
// omits the project skills the agent actually has.
const visibleSkills = filterSkillsByRuntimeFlags(
configSkills,
data.externalSkills ?? null,
);
if (generation !== skillsGeneration) return false;
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
isLoading: false,
};
if (isAmbient) next.skills = visibleSkills;
return next;
});
skillsLastLoadedAt.set(cacheKey, Date.now());
return true;
} catch (error) {
lastError = error;
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
console.error("Failed to load skills:", lastError);
if (generation !== skillsGeneration) return false;
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
isLoading: false,
};
if (isAmbient) next.skills = previousSkills;
return next;
});
return false;
})();
skillsLoadInFlight.set(cacheKey, request);
try {
return await request;
} finally {
skillsLoadInFlight.delete(cacheKey);
}
},
getSkillDetail: async (name: string, requestedDirectory?: string | null) => {
try {
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
if (!response.ok) {
return null;
}
return await response.json() as SkillDetail;
} catch {
return null;
}
},
createSkill: async (config: SkillConfig, requestedDirectory?: string | null) => {
try {
const skillConfig: Record<string, unknown> = {
name: config.name,
description: config.description,
};
if (config.instructions) skillConfig.instructions = config.instructions;
if (config.scope) skillConfig.scope = config.scope;
if (config.source) skillConfig.source = config.source;
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(skillConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to create skill';
throw new Error(message);
}
invalidateSkillsLoadCache(directory);
if (payload?.requiresManualRestart) {
upsertSkillLocal(set, get, config.name, config, directory);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'skills', { id: config.name })) {
upsertSkillLocal(set, get, config.name, config, directory);
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
return true;
}
if (payload?.requiresReload) {
startConfigUpdate("Creating skill...");
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills(directory);
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
}
},
updateSkill: async (name: string, config: Partial<SkillConfig>, requestedDirectory?: string | null) => {
try {
const skillConfig: Record<string, unknown> = {};
if (config.description !== undefined) skillConfig.description = config.description;
if (config.instructions !== undefined) skillConfig.instructions = config.instructions;
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath;
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(skillConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to update skill';
throw new Error(message);
}
invalidateSkillsLoadCache(directory);
if (payload?.requiresManualRestart) {
upsertSkillLocal(set, get, name, config, directory);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) {
upsertSkillLocal(set, get, name, config, directory);
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
return true;
}
if (payload?.requiresReload) {
startConfigUpdate("Updating skill...");
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills(directory);
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
}
},
renameSkill: async (name: string, newName: string, requestedDirectory?: string | null) => {
startConfigUpdate("Renaming skill...");
let requiresReload = false;
try {
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ renameTo: newName }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to rename skill';
throw new Error(message);
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills(directory);
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
deleteSkill: async (name: string, requestedDirectory?: string | null) => {
try {
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to delete skill';
throw new Error(message);
}
invalidateSkillsLoadCache(directory);
if (payload?.requiresManualRestart) {
removeSkillLocal(set, get, name);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) {
removeSkillLocal(set, get, name);
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
return true;
}
if (payload?.requiresReload) {
startConfigUpdate("Deleting skill...");
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills(directory);
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
if (get().selectedSkillName === name) {
set({ selectedSkillName: null });
}
return loaded;
} catch {
return false;
}
},
getSkillByName: (name: string, requestedDirectory?: string | null) => {
return selectSkillsForDirectory(get(), requestedDirectory).find((skill) => skill.name === name);
},
readSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => {
try {
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `&directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`,
{ headers: directory ? { 'x-opencode-directory': directory } : undefined },
);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.content ?? null;
} catch {
return null;
}
},
writeSupportingFile: async (skillName: string, filePath: string, content: string, requestedDirectory?: string | null) => {
try {
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ content })
}
);
return response.ok;
} catch {
return false;
}
},
deleteSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => {
try {
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}
);
return response.ok;
} catch {
return false;
}
},
}),
{
name: "skills-store",
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedSkillName: state.selectedSkillName,
}),
},
),
{
name: "skills-store",
},
),
);
if (typeof window !== "undefined") {
window.__zustand_skills_store__ = useSkillsStore;
}
async function waitForOpenCodeConnection(delayMs?: number) {
const initialPause = typeof delayMs === "number" && delayMs > 0
? Math.min(delayMs, FAST_HEALTH_POLL_INTERVAL_MS)
: 0;
if (initialPause > 0) {
await sleep(initialPause);
}
const start = Date.now();
let attempt = 0;
let lastError: unknown = null;
while (Date.now() - start < MAX_HEALTH_WAIT_MS) {
attempt += 1;
updateConfigUpdateMessage(`Waiting for OpenCode… (attempt ${attempt})`);
try {
const isHealthy = await opencodeClient.checkHealth();
if (isHealthy) {
return;
}
lastError = new Error("OpenCode health check reported not ready");
} catch (error) {
lastError = error;
}
const elapsed = Date.now() - start;
const waitMs =
attempt <= FAST_HEALTH_POLL_ATTEMPTS && elapsed < 1200
? FAST_HEALTH_POLL_INTERVAL_MS
: Math.min(
SLOW_HEALTH_POLL_BASE_MS +
Math.max(0, attempt - FAST_HEALTH_POLL_ATTEMPTS) * SLOW_HEALTH_POLL_INCREMENT_MS,
SLOW_HEALTH_POLL_MAX_MS,
);
await sleep(waitMs);
}
throw lastError || new Error("OpenCode did not become ready in time");
}
export async function refreshSkillsAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) {
try {
updateConfigUpdateMessage(options?.message || "Refreshing skills…");
} catch {
// ignore
}
try {
await waitForOpenCodeConnection(options?.delayMs);
updateConfigUpdateMessage("Refreshing skills…");
const skillsStore = useSkillsStore.getState();
invalidateSkillsLoadCache();
const loaded = await skillsStore.loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
} catch (error) {
updateConfigUpdateMessage("OpenCode refresh failed. Please retry.");
await sleep(1500);
throw error;
} finally {
finishConfigUpdate();
}
}
// Subscribe to config changes from other stores
let unsubscribeSkillsConfigChanges: (() => void) | null = null;
if (!unsubscribeSkillsConfigChanges) {
unsubscribeSkillsConfigChanges = subscribeToConfigChanges((event) => {
if (event.source === CONFIG_EVENT_SOURCE) {
return;
}
if (scopeMatches(event, "skills")) {
const { loadSkills } = useSkillsStore.getState();
void loadSkills();
}
});
}