Compaction wipes Claude Code's skill list. sticky-skills puts it back.

[!WARNING] No longer needed. Please uninstall it. Since Claude Code 2.1.304, compaction sends the skill list again by itself (release notes). On those versions sticky-skills makes compactions miss the prompt cache, and it can make a resumed session lose its compaction summary. Uninstall it with
claude plugin uninstall sticky-skills@sticky-skills.
Compaction wipes Claude Code's skill map <br/> ... _sticky-skills_ puts it back.
https://github.com/user-attachments/assets/48c25e26-1b26-4412-b986-aaa8b63f8994
It's been a long session. Claude has compacted a few times.
You tell Claude to do something. You've got a few relevant skills it normally finds on its own...
... but now Claude doesn't load the skills.
What happened?
After a compaction, the full list of skills that Claude gets in the system prompt is gone, unless you specifically invoke them.
sticky-skills is a Claude Code mod that puts the full list back right after the compaction summary.
If your workflow requires Claude to find skills automatically without you specifically invoking them, you need this mod.
claude plugin marketplace add Jakobbarnwell/sticky-skills
claude plugin install sticky-skills@sticky-skills
Takes effect in new sessions.
Limits:
session ends.
MIT
hooks/register.ts 90 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionMessage } from 'claude-code'
3
4import type { SkillEntries } from '../types'
5
6// Claude Code sends the full skill list once per process, and after that only
7// what changed, so a compaction loses it. This mod remembers every entry it sees
8// and puts the full list back right after the compaction summary.
9
10const remembered = atom({ plugin: 'sticky-skills', key: 'entries' } as const, {} as SkillEntries)
11
12const HEADER = 'The following skills are available for use with the Skill tool:'
13const REMINDER_START = '<system-reminder>\n'
14const LIST_START = REMINDER_START + HEADER
15const REMINDER_END = '</system-reminder>'
16
17/** Splits a list into its `- name: description` entries, keyed by name. */
18function parseEntries(list: string): SkillEntries {
19 const entries: SkillEntries = {}
20 let current: string | undefined
21 for (const line of list.split('\n')) {
22 const name = /^- ([^\s:]+(?::[^\s:]+)?): /.exec(line)?.[1]
23 if (name !== undefined) {
24 current = name
25 entries[name] = line
26 } else if (current !== undefined) {
27 entries[current] += '\n' + line
28 }
29 }
30 for (const name in entries) entries[name] = entries[name].trimEnd()
31 return entries
32}
33
34function formatList(entries: SkillEntries): string {
35 return [HEADER, '', ...Object.values(entries)].join('\n')
36}
37
38function formatReminder(entries: SkillEntries): string {
39 return `${REMINDER_START}${formatList(entries)}\n${REMINDER_END}`
40}
41
42/** Every entry of the skill lists already in the conversation. */
43function listedEntries(messages: SessionMessage[]): SkillEntries {
44 const lists = messages.flatMap(message =>
45 message.text
46 .split(LIST_START)
47 .slice(1)
48 .map(after => parseEntries(after.split(REMINDER_END)[0])),
49 )
50 return Object.assign({}, ...lists)
51}
52
53export const register: Register = on => {
54 on('prompt.attachment', async ($, e, next) => {
55 const result = await next(e)
56 // Subagents get their own full list, so only the main conversation needs help.
57 if (e.agentId !== undefined || e.type !== 'skill_listing' || result.text === null) return result
58
59 const sent = parseEntries(e.text)
60 await update($, remembered, known => ({ ...known, ...sent }))
61
62 // A resumed session starts a new process, which re-sends the full list even
63 // though the conversation may already hold it. Pass on only what's new.
64 const listed = listedEntries(await $.session.messages())
65 const unlisted = Object.fromEntries(Object.entries(sent).filter(([name, entry]) => listed[name] !== entry))
66 const count = Object.keys(unlisted).length
67 if (count === 0) return { text: null }
68 if (count === Object.keys(sent).length) return result
69 return { text: formatList(unlisted) }
70 })
71
72 on('session.compact', async ($, e, next) => {
73 const result = await next(e)
74 // A precompute only drafts a summary ahead of time; the real compaction comes later.
75 if (e.agentId !== undefined || e.trigger === 'precompute' || 'skip' in result) return result
76
77 // A failure here costs only the restored list, never the compaction.
78 try {
79 const entries = await read($, remembered)
80 if (Object.keys(entries).length === 0) return result
81
82 const [summary, ...kept] = result.messages
83 const reminder = { role: 'user' as const, text: formatReminder(entries), toolUses: [] }
84 return { ...result, messages: [summary, reminder, ...kept] }
85 } catch {
86 return result
87 }
88 })
89}
90types/index.d.ts 11 lines1/** Each skill's list entry as Claude Code last sent it, keyed by name. */
2export type SkillEntries = Record<string, string>
3
4declare module 'claude-code' {
5 interface PluginState {
6 'sticky-skills': {
7 entries: SkillEntries
8 }
9 }
10}
11