Skip to content
Merged
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/web-pre-session-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Show available skills in the composer before a session is created.
14 changes: 13 additions & 1 deletion apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,8 +717,9 @@ export class DaemonKimiWebApi implements KimiWebApi {
}

// -------------------------------------------------------------------------
// Skills — session-scoped slash-invocable skills
// Skills — slash-invocable skills (session- or workspace-scoped)
// GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] }
// GET /workspaces/{id}/skills → { skills: WireSkillDescriptor[] } (no session)
// POST /sessions/{id}/skills/{name}:activate body { args? } → { activated, skill_name }
// -------------------------------------------------------------------------

Expand All @@ -733,6 +734,17 @@ export class DaemonKimiWebApi implements KimiWebApi {
}));
}

async listSkillsForWorkspace(workspaceId: string): Promise<AppSkill[]> {
const data = await this.http.get<{ skills: WireSkillDescriptor[] }>(
`/workspaces/${encodeURIComponent(workspaceId)}/skills`,
);
return (data.skills ?? []).map((s) => ({
name: s.name,
description: s.description,
source: s.source,
}));
}

async activateSkill(
sessionId: string,
skillName: string,
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,8 @@ export interface KimiWebApi {
respondQuestion(sessionId: string, questionId: string, response: QuestionResponse): Promise<{ resolved: true; resolvedAt: string }>;
dismissQuestion(sessionId: string, questionId: string): Promise<{ dismissed: true; dismissedAt: string }>;
listSkills(sessionId: string): Promise<AppSkill[]>;
/** List skills for a workspace (no session required) — GET /workspaces/{id}/skills. */
listSkillsForWorkspace(workspaceId: string): Promise<AppSkill[]>;
activateSkill(sessionId: string, skillName: string, args?: string): Promise<{ activated: true; skillName: string }>;
listTasks(sessionId: string, status?: AppTaskStatus): Promise<AppTask[]>;
getTask(sessionId: string, taskId: string, input?: { withOutput?: boolean; outputBytes?: number }): Promise<AppTask>;
Expand Down
16 changes: 16 additions & 0 deletions apps/kimi-web/src/composables/client/useModelProviderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export function useModelProviderState(
// Session-scoped skills (slash-invocable). Loaded lazily per session; the active
// session's list feeds the composer's `/` menu.
const skillsBySession = ref<Record<string, AppSkill[]>>({});
// Workspace-scoped skills, used to populate the `/` menu before a session exists
// (onboarding composer). Keyed by workspace id; loaded once per workspace.
const skillsByWorkspace = ref<Record<string, AppSkill[]>>({});
const providers = ref<AppProvider[]>([]);

// Model picked while in the "new session draft" state (onboarding composer —
Expand Down Expand Up @@ -127,6 +130,17 @@ export function useModelProviderState(
}
}

async function loadSkillsForWorkspace(workspaceId: string): Promise<void> {
try {
const api = getKimiWebApi();
const list = await api.listSkillsForWorkspace(workspaceId);
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.
}
}

/** Load models (cached — call again to force refresh) */
async function loadModels(): Promise<void> {
try {
Expand Down Expand Up @@ -383,8 +397,10 @@ export function useModelProviderState(
providers,
draftModel,
skillsBySession,
skillsByWorkspace,
// actions
loadSkillsForSession,
loadSkillsForWorkspace,
loadModels,
refreshOAuthProviderModels,
loadProviders,
Expand Down
23 changes: 20 additions & 3 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1627,11 +1627,13 @@ const sessions = computed<Session[]>(() => {

const activeSessionId = computed<string>(() => rawState.activeSessionId ?? '');

/** Slash-invocable skills for the active session (feeds the composer `/` menu). */
/** Slash-invocable skills for the composer `/` menu — the active session's skills,
* or, before a session exists, the active workspace's skills. */
const skills = computed<AppSkill[]>(() => {
const sid = rawState.activeSessionId;
if (!sid) return [];
return modelProvider.skillsBySession.value[sid] ?? [];
if (sid) return modelProvider.skillsBySession.value[sid] ?? [];
const wid = activeWorkspaceId.value;
return wid ? (modelProvider.skillsByWorkspace.value[wid] ?? []) : [];

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 Handle pre-session skill commands before activation

When there is no active session, returning workspace skills here makes Composer treat /skill ... as a known slash command, so it clears the draft and emits command instead of going through handleSubmit/startSessionAndSendPrompt; App.vue then calls client.activateSkill(...), whose implementation returns immediately when activeSessionId is empty. In the onboarding composer for a workspace with any skill, selecting or typing that skill now silently does nothing rather than creating a session or sending the text.

Useful? React with 👍 / 👎.

});

const isSending = computed<boolean>(() => {
Expand Down Expand Up @@ -2048,6 +2050,21 @@ const activeWorkspaceId = computed<string | null>(() => {
return list[0]?.id ?? null;
});

// Pre-warm workspace-scoped skills so the onboarding composer's `/` menu is
// populated before a session exists. Loaded once per workspace (guard mirrors
// the per-session guard in refreshSessionSidecars); session skills take over
// via refreshSessionSidecars once a session is created.
watch(
activeWorkspaceId,
(id) => {
if (!id) return;
if (!Object.prototype.hasOwnProperty.call(modelProvider.skillsByWorkspace.value, id)) {
void modelProvider.loadSkillsForWorkspace(id);
}
},
{ immediate: true },
);

/** The active workspace as a sidebar view (or null when none). */
const visibleWorkspace = computed<WorkspaceView | null>(() => {
const id = activeWorkspaceId.value;
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core/src/rpc/core-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ export interface ActivateSkillPayload {
readonly args?: string | undefined;
}

export interface ListWorkspaceSkillsPayload {
readonly workDir: string;
}

export interface ActivatePluginCommandPayload {
readonly pluginId: string;
readonly commandName: string;
Expand Down Expand Up @@ -453,6 +457,7 @@ export interface CoreAPI extends SessionAPIWithId {
forkSession: (payload: ForkSessionPayload) => ResumeSessionResult;
listSessions: (payload: ListSessionsPayload) => readonly SessionSummary[];
exportSession: (payload: ExportSessionPayload) => ExportSessionResult;
listWorkspaceSkills: (payload: ListWorkspaceSkillsPayload) => Promise<readonly SkillSummary[]>;
listPlugins: (payload: EmptyPayload) => readonly PluginSummary[];
installPlugin: (payload: InstallPluginPayload) => PluginSummary;
setPluginEnabled: (payload: SetPluginEnabledPayload) => void;
Expand Down
38 changes: 38 additions & 0 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ import type { Logger } from '../logging/types';
import { resolveSessionMcpConfig, mergeCallerMcpServers, type SessionMcpConfig } from '../mcp';
import { Session, type SessionMeta, type SessionSkillConfig } from '../session';
import { exportSessionDirectory } from '../session/export';
import {
registerBuiltinSkills,
SessionSkillRegistry,
resolveSkillRoots,
summarizeSkill,
} from '../skill';
import {
ProviderManager, type BearerTokenProvider,
type OAuthTokenProviderResolver
Expand Down Expand Up @@ -80,6 +86,7 @@ import type {
GetPluginInfoPayload,
InstallPluginPayload,
ListSessionsPayload,
ListWorkspaceSkillsPayload,
McpServerInfo,
McpStartupMetrics,
PluginInfo,
Expand Down Expand Up @@ -756,6 +763,37 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.sessionApi(sessionId).listSkills(payload);
}

/**
* List the skills available for a workspace working directory without
* requiring a session. Mirrors `Session.loadSkills` exactly (same roots,
* same discovery order, same built-ins) so the result matches what a new
* session created in `workDir` would see. Used to populate the composer
* skill menu before a session exists.
*/
async listWorkspaceSkills({
workDir,
}: ListWorkspaceSkillsPayload): Promise<readonly SkillSummary[]> {
const cwd = requiredWorkDir('listWorkspaceSkills', workDir);
await this.pluginsReady;
const skills = this.resolveSessionSkillConfig(this.reloadProviderManager());
const roots = await resolveSkillRoots({
paths: {
userHomeDir: skills.userHomeDir ?? this.userHomeDir,
brandHomeDir: skills.brandHomeDir ?? this.homeDir,
workDir: cwd,
},
explicitDirs: skills.explicitDirs,
extraDirs: skills.extraDirs,
pluginSkillRoots: skills.pluginSkillRoots,
mergeAllAvailableSkills: skills.mergeAllAvailableSkills,
builtinDir: skills.builtinDir,
});
const registry = new SessionSkillRegistry({});
await registry.loadRoots(roots);
registerBuiltinSkills(registry);
return registry.listSkills().map(summarizeSkill);
}

listPluginCommands({
sessionId,
...payload
Expand Down
24 changes: 18 additions & 6 deletions packages/agent-core/src/services/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@
*
* **CoreAPI surface used**:
* - `core.rpc.listSkills({sessionId}) => readonly SkillSummary[]`
* (packages/agent-core/src/rpc/core-api.ts:347, SessionAPI).
* (packages/agent-core/src/rpc/core-api.ts, SessionAPI) — session-scoped.
* - `core.rpc.listWorkspaceSkills({workDir}) => Promise<readonly SkillSummary[]>`
* (CoreAPI) — workspace-cwd-scoped, no session required; mirrors the roots
* a new session would scan.
* - `core.rpc.activateSkill({sessionId, agentId, name, args})`
* (line 324, AgentAPI) — renders the skill prompt and starts a turn with a
* (AgentAPI) — renders the skill prompt and starts a turn with a
* `skill_activation` origin (trigger 'user-slash'), mirroring the TUI's
* slash-command path. It does NOT go through `IPromptService`, so no
* `prompt_id` is minted; clients observe progress via `skill.activated` +
* `turn.*` events on the WS stream.
*
* **Session scoping**: the skill registry is per-session (project skills are
* discovered from the session cwd), so both methods are session-scoped and the
* impl resumes the session before dispatching — sessions that exist on disk
* but are not in the active map after a daemon restart still resolve.
* **Scoping**: the skill registry is per-session (project skills are
* discovered from the session cwd). `list`/`activate` are session-scoped, so
* the impl resumes the session before dispatching — sessions that exist on
* disk but are not in the active map after a daemon restart still resolve.
* `listForWorkDir` is workspace-cwd-scoped instead: it scans the same roots a
* new session would, without creating or resuming one.
*
* **Error model**:
* - `SkillSessionNotFoundError` is NOT defined here — the impl throws the
Expand Down Expand Up @@ -68,6 +73,13 @@ export interface ISkillService {
*/
list(sessionId: string): Promise<readonly SkillDescriptor[]>;

/**
* Return the skills available for a workspace working directory (project +
* user + extra + builtin) without requiring a session. Used to populate the
* composer skill menu before a session is created.
*/
listForWorkDir(workDir: string): Promise<readonly SkillDescriptor[]>;

/**
* Activate a skill by name in a session — the REST analogue of typing
* `/<skill> <args>`. Starts a turn on the session's main agent. Throws
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core/src/services/skill/skillService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export class SkillService extends Disposable implements ISkillService {
return raw.map(toProtocolSkill);
}

async listForWorkDir(workDir: string): Promise<readonly SkillDescriptor[]> {
const raw = await this.core.rpc.listWorkspaceSkills({ workDir });
return raw.map(toProtocolSkill);
}

async activate(sessionId: string, skillName: string, args?: string): Promise<void> {
await this._requireLoadedSession(sessionId);
try {
Expand Down
62 changes: 57 additions & 5 deletions packages/server/src/routes/skills.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
/**
* `/sessions/{session_id}/skills*` REST routes.
* `/skills` REST routes (session- and workspace-scoped).
*
* 2 endpoints:
* 3 endpoints:
*
* GET /sessions/{session_id}/skills data: {skills: SkillDescriptor[]}
* GET /workspaces/{workspace_id}/skills data: {skills: SkillDescriptor[]}
* POST /sessions/{session_id}/skills/{skill_name}:activate body: {args?} data: {activated: true, skill_name}
*
* Skills are session-scoped: the registry is built per session (project
* skills are discovered from the session cwd), so the list lives under
* The session list is session-scoped: the registry is built per session
* (project skills are discovered from the session cwd), so it lives under
* `/sessions/{session_id}` rather than as a global collection like `/tools`.
*
* The workspace list (`/workspaces/{workspace_id}/skills`) is the
* session-less counterpart: it scans the same roots a new session in that
* workspace cwd would, so clients can populate the composer skill menu before
* a session exists. The workspace id is resolved to its root via
* `IWorkspaceRegistry.resolveRoot`.
*
* Activation is the REST analogue of typing `/<skill> <args>` in the TUI: it
* renders the skill prompt and starts a turn on the session's main agent with
* a `skill_activation` origin. No prompt_id is minted (the turn bypasses
* `IPromptService`); clients follow progress via `skill.activated` and
* `turn.*` events on the WS stream.
*
* **Error mapping**:
* - `WorkspaceNotFoundError` → envelope `code: 40410 workspace.not_found`.
* - `SessionNotFoundError` → envelope `code: 40401 session.not_found`.
* - `SkillNotFoundError` → envelope `code: 40415 skill.not_found`.
* - `SkillNotActivatableError` → envelope `code: 40912 skill.not_activatable`.
Expand All @@ -34,8 +42,9 @@ import {
activateSkillRequestSchema,
activateSkillResultSchema,
listSkillsResponseSchema,
workspaceIdParamSchema,
} from '@moonshot-ai/protocol';
import { ISkillService, SessionNotFoundError, SkillNotActivatableError, SkillNotFoundError, type IInstantiationService } from '@moonshot-ai/agent-core';
import { ISkillService, IWorkspaceRegistry, SessionNotFoundError, SkillNotActivatableError, SkillNotFoundError, WorkspaceNotFoundError, type IInstantiationService } from '@moonshot-ai/agent-core';
import { z } from 'zod';


Expand Down Expand Up @@ -102,6 +111,45 @@ export function registerSkillsRoutes(
listSkillsRoute.handler as Parameters<SkillsRouteHost['get']>[2],
);

// GET /workspaces/{workspace_id}/skills --------------------------------
const listWorkspaceSkillsRoute = defineRoute(
{
method: 'GET',
path: '/workspaces/{workspace_id}/skills',
params: workspaceIdParamSchema,
success: { data: listSkillsResponseSchema },
errors: {
[ErrorCode.WORKSPACE_NOT_FOUND]: {},
},
description: 'List the skills available to a workspace (no session required)',
tags: ['skills'],
operationId: 'listWorkspaceSkills',
},
async (req, reply) => {
try {
const { workspace_id } = req.params;
// Resolve both services from the accessor synchronously: the DI
// accessor is only valid during the synchronous invocation of the
// callback, so no `a.get(...)` may follow an `await`.
const skills = await ix.invokeFunction((a) => {
const registry = a.get(IWorkspaceRegistry);
const skillService = a.get(ISkillService);
return registry.resolveRoot(workspace_id).then((workDir) =>
skillService.listForWorkDir(workDir),
);
});
reply.send(okEnvelope({ skills }, req.id));
} catch (err) {
sendMappedError(reply, req.id, err);
}
},
);
app.get(
listWorkspaceSkillsRoute.path,
listWorkspaceSkillsRoute.options,
listWorkspaceSkillsRoute.handler as Parameters<SkillsRouteHost['get']>[2],
);

// POST /sessions/{session_id}/skills/{skill_name}:activate --------------
const activateSkillRoute = defineRoute(
{
Expand Down Expand Up @@ -169,6 +217,10 @@ function sendMappedError(
requestId: string,
err: unknown,
): void {
if (err instanceof WorkspaceNotFoundError) {
reply.send(errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, err.message, requestId));
return;
}
if (err instanceof SessionNotFoundError) {
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ exports[`API surface snapshot > matches the documented v1 route table and meta e
"GET",
"/api/v1/workspaces",
],
[
"GET",
"/api/v1/workspaces/{workspace_id}/skills",
],
[
"GET",
"/asyncapi.json",
Expand Down
Loading
Loading