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

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.
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.
[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./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 didhooks/register.ts 237 lines1import { 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}
237hooks/prompt.ts 55 lines1export 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}
55types/index.d.ts 17 lines1export 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