SLOPSHOPPER

sticky-mode

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.

newbandcommandtoastprompt
v0.4.0no licenseupdated 2026-10-02daviesayo/rkt-cc-mods/plugins/sticky-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sticky-mode
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mode ⎿ sticky-mode: No mode is on. Run /mode <skill>. In the terminal you can also pick a skill from the / menu and press Option+E ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
Source 2 files
hooks/sticky-mode.mjs 300 lines
1const 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}
300
types/index.d.ts 10 lines
1declare 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