@@ -26,7 +26,6 @@ import (
26 "github.com/charmbracelet/crush/internal/event"
26 "github.com/charmbracelet/crush/internal/event"
27 "github.com/charmbracelet/crush/internal/filetracker"
27 "github.com/charmbracelet/crush/internal/filetracker"
28 "github.com/charmbracelet/crush/internal/history"
28 "github.com/charmbracelet/crush/internal/history"
29− "github.com/charmbracelet/crush/internal/home"
30 "github.com/charmbracelet/crush/internal/hooks"
29 "github.com/charmbracelet/crush/internal/hooks"
31 "github.com/charmbracelet/crush/internal/log"
30 "github.com/charmbracelet/crush/internal/log"
32 "github.com/charmbracelet/crush/internal/lsp"
31 "github.com/charmbracelet/crush/internal/lsp"
@@ -118,9 +117,19 @@ func NewCoordinator(
118 filetracker filetracker.Service,
117 filetracker filetracker.Service,
119 lspManager *lsp.Manager,
118 lspManager *lsp.Manager,
120 notify pubsub.Publisher[notify.Notification],
119 notify pubsub.Publisher[notify.Notification],
120+ skillsMgr *skills.Manager,
121) (Coordinator, error) {
121) (Coordinator, error) {
122− // Discover skills once at session start.
122+ // Skills are pre-discovered by the caller (see app.New /
123+ // backend.CreateWorkspace) and passed in via the manager. If no
124+ // manager was provided (legacy callers), fall back to an in-line
125+ // discovery so the coordinator still works.
126+ var allSkills, activeSkills []*skills.Skill
127+ if skillsMgr != nil {
128+ allSkills = skillsMgr.AllSkills()
129+ activeSkills = skillsMgr.ActiveSkills()
123− allSkills, activeSkills := discoverSkills(cfg)
131+ allSkills, activeSkills = discoverSkills(cfg)
124 skillTracker := skills.NewTracker(activeSkills)
133 skillTracker := skills.NewTracker(activeSkills)
126 c := &coordinator{
135 c := &coordinator{
@@ -1138,53 +1147,31 @@ func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionI
1138 return nil
1147 return nil
1141−// discoverSkills runs the skill discovery pipeline and returns both the
1150+// discoverSkills is a thin fallback wrapper used only when no
1142−// pre-filter (all discovered, after dedup) and post-filter (active) lists.
1151+// skills.Manager has been threaded through to the coordinator. All
1143−// It also emits a single diagnostic log line summarising the outcome to
1152+// production call sites (backend.CreateWorkspace, setupLocalWorkspace)
1144−// help track skill-loading health over time.
1153+// run discovery in advance and pass the results via the manager;
1154+// reaching this path means a caller bypassed both. It deliberately does
1155+// NOT publish to the package-level broker — there are no subscribers in
1156+// that case, so doing so would be misleading without delivering the
1157+// snapshot anywhere useful.
1145func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
1158func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
1146− builtin, builtinStates := skills.DiscoverBuiltinWithStates()
1147− discovered := append([]*skills.Skill(nil), builtin...)
1149− var userStates []*skills.SkillState
1150− var userPaths []string
1152 opts := cfg.Config().Options
1159 opts := cfg.Config().Options
1153− if opts != nil && len(opts.SkillsPaths) > 0 {
1160+ var paths, disabled []string
1154− userPaths = make([]string, 0, len(opts.SkillsPaths))
1155− for _, pth := range opts.SkillsPaths {
1156− expanded := home.Long(pth)
1157− if strings.HasPrefix(expanded, "$") {
1158− if resolved, err := cfg.Resolver().ResolveValue(expanded); err == nil {
1159− expanded = resolved
1162− userPaths = append(userPaths, expanded)
1164− var userSkills []*skills.Skill
1165− userSkills, userStates = skills.DiscoverWithStates(userPaths)
1166− discovered = append(discovered, userSkills...)
1169− allSkills = skills.Deduplicate(discovered)
1170− var disabledSkills []string
1171 if opts != nil {
1161 if opts != nil {
1162+ paths = opts.SkillsPaths
1172− disabledSkills = opts.DisabledSkills
1163+ disabled = opts.DisabledSkills
1174− activeSkills = skills.Filter(allSkills, disabledSkills)
1165+ var resolver func(string) (string, error)
1175−
1166+ if r := cfg.Resolver(); r != nil {
1176− allStates := append([]*skills.SkillState(nil), builtinStates...)
1167+ resolver = r.ResolveValue
1177− allStates = append(allStates, userStates...)
1168+ }
1178−
1169+ allSkills, activeSkills, states := skills.DiscoverFromConfig(skills.DiscoveryConfig{
1179− allStates = skills.DeduplicateStates(allStates)
1170+ SkillsPaths: paths,
1180−
1171+ DisabledSkills: disabled,
1181− slices.SortStableFunc(allStates, func(a, b *skills.SkillState) int {
1172+ Resolver: resolver,
1182− return strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path))
1184− skills.SetLatestStates(allStates)
1185− skills.PublishStates(allStates)
1187− logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
1174+ logDiscoveryStats(states, paths, allSkills, activeSkills, disabled)
1188 return allSkills, activeSkills
1175 return allSkills, activeSkills
@@ -1232,28 +1219,26 @@ func logTurnSkillUsage(
1233// logDiscoveryStats emits a single structured log line summarising skill
1220// logDiscoveryStats emits a single structured log line summarising skill
1234// discovery for the current session. It is intentionally low-volume: one
1221// discovery for the current session. It is intentionally low-volume: one
1235−// line per session start.
1222+// line per session start. Builtin vs user counts are derived from the
1223+// SkillState.Path — builtin states use the "builtin/" embed prefix.
1236func logDiscoveryStats(
1224func logDiscoveryStats(
1237− builtin []*skills.Skill,
1238− builtinStates, userStates []*skills.SkillState,
1225+ states []*skills.SkillState,
1239 userPaths []string,
1226 userPaths []string,
1240 allSkills, activeSkills []*skills.Skill,
1227 allSkills, activeSkills []*skills.Skill,
1241 disabled []string,
1228 disabled []string,
1243− countErrors := func(states []*skills.SkillState) int {
1230+ var builtinOK, builtinErr, userOK, userErr int
1244− n := 0
1231+ for _, s := range states {
1245− for _, s := range states {
1232+ isBuiltin := strings.HasPrefix(s.Path, "builtin/")
1234+ case isBuiltin && s.State == skills.StateNormal:
1246− if s.State == skills.StateError {
1236+ case isBuiltin && s.State == skills.StateError:
1247− n++
1237+ builtinErr++
1254− for _, s := range userStates {
1255− if s.State == skills.StateNormal {
1238+ case !isBuiltin && s.State == skills.StateNormal:
1256 userOK++
1239 userOK++
1240+ case !isBuiltin && s.State == skills.StateError:
@@ -1267,10 +1252,10 @@ func logDiscoveryStats(
1267 slog.Info(
1252 slog.Info(
1268 "Skill discovery complete",
1253 "Skill discovery complete",
1269 "component", "skills",
1254 "component", "skills",
1270− "builtin_ok", len(builtin),
1255+ "builtin_ok", builtinOK,
1271− "builtin_errors", countErrors(builtinStates),
1256+ "builtin_errors", builtinErr,
1272 "user_ok", userOK,
1257 "user_ok", userOK,
1273− "user_errors", countErrors(userStates),
1258+ "user_errors", userErr,
1274 "user_paths", len(userPaths),
1259 "user_paths", len(userPaths),
1275 "deduped_total", len(allSkills),
1260 "deduped_total", len(allSkills),
1276 "active", len(activeSkills),
1261 "active", len(activeSkills),