Keep a skill on for the whole session, like Cursor's Custom Modes. Run any skill and press Keep on, or run /mode <skill>. /mode off or Exit mode turns it off.

hooks/sticky-mode.mjs 300 lines1const MODE = { plugin: 'sticky-mode', key: 'mode' }
2const NOTICE = { plugin: 'sticky-mode', key: 'notice' }
3const OFFER = { plugin: 'sticky-mode', key: 'offer' }
4const COMMAND = 'mode'
5const MUTED = '#8a8a8a'
6const ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="${MUTED}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>`
7const SUGGESTION_LIMIT = 8
8
9export function register(on) {
10 on('session.start', async ($, e, next) => {
11 const result = await next(e)
12 await restoreMode($)
13 try {
14 await $.command.register({
15 name: COMMAND,
16 description: 'Keep a skill on for the whole session',
17 argumentHint: '<skill> | off',
18 })
19 } catch (error) {
20 $.ui.toast('sticky-mode could not add /' + COMMAND + ': ' + error.message)
21 }
22 return result
23 })
24
25 on('command.run', { command: COMMAND }, async ($, e) => {
26 const requested = e.args.trim().replace(/^\//, '')
27 const { value: current = null } = await $.state.get(MODE)
28
29 if (requested === '') {
30 return {
31 text: current
32 ? `Mode on: /${current.skill}. Run /mode off to exit.`
33 : 'No mode is on. Run /mode <skill>. In the terminal you can also pick a skill from the / menu and press Option+Enter.',
34 }
35 }
36
37 if (requested === 'off') {
38 if (!current) return { text: 'No mode is on.' }
39 await saveMode($, null)
40 return { text: `Mode off: /${current.skill}.`, context: [offNotice(current)] }
41 }
42
43 const lookup = await resolveSkill($, requested)
44 if (lookup.kind === 'ambiguous') {
45 return { text: `Several skills are named ${requested}: ${lookup.names.map((n) => '/' + n).join(', ')}. Run /mode with the full name of one.` }
46 }
47 if (lookup.kind === 'missing') {
48 const hint = lookup.suggestions.length ? ` Did you mean ${lookup.suggestions.map((n) => '/' + n).join(', ')}?` : ''
49 return { text: `No skill named /${requested} was found.${hint}` }
50 }
51 const mode = await engage($, lookup, false)
52 return { text: `Mode on: /${mode.skill}. It stays on every turn until /mode off.`, context: [fullText(mode)] }
53 })
54
55 on('command.run', async ($, e, next) => {
56 const result = await next(e)
57 const lookup = await resolveSkill($, e.command)
58 if (lookup.kind !== 'found') return result
59 const { value: mode = null } = await $.state.get(MODE)
60 if (mode?.skill !== lookup.name) await $.state.set(OFFER, lookup.name)
61 return result
62 })
63
64 on('prompt.edit', async ($, e, next) => {
65 if (e.inputText !== '\n') return next(e)
66 const picked = e.text.match(/^\/([\w:.-]+)\s*$/)
67 if (!picked || picked[1] === COMMAND) return next(e)
68 const lookup = await resolveSkill($, picked[1])
69 if (lookup.kind !== 'found') return next(e)
70 await engage($, lookup, true)
71 return { text: '', cursor: 0 }
72 })
73
74 on('session.compact', async ($, e, next) => {
75 const result = await next(e)
76 const { value: mode = null } = await $.state.get(MODE)
77 if (mode && !result?.skip) await $.state.set(MODE, { ...mode, needsFullText: true })
78 return result
79 })
80
81 on('prompt.submit', async ($, e, next) => {
82 const { value: mode = null } = await $.state.get(MODE)
83 const { value: notice = null } = await $.state.get(NOTICE)
84 if (!e.text.startsWith('/')) await $.state.set(OFFER, null)
85 const notes = []
86 if (notice) {
87 notes.push(notice)
88 await $.state.set(NOTICE, null)
89 }
90 if (mode) {
91 if (mode.needsFullText) await $.state.set(MODE, { ...mode, needsFullText: false })
92 notes.push(mode.needsFullText ? fullText(mode) : reminder(mode))
93 }
94 if (notes.length === 0) return next(e)
95 return next({ ...e, context: [...(e.context ?? []), ...notes] })
96 })
97
98 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
99 const { value: mode = null } = await $.state.get(MODE)
100 const { value: offer = null } = await $.state.get(OFFER)
101 if ((!mode && !offer) || e.props.hasSurvey) return next(e)
102 const { Box, Text, Button, Svg } = $.ui.resolve(e)
103 const icon = e.surface === 'desktop' ? [Svg({ source: ICON, alt: 'Session mode', width: 14, height: 14 })] : []
104 const rows = []
105
106 if (offer) {
107 rows.push(Box({
108 flexDirection: 'row',
109 gap: 1,
110 paddingX: 1,
111 children: [
112 ...icon,
113 Text({ color: MUTED, children: `${shortName(offer)} ran.` }),
114 Button({
115 key: 'keep-on',
116 label: 'Keep on',
117 variant: 'primary',
118 onPress: async () => {
119 const lookup = await resolveSkill($, offer)
120 await $.state.set(OFFER, null)
121 if (lookup.kind !== 'found') return
122 await engage($, lookup, false)
123 await $.state.set(NOTICE, keptNotice(lookup.name))
124 },
125 }),
126 Button({ key: 'dismiss-offer', label: 'Dismiss', role: 'dismiss', dimColor: true, onPress: () => $.state.set(OFFER, null) }),
127 ],
128 }))
129 }
130
131 if (mode) rows.push(Box({
132 flexDirection: 'row',
133 gap: 1,
134 paddingX: 1,
135 children: [
136 ...icon,
137 Text({ color: MUTED, bold: true, children: shortName(mode.skill) }),
138 Button({
139 key: 'exit-mode',
140 label: 'Exit mode',
141 role: 'dismiss',
142 dimColor: true,
143 onPress: async () => {
144 await saveMode($, null)
145 await $.state.set(NOTICE, offNotice(mode))
146 },
147 }),
148 ],
149 }))
150
151 const below = await next(e)
152 if (below) rows.push(below)
153 return rows.length === 1 ? rows[0] : Box({ flexDirection: 'column', children: rows })
154 })
155}
156
157async function engage($, skill, needsFullText) {
158 const mode = { skill: skill.name, text: skill.text, needsFullText }
159 await saveMode($, mode)
160 await $.state.set(NOTICE, null)
161 return mode
162}
163
164async function saveMode($, mode) {
165 await $.state.set(MODE, mode)
166 const key = 'mode:' + (await $.session.id())
167 if (mode) await $.store.set(key, mode.skill)
168 else await $.store.delete(key)
169}
170
171async function restoreMode($) {
172 const { value: current = null } = await $.state.get(MODE)
173 if (current) return
174 const saved = await $.store.get('mode:' + (await $.session.id()))
175 if (typeof saved !== 'string') return
176 const lookup = await resolveSkill($, saved)
177 if (lookup.kind !== 'found') return
178 await $.state.set(MODE, { skill: lookup.name, text: lookup.text, needsFullText: true })
179}
180
181async function resolveSkill($, requested) {
182 const commands = (await $.command.list()).filter((c) => c.source === 'plugin' || c.source === 'user')
183 const candidates = requested.includes(':')
184 ? [requested]
185 : commands.map((c) => c.name).filter((name) => name === requested || name.endsWith(':' + requested))
186
187 const found = []
188 for (const name of candidates) {
189 const location = await findSkill($, name)
190 if (location) found.push({ name, location })
191 }
192
193 if (found.length === 1) {
194 const { name, location } = found[0]
195 return { kind: 'found', name, text: await readSkill($, location) }
196 }
197 if (found.length > 1) return { kind: 'ambiguous', names: found.map((f) => f.name) }
198
199 const ranked = commands
200 .map(({ name }) => ({ name, rank: matchRank(name, requested) }))
201 .filter((c) => c.rank !== null)
202 .sort((a, b) => a.rank - b.rank || a.name.length - b.name.length)
203 const suggestions = []
204 for (const { name } of ranked) {
205 if (suggestions.length === SUGGESTION_LIMIT) break
206 if (await findSkill($, name)) suggestions.push(name)
207 }
208 return { kind: 'missing', suggestions }
209}
210
211function matchRank(name, query) {
212 const haystack = shortName(name).toLowerCase()
213 const needle = shortName(query).toLowerCase()
214 if (haystack.includes(needle)) return 0
215 let best = null
216 for (let start = haystack.indexOf(needle[0]); start !== -1; start = haystack.indexOf(needle[0], start + 1)) {
217 let at = 0
218 let end = start
219 for (; end < haystack.length && at < needle.length; end += 1) if (haystack[end] === needle[at]) at += 1
220 const span = end - start
221 if (at === needle.length && span <= needle.length * 2 && (best === null || span < best)) best = span
222 }
223 return best
224}
225
226function fullText(mode) {
227 return [
228 `# Session mode: /${mode.skill}`,
229 `The user turned on the /${mode.skill} skill as a session mode. Follow it on every turn until the user runs /mode off. Do not invoke the skill again; its full text is below.`,
230 '',
231 mode.text,
232 ].join('\n')
233}
234
235function shortName(skill) {
236 return skill.split(':').pop()
237}
238
239function keptNotice(skill) {
240 return `# Session mode: /${skill}\nThe user turned the /${skill} skill you just ran into a session mode. Keep following its instructions, given above, on every turn until the user runs /mode off.`
241}
242
243function offNotice(mode) {
244 return `The user turned off the /${mode.skill} session mode. Stop following its instructions from now on.`
245}
246
247function reminder(mode) {
248 return `Session mode /${mode.skill} is still on. Keep following its instructions, given in full earlier in this conversation.`
249}
250
251async function readSkill($, location) {
252 const raw = await $.fs.read(location.file)
253 return substitute(withoutFrontmatter(raw), location.vars).trim()
254}
255
256async function findSkill($, name) {
257 const home = await $.env.get('HOME')
258 if (name.includes(':')) {
259 const [plugin, skill] = name.split(':')
260 return findPluginSkill($, home, plugin, skill)
261 }
262
263 const cwd = await $.session.cwd()
264 for (const root of [`${cwd}/.claude/skills`, `${home}/.claude/skills`]) {
265 const dir = `${root}/${name}`
266 if (await $.fs.exists(`${dir}/SKILL.md`)) {
267 return { file: `${dir}/SKILL.md`, vars: { CLAUDE_SKILL_DIR: dir } }
268 }
269 }
270 return null
271}
272
273async function findPluginSkill($, home, plugin, skill) {
274 const registry = JSON.parse(await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`))
275 const id = Object.keys(registry.plugins).find((key) => key.split('@')[0] === plugin)
276 if (!id) return null
277
278 const root = registry.plugins[id][0].installPath
279 const dir = `${root}/skills/${skill}`
280 if (!(await $.fs.exists(`${dir}/SKILL.md`))) return null
281
282 const marketplace = id.split('@')[1]
283 return {
284 file: `${dir}/SKILL.md`,
285 vars: {
286 CLAUDE_PLUGIN_ROOT: root,
287 CLAUDE_SKILL_DIR: dir,
288 CLAUDE_PLUGIN_DATA: `${home}/.claude/plugins/data/${plugin}-${marketplace}`,
289 },
290 }
291}
292
293function withoutFrontmatter(markdown) {
294 return markdown.replace(/^---\n[\s\S]*?\n---\n/, '')
295}
296
297function substitute(text, vars) {
298 return text.replace(/\$\{(CLAUDE_[A-Z_]+)\}/g, (match, key) => vars[key] ?? match)
299}
300types/index.d.ts 10 lines1declare module 'claude-code' {
2 interface PluginState {
3 'sticky-mode': {
4 mode: { skill: string; text: string; needsFullText: boolean } | null
5 notice: string | null
6 offer: string | null
7 }
8 }
9}
10