SLOPSHOPPER

auto-title

Keeps each session's title clear and current as the conversation changes, as [Category] Plain title; /title pins or sets it.

newcommandstatusmodel
v0.2.0no licenseupdated 2026-10-23emersonmccuin-pixel/auto-title
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-title
› 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 › /title ⎿ auto-title: Title pinned. /title auto turns auto-titling back on. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ auto-title: title: pinned
README

auto-title

A Claude Code mod that keeps each session's title clear and current as the conversation changes, as [Category] Plain title, for example [Reporting] Bookings by product report.

  • After each turn, Haiku reads the recent messages and renames the session only when the topic has really changed.
  • You pick the categories (see Settings).
  • If you rename a session yourself, the mod stops renaming it.
  • Optional: file each session into a desktop-app sidebar section named for its category.

Install

At the prompt of a terminal session:

/plugin install auto-title --marketplace emersonmccuin-pixel/auto-title

Answer y to add the marketplace, then pick a scope (user scope runs it in every session).

From a shell:

claude plugin install auto-title --marketplace emersonmccuin-pixel/auto-title

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with emersonmccuin-pixel/auto-title, then Add plugin.

Settings

  • Categories: the [prefix] list, written as Name: what it covers; Name: what it covers. Other is always added. Starts with a generic list: replace it with your own work areas.
  • File into sidebar sections: off by default. On, each session is also moved into a sidebar section named for its category (created the first time). A session you filed in a section of your own stays put.

Commands

  • /title: pin the current title
  • /title auto: resume auto-titling
  • /title <text>: set a title and pin it
  • /title sections on / /title sections off: section filing for this session
  • /title status: what the mod last did
Source 3 files
hooks/register.ts 237 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PendingTitle, TitleMode } from '../types'
5import { DEFAULT_CATEGORIES, KEEP, MAX_TITLE, parseCategories, systemPrompt } from './prompt'
6
7const mode = atom({ plugin: 'auto-title', key: 'mode' } as const, 'auto' as TitleMode)
8const pending = atom({ plugin: 'auto-title', key: 'pending' } as const, null as PendingTitle | null)
9const applied = atom({ plugin: 'auto-title', key: 'applied' } as const, null as string | null)
10const current = atom({ plugin: 'auto-title', key: 'current' } as const, null as string | null)
11// null follows the `sections` option; /title sections on|off sets it for this session.
12const sections = atom({ plugin: 'auto-title', key: 'sections' } as const, null as boolean | null)
13const note = atom({ plugin: 'auto-title', key: 'note' } as const, 'no title proposed yet')
14
15const RECENT = 16
16const PER_MESSAGE = 600
17
18// Set from the plugin's options each time the module loads.
19let NAMES: readonly string[] = []
20let SYSTEM = ''
21let isSectionsDefault = false
22
23let isRefreshing = false
24
25const clean = (text: string): string =>
26  (text.split('\n')[0] ?? '')
27    .replace(/^["'`*\s]+|["'`*.\s]+$/g, '')
28    .replace(/^(title|label):\s*/i, '')
29    .slice(0, MAX_TITLE)
30    .trim()
31
32// Holds the model's reply to [Category] Plain title, filing an unknown or missing category under Other.
33const label = (text: string): string => {
34  const title = clean(text)
35  if (title === '' || title.toUpperCase() === KEEP) return title
36  const parts = /^\[([^\]]+)\]\s*(.+)$/.exec(title)
37  const named = NAMES.find(name => name.toLowerCase() === parts?.[1]?.trim().toLowerCase())
38  return `[${named ?? 'Other'}] ${parts?.[2] ?? title}`.slice(0, MAX_TITLE)
39}
40
41const categoryOf = (title: string | null): string | undefined => {
42  const named = /^\[([^\]]+)\]/.exec(title ?? '')?.[1]
43  return NAMES.find(name => name === named)
44}
45
46const isFiling = async ($: EngineInterface) => (await read($, sections)) ?? isSectionsDefault
47
48const showMode = async ($: EngineInterface) => {
49  $.ui.status(`title: ${await read($, mode)}`)
50}
51
52// Calls one of the app's own tools and reads its text reply as JSON where it is JSON.
53const appTool = async ($: EngineInterface, server: string, tool: string, args: Record<string, unknown>) => {
54  const result = await $.mcp.call(server, tool, args)
55  const text = result.content.map(block => ('text' in block ? String(block.text) : '')).join('')
56  if (result.isError) throw new Error(`${tool}: ${text.slice(0, 200)}`)
57  try {
58    return JSON.parse(text) as unknown
59  } catch {
60    return text
61  }
62}
63
64// Renames the session now; where the app refuses, the next prompt carries the title instead.
65const applyTitle = async ($: EngineInterface, title: string) => {
66  try {
67    await appTool($, 'ccd_session_mgmt', 'set_session_title', { session_id: 'self', title })
68    await update($, applied, () => title)
69    await update($, current, () => title)
70    await update($, pending, () => null)
71    return 'renamed now'
72  } catch (error) {
73    await update($, pending, () => ({ title, isManual: false }))
74    return `rename on next message (${String(error).slice(0, 120)})`
75  }
76}
77
78type Section = { id: string; kind: string; name: string }
79type Session = { group: { id: string; name: string } | null }
80
81// Files the session under the section named for its category, creating the section the first time.
82// A session the person filed elsewhere by hand stays where they put it.
83const fileUnder = async ($: EngineInterface, category: string) => {
84  if (!(await isFiling($))) return 'sections off'
85  const self = (await appTool($, 'ccd_session_mgmt', 'get_session', { session_id: 'self' })) as Session
86  const group = self.group?.name ?? null
87  if (group === category) return `in ${category}`
88  if (group !== null && !NAMES.includes(group)) return `left in ${group} (filed by hand)`
89
90  const listed = (await appTool($, 'ccd_sidebar', 'list_sections', {})) as Section[]
91  let id = listed.find(s => s.kind === 'manual' && s.name === category)?.id
92  if (id === undefined) {
93    const made = await appTool($, 'ccd_sidebar', 'create_section', { name: category })
94    id = typeof made === 'object' && made !== null && 'id' in made
95      ? String((made as { id: unknown }).id)
96      : /[\w-]{8,}/.exec(String(made))?.[0]
97  }
98  if (id === undefined) return `could not create ${category}`
99  await appTool($, 'ccd_sidebar', 'move_sessions', { session_ids: ['self'], section_id: id })
100  return `moved to ${category}`
101}
102
103const refresh = async ($: EngineInterface) => {
104  if (isRefreshing || (await read($, mode)) !== 'auto') return
105  isRefreshing = true
106  try {
107    const messages = await $.session.messages()
108    if ('deny' in messages) return
109    const recent = messages
110      .filter(m => m.text.trim() !== '')
111      .slice(-RECENT)
112      .map(m => `${m.role.toUpperCase()}: ${m.text.trim().slice(0, PER_MESSAGE)}`)
113    if (!recent.some(line => line.startsWith('USER:'))) return
114
115    const held = (await read($, pending))?.title ?? (await read($, current))
116    const reply = await $.model.complete({
117      model: 'haiku',
118      system: SYSTEM,
119      prompt: `Current label: ${held ?? '(none yet)'}\n\nRecent messages, oldest first:\n\n${recent.join('\n\n')}`,
120      maxTokens: 40,
121      effort: 'low',
122      timeoutMs: 20000,
123    })
124    if (!reply.isAnswered) {
125      await update($, note, () => `model gave no title: ${reply.reason}`)
126      return
127    }
128
129    const title = label(reply.text)
130    const isNew = title !== '' && title.toUpperCase() !== KEEP && title !== held
131    if ((await read($, mode)) !== 'auto') return
132    const renamed = isNew ? await applyTitle($, title) : `kept "${held}"`
133    const category = categoryOf(isNew ? title : held)
134    const filed = category === undefined ? 'no category' : await fileUnder($, category).catch(error => `filing failed: ${String(error).slice(0, 120)}`)
135    await update($, note, () => `${isNew ? `"${title}"` : 'no change'}: ${renamed}; ${filed}`)
136  } catch (error) {
137    await update($, note, () => `refresh failed: ${String(error).slice(0, 160)}`)
138  } finally {
139    isRefreshing = false
140  }
141}
142
143export const register: Register = (on, options) => {
144  const listed = typeof options.categories === 'string' && options.categories.trim() !== '' ? options.categories : DEFAULT_CATEGORIES
145  const categories = parseCategories(listed)
146  NAMES = categories.map(c => c.name)
147  SYSTEM = systemPrompt(categories)
148  isSectionsDefault = options.sections === true
149
150  on('session.start', async ($, e, next) => {
151    await $.command.register({
152      name: 'title',
153      description: 'Session title: /title pins it, /title auto resumes, /title <text> sets it, /title sections on|off, /title status',
154    })
155    await showMode($)
156
157    return next(e)
158  })
159
160  on('command.run', { command: 'title' }, async ($, e) => {
161    const args = e.args.trim()
162    const word = args.toLowerCase()
163
164    if (args === '') {
165      await update($, mode, () => 'pinned')
166      await update($, pending, () => null)
167      await showMode($)
168      return { text: 'Title pinned. /title auto turns auto-titling back on.' }
169    }
170
171    if (word === 'auto') {
172      await update($, mode, () => 'auto')
173      await showMode($)
174      void refresh($)
175      return { text: 'Auto-titling on. The title follows the conversation again.' }
176    }
177
178    if (word === 'status') {
179      const lines = [
180        `mode: ${await read($, mode)}`,
181        `sections: ${(await isFiling($)) ? 'on' : 'off'}`,
182        `categories: ${NAMES.join(', ')}`,
183        `title the mod last set: ${(await read($, applied)) ?? 'none'}`,
184        `waiting to apply: ${(await read($, pending))?.title ?? 'nothing'}`,
185        `last run: ${await read($, note)}`,
186      ]
187      return { text: lines.join('\n') }
188    }
189
190    if (word === 'sections on' || word === 'sections off') {
191      const isOn = word === 'sections on'
192      await update($, sections, () => isOn)
193      if (isOn) void refresh($)
194      return { text: `Sections ${isOn ? 'on: sessions are filed under their category' : 'off: sessions stay where they are'}.` }
195    }
196
197    const title = clean(args)
198    await update($, mode, () => 'pinned')
199    await showMode($)
200    const renamed = await applyTitle($, title)
201    if (renamed !== 'renamed now') await update($, pending, () => ({ title, isManual: true }))
202    return { text: `Title set to "${title}" (${renamed}) and pinned.` }
203  })
204
205  on('classic.UserPromptSubmit', async ($, e, next) => {
206    const result = await next(e)
207    const seen = e.session_title ?? null
208    const lastSet = await read($, applied)
209
210    // A title that changed since the mod last set one was renamed by hand: stop auto-titling.
211    if (seen !== null && lastSet !== null && seen !== lastSet && (await read($, mode)) === 'auto') {
212      await update($, mode, () => 'pinned')
213      await update($, pending, () => null)
214      await update($, note, () => `you renamed it to "${seen}": pinned`)
215      await showMode($)
216    }
217    await update($, current, () => seen)
218
219    const next_ = await read($, pending)
220    const isAllowed = next_ !== null && (next_.isManual || (await read($, mode)) === 'auto')
221    if (next_ === null || !isAllowed || next_.title === seen) return result
222
223    await update($, applied, () => next_.title)
224    await update($, current, () => next_.title)
225    await update($, pending, () => null)
226
227    return { ...result, sessionTitle: next_.title }
228  })
229
230  on('turn.complete', async ($, e, next) => {
231    const result = await next(e)
232    if (e.agentId === undefined && !e.isAborted) void refresh($)
233
234    return result
235  })
236}
237
hooks/prompt.ts 55 lines
1export type Category = { name: string; covers: string }
2
3// The starter list each person replaces with their own in the plugin's settings (the `categories` option):
4// "Name: what it covers; Name: what it covers; ...". Other is always added last.
5export const DEFAULT_CATEGORIES =
6  'Sales: pipeline, deals, reps, sales process; ' +
7  'Marketing: campaigns, ads, email, events, lists; ' +
8  'Reporting: reports, dashboards, metrics; ' +
9  'Data: data quality, duplicates, syncs, imports; ' +
10  'Systems: CRM and tool setup, integrations, automation, workflows; ' +
11  'Planning: plans, projects, priorities, roadmaps; ' +
12  'Admin: own tasks, inbox, calendar, meetings; ' +
13  'Claude Setup: Claude Code itself, mods, skills, tooling'
14
15export const MAX_TITLE = 60
16export const KEEP = 'KEEP'
17
18export const parseCategories = (text: string): Category[] => {
19  const listed = text
20    .split(';')
21    .map(entry => entry.trim())
22    .filter(entry => entry !== '')
23    .map(entry => {
24      const cut = entry.indexOf(':')
25      return cut < 0
26        ? { name: entry, covers: '' }
27        : { name: entry.slice(0, cut).trim(), covers: entry.slice(cut + 1).trim() }
28    })
29    .filter(c => c.name !== '' && !/[[\]]/.test(c.name))
30  const isOtherListed = listed.some(c => c.name.toLowerCase() === 'other')
31  return isOtherListed ? listed : [...listed, { name: 'Other', covers: 'anything that fits none of the above' }]
32}
33
34export const systemPrompt = (categories: readonly Category[]): string => {
35  const lines = categories.map(c => (c.covers === '' ? `- ${c.name}` : `- ${c.name}: ${c.covers}`)).join('\n')
36
37  return `You label work sessions in a sidebar. Given the current label and the most recent messages of a conversation, return the label for what the conversation is about NOW.
38
39Format: [Category] Plain title
40
41Category: exactly one name from this list, the one the work most belongs to:
42${lines}
43
44Plain title:
45- 2 to 5 words, plain everyday words. Someone glancing at the sidebar knows exactly what this session is.
46- Name the specific thing (the system, report, person, form, workflow) and what's happening to it: "Webinar lead UTMs missing","Build auto-title mod", "Fix round robin skew", "Bookings by product report".
47- No filler ("Discussion of", "Help with", "Question about", "Investigation into"), no jargon beyond names of systems and things, no dates unless they are the point.
48- No quotes, no trailing punctuation, no emoji. Whole label at most ${MAX_TITLE} characters.
49
50Weight the latest messages most: the label follows where the work has gone.
51If the current label is already in this format and still describes the work well, reply with exactly ${KEEP}.
52
53Reply with the label or ${KEEP} and nothing else.`
54}
55
types/index.d.ts 17 lines
1export type TitleMode = 'auto' | 'pinned'
2
3export type PendingTitle = { title: string; isManual: boolean }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'auto-title': {
8      mode: TitleMode
9      pending: PendingTitle | null
10      applied: string | null
11      current: string | null
12      sections: boolean | null
13      note: string
14    }
15  }
16}
17