Skip to content

Commit 79ce0d6

Browse files
committed
fix(vscode): list skills in the slash menu before a session exists
Skill resolution was reachable only through a live session, so a freshly opened panel showed the built-in commands alone and skills appeared only after the first message. Resolution now also runs at workspace scope, using the same roots a session resolves, and the panel calls that when it has no session yet. A live session is still preferred, since only it knows its MCP prompts.
1 parent de40509 commit 79ce0d6

7 files changed

Lines changed: 95 additions & 5 deletions

File tree

apps/vscode/src/handlers/config.handler.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,20 @@ const getModels: Handler<void, ModelsConfig> = async (_, ctx) => {
8989
};
9090

9191
/**
92-
* The skill catalog is session-scoped in the engine, so the list is empty until
93-
* a session exists. `bridge-handler` re-broadcasts the commands once one is
94-
* created, which is what fills the menu on a cold start.
92+
* Skills are resolved from the workspace, not from a session, so a panel that
93+
* has not sent a message yet still lists them. A live session is preferred when
94+
* there is one: only it can report the prompts of its MCP connections.
9595
*/
9696
export const getSlashCommands: Handler<void, SlashCommandInfo[]> = async (_, ctx) => {
9797
const session = ctx.getSession()?.session;
98-
if (session === undefined) return SLASH_COMMANDS;
9998
try {
100-
const { commands } = buildSkillSlashCommands(await session.listSkills());
99+
const skills =
100+
session !== undefined
101+
? await session.listSkills()
102+
: ctx.workDir !== null
103+
? await ctx.harness.listWorkspaceSkills(ctx.workDir)
104+
: [];
105+
const { commands } = buildSkillSlashCommands(skills);
101106
return [...SLASH_COMMANDS, ...commands.map(toSlashCommandInfo)];
102107
} catch (error) {
103108
ctx.logError("Unable to list skills", error);

packages/agent-core/src/rpc/core-api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,10 @@ export interface ListAgentProfilesPayload {
387387
readonly workDir: string;
388388
}
389389

390+
export interface ListWorkspaceSkillsPayload {
391+
readonly workDir: string;
392+
}
393+
390394
export interface AgentProfileSummary {
391395
readonly name: string;
392396
readonly description?: string;
@@ -494,6 +498,7 @@ export interface CoreAPI extends SessionAPIWithId {
494498
getConfigDiagnostics: (payload: EmptyPayload) => ConfigDiagnostics;
495499
listOutputStyles: (payload: ListOutputStylesPayload) => OutputStyleCatalog;
496500
listAgentProfiles: (payload: ListAgentProfilesPayload) => AgentProfileCatalog;
501+
listWorkspaceSkills: (payload: ListWorkspaceSkillsPayload) => readonly SkillSummary[];
497502
setPythinkerConfig: (payload: SetPythinkerConfigPayload) => PythinkerConfig;
498503
replacePythinkerConfig: (payload: ReplacePythinkerConfigPayload) => PythinkerConfig;
499504
removePythinkerProvider: (payload: RemovePythinkerProviderPayload) => PythinkerConfig;

packages/agent-core/src/rpc/core-impl.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { FLAG_DEFINITIONS, FlagResolver, type ExperimentalFeatureState } from '../flags';
3636
import type { Logger } from '../logging/types';
3737
import { resolveSessionMcpConfig, mergeCallerMcpServers, type SessionMcpConfig } from '../mcp';
38+
import { listWorkspaceSkills } from '../skill/workspace';
3839
import {
3940
DEFAULT_AGENT_PROFILES,
4041
loadAgentProfilesFromDirectories,
@@ -92,6 +93,7 @@ import type {
9293
InstallPluginPayload,
9394
ListSessionsPayload,
9495
ListAgentProfilesPayload,
96+
ListWorkspaceSkillsPayload,
9597
ListOutputStylesPayload,
9698
McpServerInfo,
9799
SkillActivationResult,
@@ -615,6 +617,19 @@ export class PythinkerCore implements PromisableMethods<CoreAPI> {
615617
return this.loadAgentProfileCatalog(requiredWorkDir('listAgentProfiles', workDir));
616618
}
617619

620+
// Resolves the same roots a session would, so a caller that has no session yet
621+
// (a freshly opened editor panel) still sees the workspace's real skill list.
622+
async listWorkspaceSkills({
623+
workDir,
624+
}: ListWorkspaceSkillsPayload): Promise<readonly SkillSummary[]> {
625+
await this.pluginsReady;
626+
this.assertPluginsLoaded();
627+
return listWorkspaceSkills({
628+
workDir: requiredWorkDir('listWorkspaceSkills', workDir),
629+
...this.resolveSessionSkillConfig(this.readConfigForWrite()),
630+
});
631+
}
632+
618633
async setPythinkerConfig(input: SetPythinkerConfigPayload): Promise<PythinkerConfig> {
619634
const config = mergeConfigPatch(this.readConfigForWrite(), input);
620635
return this.writePythinkerConfig(config);

packages/agent-core/src/skill/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './parser';
33
export * from './registry';
44
export * from './scanner';
55
export * from './types';
6+
export * from './workspace';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { homedir } from 'node:os';
2+
3+
import { registerBuiltinSkills } from './builtin';
4+
import { SessionSkillRegistry } from './registry';
5+
import { resolveSkillRoots } from './scanner';
6+
import { summarizeSkill, type SkillRoot, type SkillSummary } from './types';
7+
8+
/**
9+
* Field-for-field the resolution inputs a session uses, so a caller can pass the
10+
* config it already assembles for sessions. Declared here rather than imported
11+
* from the session layer, which depends on this one.
12+
*/
13+
export interface ListWorkspaceSkillsOptions {
14+
/** Directory the skills are resolved for, as a session's cwd would be. */
15+
readonly workDir: string;
16+
readonly userHomeDir?: string;
17+
/** Brand data dir (PYTHINKER_CODE_HOME); user brand skills live under `<brandHomeDir>/skills`. */
18+
readonly brandHomeDir?: string;
19+
readonly explicitDirs?: readonly string[];
20+
readonly extraDirs?: readonly string[];
21+
readonly pluginSkillRoots?: readonly SkillRoot[];
22+
readonly mergeAllAvailableSkills?: boolean;
23+
readonly builtinDir?: string;
24+
readonly onWarning?: (message: string, cause?: unknown) => void;
25+
}
26+
27+
/**
28+
* The skill catalog a workspace resolves to, without opening a session.
29+
*
30+
* A session's own `listSkills` additionally reports the prompts of its MCP
31+
* connections; those belong to a live session and are absent here.
32+
*/
33+
export async function listWorkspaceSkills(
34+
options: ListWorkspaceSkillsOptions,
35+
): Promise<readonly SkillSummary[]> {
36+
const registry = new SessionSkillRegistry({ onWarning: options.onWarning });
37+
const roots = await resolveSkillRoots({
38+
paths: {
39+
userHomeDir: options.userHomeDir ?? homedir(),
40+
brandHomeDir: options.brandHomeDir,
41+
workDir: options.workDir,
42+
},
43+
explicitDirs: options.explicitDirs,
44+
extraDirs: options.extraDirs,
45+
pluginSkillRoots: options.pluginSkillRoots,
46+
mergeAllAvailableSkills: options.mergeAllAvailableSkills,
47+
builtinDir: options.builtinDir,
48+
});
49+
await registry.loadRoots(roots);
50+
registerBuiltinSkills(registry);
51+
return registry.listSkills().map(summarizeSkill);
52+
}

packages/node-sdk/src/pythinker-harness.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
RenameSessionInput,
2626
ResumeSessionInput,
2727
SessionSummary,
28+
SkillSummary,
2829
TelemetryClient,
2930
TelemetryContextPatch,
3031
TelemetryProperties,
@@ -235,6 +236,11 @@ export class PythinkerHarness {
235236
return this.rpc.listAgentProfiles(workDir);
236237
}
237238

239+
/** The workspace's skills without opening a session; excludes session-only MCP prompts. */
240+
async listWorkspaceSkills(workDir: string): Promise<readonly SkillSummary[]> {
241+
return this.rpc.listWorkspaceSkills(workDir);
242+
}
243+
238244
async ensureConfigFile(): Promise<void> {
239245
await this.ensureConfigFileImpl();
240246
}

packages/node-sdk/src/rpc.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@ export abstract class SDKRpcClientBase {
249249
return rpc.listAgentProfiles({ workDir });
250250
}
251251

252+
/** The workspace's skills without opening a session; excludes session-only MCP prompts. */
253+
async listWorkspaceSkills(workDir: string): Promise<readonly SkillSummary[]> {
254+
const rpc = await this.getRpc();
255+
return rpc.listWorkspaceSkills({ workDir });
256+
}
257+
252258
async setConfig(input: PythinkerConfigPatch): Promise<PythinkerConfig> {
253259
const rpc = await this.getRpc();
254260
return rpc.setPythinkerConfig(input);

0 commit comments

Comments
 (0)