Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/disabled-skills-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add `disabled_skills` config to fully hide selected skill names from Kimi (model listing, Skill tool, slash menu, and activation). Set `disabled_skills = ["name"]` in `config.toml`, then run `/reload`.
5 changes: 5 additions & 0 deletions .changeset/refresh-web-skill-menus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Refresh loaded skill menus after configuration changes.
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
services: 'services',
mergeAllAvailableSkills: 'merge_all_available_skills',
extraSkillDirs: 'extra_skill_dirs',
disabledSkills: 'disabled_skills',
loopControl: 'loop_control',
background: 'background',
experimental: 'experimental',
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,7 @@ export function toAppConfig(wire: WireConfig): AppConfig {
services: wire.services,
mergeAllAvailableSkills: wire.merge_all_available_skills,
extraSkillDirs: wire.extra_skill_dirs,
disabledSkills: wire.disabled_skills,
loopControl: wire.loop_control,
background: wire.background,
experimental: wire.experimental,
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ export interface WireConfig {
services?: unknown;
merge_all_available_skills?: boolean;
extra_skill_dirs?: string[];
disabled_skills?: string[];
loop_control?: unknown;
background?: unknown;
experimental?: Record<string, boolean>;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ export interface AppConfig {
services?: unknown;
mergeAllAvailableSkills?: boolean;
extraSkillDirs?: string[];
disabledSkills?: string[];
loopControl?: unknown;
background?: unknown;
experimental?: Record<string, boolean>;
Expand Down
74 changes: 68 additions & 6 deletions apps/kimi-web/src/composables/client/useModelProviderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,28 +258,89 @@ export function useModelProviderState(
.catch((error: unknown) => pushOperationFailure('setConfig', error));
}

async function loadSkillsForSession(sessionId: string): Promise<void> {
const sessionSkillRequestSeq = new Map<string, number>();
const workspaceSkillRequestSeq = new Map<string, number>();
const activeSessionSkillRequests = new Map<string, number>();
const activeWorkspaceSkillRequests = new Map<string, number>();

async function loadSkillsForSession(
sessionId: string,
source: 'direct' | 'config-refresh' = 'direct',
): Promise<void> {
const requestSeq = (sessionSkillRequestSeq.get(sessionId) ?? 0) + 1;
sessionSkillRequestSeq.set(sessionId, requestSeq);
activeSessionSkillRequests.set(sessionId, (activeSessionSkillRequests.get(sessionId) ?? 0) + 1);
try {
const api = getKimiWebApi();
const list = await api.listSkills(sessionId);
if (sessionSkillRequestSeq.get(sessionId) !== requestSeq) return;
skillsBySession.value = { ...skillsBySession.value, [sessionId]: list };
} catch {
// Skills are side data; an older daemon without /skills just yields no
// slash-skills, the built-in commands still work.
if (source === 'config-refresh' && sessionSkillRequestSeq.get(sessionId) === requestSeq) {
const next = { ...skillsBySession.value };
delete next[sessionId];
skillsBySession.value = next;
}
} finally {
const remaining = (activeSessionSkillRequests.get(sessionId) ?? 1) - 1;
if (remaining === 0) {
activeSessionSkillRequests.delete(sessionId);
sessionSkillRequestSeq.delete(sessionId);
} else {
activeSessionSkillRequests.set(sessionId, remaining);
}
}
}

async function loadSkillsForWorkspace(workspaceId: string): Promise<void> {
async function loadSkillsForWorkspace(
workspaceId: string,
source: 'direct' | 'config-refresh' = 'direct',
): Promise<void> {
const requestSeq = (workspaceSkillRequestSeq.get(workspaceId) ?? 0) + 1;
workspaceSkillRequestSeq.set(workspaceId, requestSeq);
activeWorkspaceSkillRequests.set(
workspaceId,
(activeWorkspaceSkillRequests.get(workspaceId) ?? 0) + 1,
);
try {
const api = getKimiWebApi();
const list = await api.listSkillsForWorkspace(workspaceId);
if (workspaceSkillRequestSeq.get(workspaceId) !== requestSeq) return;
skillsByWorkspace.value = { ...skillsByWorkspace.value, [workspaceId]: list };
} catch {
// Side data; an older daemon without /workspaces/{id}/skills just yields
// no slash-skills for the onboarding composer.
if (source === 'config-refresh' && workspaceSkillRequestSeq.get(workspaceId) === requestSeq) {
const next = { ...skillsByWorkspace.value };
delete next[workspaceId];
skillsByWorkspace.value = next;
}
} finally {
const remaining = (activeWorkspaceSkillRequests.get(workspaceId) ?? 1) - 1;
if (remaining === 0) {
activeWorkspaceSkillRequests.delete(workspaceId);
workspaceSkillRequestSeq.delete(workspaceId);
} else {
activeWorkspaceSkillRequests.set(workspaceId, remaining);
}
}
}

async function refreshLoadedSkills(): Promise<void> {
const sessionIds = new Set([
...Object.keys(skillsBySession.value),
...activeSessionSkillRequests.keys(),
]);
const workspaceIds = new Set([
...Object.keys(skillsByWorkspace.value),
...activeWorkspaceSkillRequests.keys(),
]);
await Promise.all([
...[...sessionIds].map((sessionId) => loadSkillsForSession(sessionId, 'config-refresh')),
...[...workspaceIds].map((workspaceId) =>
loadSkillsForWorkspace(workspaceId, 'config-refresh'),
),
]);
}

/** Load models (cached — call again to force refresh) */
async function loadModels(): Promise<void> {
try {
Expand Down Expand Up @@ -583,6 +644,7 @@ export function useModelProviderState(
// actions
loadSkillsForSession,
loadSkillsForWorkspace,
refreshLoadedSkills,
loadModels,
loadProviders,
setModel,
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq

if (event.type === 'configChanged') {
rawState.defaultModel = event.config.defaultModel ?? null;
void modelProvider.refreshLoadedSkills();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for catalog reload before refreshing cached skills

When a live v2 session has cached skills and a config patch changes extra_skill_dirs or merge_all_available_skills, this refresh can read and permanently cache the old catalog. In SessionSkillCatalogService, the config-source listener starts reloadSource() without awaiting it, while GET /sessions/{id}/skills waits only for the initial catalog.ready; the config route publishes event.config.changed immediately after config.set(). A slow directory scan therefore lets this request finish before the new contribution is merged, and the later catalog onDidChange is not forwarded to the web, so no second refresh repairs the cache. Publish/handle a post-catalog-refresh signal or make the listing wait for outstanding source reloads.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The session skill listing endpoint now waits for outstanding async catalog reloads (extra_skill_dirs, merge_all_available_skills) before returning, instead of only awaiting the initial catalog ready promise. Deferred tests cover both config-triggered reload paths, so the web refresh no longer caches a stale listing.

}

if (event.type === 'modelCatalogChanged') {
Expand Down
164 changes: 163 additions & 1 deletion apps/kimi-web/test/event-batcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ describe('coalesceAppRenderEvents (lossless stream grouping)', () => {
});
});

describe('useKimiWebClient (resync integration)', () => {
describe('useKimiWebClient integration', () => {
it('flushes queued deltas around an authoritative snapshot before live streaming resumes', async () => {
vi.stubGlobal('WebSocket', class {});

Expand Down Expand Up @@ -775,6 +775,168 @@ describe('useKimiWebClient (resync integration)', () => {
vi.unstubAllGlobals();
}
});

it('refreshes loaded skills after a configChanged event', async () => {
vi.resetModules();
vi.stubGlobal('WebSocket', vi.fn());

const sessionId = 'session-config';
const session: AppSession = {
id: sessionId,
title: 'Session',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
status: 'idle',
archived: false,
currentPromptId: null,
cwd: '/workspace',
model: 'model-1',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
totalCostUsd: 0,
contextTokens: 0,
contextLimit: 0,
turnCount: 0,
},
messageCount: 0,
lastSeq: 0,
workspaceId: 'workspace-1',
};
let handlers: KimiEventHandlers | undefined;
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
};
const skill = (name: string) => ({ name, description: '', source: 'project' as const });
const firstLoad = deferred<ReturnType<typeof skill>[]>();
const configRefresh = deferred<ReturnType<typeof skill>[]>();
const listSkills = vi
.fn()
.mockImplementationOnce(() => firstLoad.promise)
.mockImplementationOnce(() => configRefresh.promise);
const connection: KimiEventConnection = {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
bindNextPromptId: vi.fn(),
seedSnapshot: vi.fn(),
abort: vi.fn(),
terminalAttach: vi.fn(),
terminalInput: vi.fn(),
terminalResize: vi.fn(),
terminalDetach: vi.fn(),
terminalClose: vi.fn(),
markSideChannelAgent: vi.fn(),
health: () => ({ connected: true, open: true, stale: false }),
reconnect: vi.fn(),
close: vi.fn(),
};
const api: Partial<KimiWebApi> = {
getAuth: vi.fn(async () => ({
ready: true,
defaultModel: 'model-1',
managedProvider: null,
})),
getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })),
getMeta: vi.fn(async () => ({
serverVersion: '0.0.0',
serverId: 'server-1',
startedAt: '2026-01-01T00:00:00.000Z',
capabilities: {},
openInApps: [],
dangerousBypassAuth: false,
backend: 'v2',
})),
getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'model-1' })),
listModels: vi.fn(async () => []),
listProviders: vi.fn(async () => []),
listWorkspaces: vi.fn(async () => [
{ id: 'workspace-1', root: '/workspace', name: 'Workspace', sessionCount: 1 },
]),
getFsHome: vi.fn(async () => ({ home: '/home/test', recentRoots: [] })),
listSessions: vi.fn(async () => ({ items: [session], hasMore: false })),
getSessionSnapshot: vi.fn(async () => ({
asOfSeq: 0,
epoch: 'epoch-1',
session,
messages: [],
hasMoreMessages: false,
inFlightTurn: null,
subagents: [],
pendingApprovals: [],
pendingQuestions: [],
})),
getSessionStatus: vi.fn(async () => ({
model: 'model-1',
thinkingEffort: 'high',
permission: 'manual',
planMode: false,
swarmMode: false,
contextTokens: 0,
maxContextTokens: 0,
contextUsage: 0,
})),
getSessionGoal: vi.fn(async () => null),
getSessionWarnings: vi.fn(async () => []),
getGitStatus: vi.fn(async () => ({
branch: '',
ahead: 0,
behind: 0,
entries: {},
additions: 0,
deletions: 0,
pullRequest: null,
})),
listTasks: vi.fn(async () => []),
listSkills,
listSkillsForWorkspace: vi.fn(async () => []),
getFileUrl: (fileId) => `file:${fileId}`,
connectEvents: vi.fn((nextHandlers) => {
handlers = nextHandlers;
return connection;
}),
};
for (const key of Object.keys(clientApiMock)) delete clientApiMock[key];
Object.assign(clientApiMock, api);

try {
const { useKimiWebClient } = await import('../src/composables/useKimiWebClient');
const client = useKimiWebClient();
await client.load();
await vi.waitFor(() => {
expect(listSkills).toHaveBeenCalledTimes(1);
});

handlers!.onEvent(
{
type: 'configChanged',
changedFields: ['disabledSkills'],
config: { providers: {}, defaultModel: 'model-1', disabledSkills: ['review-helper'] },
},
{ sessionId, seq: 1 },
);

await vi.waitFor(() => {
expect(listSkills).toHaveBeenCalledTimes(2);
});
configRefresh.resolve([skill('after-config')]);
await vi.waitFor(() => {
expect(client.skills.value[0]?.name).toBe('after-config');
});
firstLoad.resolve([skill('before-config')]);
await firstLoad.promise;
await Promise.resolve();
expect(client.skills.value[0]?.name).toBe('after-config');
} finally {
connection.close();
vi.unstubAllGlobals();
}
});
});

describe('isRenderEvent (queue classification)', () => {
Expand Down
Loading