Keeps Claude Code session titles in `feat|fix|chore(Scope): imperative summary` form: Haiku titles each session once from your first prompt, `/autoname`…

A Claude Code plugin that keeps your session titles tidy and consistent, in conventional-commit form: feat|fix|chore(Scope): imperative summary (for example feat(ClaudePlugins): auto-name sessions by convention).
/plugin marketplace add avegancafe/avegancafe-marketplace
/plugin install session-management@avegancafe-marketplace
Restart Claude Code afterwards; plugins load at session start.
| Piece | Behavior |
|---|---|
| Auto title | Haiku titles the session once, from the first message you send, and never touches it again. Wakeups, task notifications, other injected messages and slash commands don't count, and a session that already has a name (resumed, renamed, or started with --name) is left alone. |
/session-title | Shows the title it chose. |
/autoname [hint] | Re-titles the session any time from its whole history (your messages and Claude's replies; tool calls left out, long sessions cut to the first prompt plus the latest turns), then applies it with /rename. Optional text after it, e.g. /autoname mostly the bootstrap fix, is weighed on top of the history. |
Titles use a small Haiku call (about 60 output tokens) per title.
If your organization's managed settings keep Haiku out (an availableModels allowlist, or a Bedrock/Vertex/gateway setup that doesn't serve it), titles fall back to the session's current model, so they still appear at a slightly higher cost per title. If the org sets allowManagedHooksOnly, user-installed plugins load no hooks at all; this plugin needs to be installed through managed settings there.
hooks/register.ts 187 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TitleState } from '../types'
5
6const COMMAND = 'session-title'
7const AUTONAME = 'autoname'
8const MAX_LEN = 72
9const HISTORY_LEN = 6000
10
11const state = atom({ plugin: 'session-management', key: 'sessionTitle' } as const, { done: false } as TitleState)
12
13/** A trailing word a truncated title shouldn't end on. */
14const DANGLING_RE = /\s+(and|or|but|the|a|an|to|of|for|with|in|on)$/i
15
16export const TITLE_RE =/^(fix|chore|feat)\([A-Z][A-Za-z0-9]*\): [a-z0-9].*\S$/
17
18const RULES = `You name coding sessions. Reply with ONE line and nothing else, in exactly this form:
19
20<type>(<Scope>): <description>
21
22- <type> is one of: feat (new capability), fix (something broken), chore (maintenance, config, docs, refactors, investigations, tooling).
23- <Scope> is ONE PascalCase word naming the high-level area: a product area, system or repo (e.g. Auth, Billing, ClaudePlugins, Beads, CI, DevEnv). No spaces, no dots, no slashes.
24- <description> is an imperative, lowercase phrase that says what the session is doing ("add retry to export uploads", not "added" or "adding"). No trailing period. Keep the whole line under ${MAX_LEN} characters.`
25
26/** Normalizes a model reply into a valid title, or undefined if it can't. */
27export const clean = (raw: string): string | undefined => {
28 const line = raw
29 .split('\n')
30 .map(l => l.trim().replace(/^[`"'*]+|[`"'*.]+$/g, ''))
31 .find(l => l.length > 0)
32 if (!line) return undefined
33 const m = /^(fix|chore|feat)\s*\(\s*([A-Za-z0-9]+)\s*\)\s*:\s*(.+)$/i.exec(line)
34 const [, type = '', scope = '', desc = ''] = m ?? []
35 if (!type || !scope || !desc) return undefined
36 let title = `${type.toLowerCase()}(${scope.charAt(0).toUpperCase()}${scope.slice(1)}): ${desc.charAt(0).toLowerCase()}${desc.slice(1)}`
37 if (title.length > MAX_LEN) {
38 // Cut at a word boundary, then drop filler the cut left dangling ("… skipping and").
39 title = title.slice(0, title.lastIndexOf(' ', MAX_LEN))
40 while (DANGLING_RE.test(title)) title = title.replace(DANGLING_RE, '')
41 }
42 return TITLE_RE.test(title) ? title : undefined
43}
44
45const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
46
47/** API errors that mean "this model isn't yours to call", not "try later". */
48const MODEL_REFUSED = new Set([400, 401, 403, 404])
49
50/**
51 * Asks Haiku for a title under RULES; `body` is what it titles from.
52 *
53 * A managed org can keep Haiku out of reach: its `availableModels` allowlist
54 * makes the engine refuse the call, or its provider/gateway answers 4xx for a
55 * model it doesn't serve. Either way the title comes from the session's own
56 * model instead, which the org has necessarily allowed.
57 */
58const generate = async ($: EngineInterface, body: string) => {
59 const prompt = `${body}\n\nTitle:`
60 const ask = { system: RULES, prompt, maxTokens: 60, effort: 'low' } as const
61 const tried = new Set<string>()
62 for (const next of [async () => 'haiku', () => $.session.model()]) {
63 const model = await next().catch(() => undefined)
64 if (!model || tried.has(model)) continue
65 tried.add(model)
66 const r = await $.model.complete({ ...ask, model }).catch(() => undefined)
67 if (r?.isAnswered) return clean(r.text)
68 if (r && !(r.reason === 'api-error' && r.status !== null && MODEL_REFUSED.has(r.status))) return undefined
69 }
70 return undefined
71}
72
73/** Injected rows (`<task-notification>`, …) and slash commands aren't the person's words. */
74const isPersonText = (text: string) => !text.startsWith('<') && !text.startsWith('/')
75
76/**
77 * The conversation so far as `Person:` / `Claude:` lines, tool calls dropped.
78 * Over `max` chars it keeps the first line (what the session set out to do)
79 * and the newest tail (where it is now).
80 */
81export const digest = (messages: readonly { role: string; text: string }[], max = HISTORY_LEN): string => {
82 const lines = messages.flatMap(m => {
83 const text = m.text.trim()
84 if (!text) return []
85 if (m.role === 'user') return isPersonText(text) ? [`Person: ${clip(text, 1500)}`] : []
86 return [`Claude: ${clip(text, 600)}`]
87 })
88 const all = lines.join('\n\n')
89 if (all.length <= max || lines.length < 2) return clip(all, max)
90 const [first = '', ...rest] = lines
91 const tail: string[] = []
92 let room = max - first.length - 10
93 for (const line of rest.reverse()) {
94 if (line.length + 2 > room) break
95 tail.unshift(line)
96 room -= line.length + 2
97 }
98 return [first, '…', ...tail].join('\n\n')
99}
100
101/** Prompts typed by a person (composer, Remote Control, or `-p` / SDK); not wakeups or injected messages. */
102const isFromPerson = (kind: string) => kind === 'composer' || kind === 'bridge' || kind === 'sdk'
103
104/**
105 * Runs `/rename` from a timer: a hook can't run a command itself (it would
106 * wait on the turn the hook holds), so it runs once the calling hook returns.
107 */
108const rename = ($: EngineInterface, title: string, from: string) =>
109 $.clock.after(0, () => {
110 $.command.run({ command: 'rename', args: title }).catch((err: unknown) => {
111 $.ui.log(`${from}: rename failed: ${err instanceof Error ? err.message : String(err)}`)
112 })
113 })
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 await $.command.register({ name: COMMAND, description: 'Show the auto session title' })
118 await $.command.register({
119 name: AUTONAME,
120 description: 'Re-title this session from its history, by the session-title convention',
121 argumentHint: '[optional hint]',
122 })
123 return next(e)
124 })
125
126 on('command.run', { command: COMMAND }, async $ => {
127 const s = await read($, state)
128 return { text: s.title ?? '(no title yet)' }
129 })
130
131 // Re-titles the session on demand from everything said so far, under the
132 // same RULES; anything after `/autoname` is a hint on top of the history.
133 on('command.run', { command: AUTONAME }, async ($, e) => {
134 const history = digest(await $.session.messages())
135 const hint = e.args.trim()
136 if (!history && !hint) return { text: 'Nothing to title yet.' }
137 const body = [history && `Session so far:\n${history}`, hint && `Hint from the person: ${clip(hint, 300)}`]
138 .filter(Boolean)
139 .join('\n\n')
140 const title = await generate($, body).catch(() => undefined)
141 if (!title) return { text: "Couldn't generate a title. Try `/autoname <hint>`." }
142 await update($, state, x => ({ ...x, title, done: true }))
143 rename($, title, AUTONAME)
144 return { text: `Renamed: ${title}` }
145 })
146
147 // A session the person names by hand is theirs: the first-prompt title
148 // never overwrites it.
149 on('command.run', { command: 'rename' }, async ($, e, next) => {
150 await update($, state, x => ({ ...x, done: true }))
151 return next(e)
152 })
153
154 // Titles the session once, from the first message the person sends, and
155 // never touches it again. This is `prompt.submit`, not the classic
156 // UserPromptSubmit: on a managed machine the built-in sec-default plugin
157 // sends classic events past every user-installed plugin's hooks.
158 on('prompt.submit', async ($, e, next) => {
159 const result = await next(e)
160 if (result.drop !== undefined || e.command || e.isMeta || !isFromPerson(e.origin.kind)) return result
161
162 const s = await read($, state)
163 if (s.done) return result
164
165 const text = e.text.trim()
166 if (!text || !isPersonText(text)) return result
167
168 // Mark done first, so a slow or failed call never retries on a later prompt.
169 await update($, state, x => ({ ...x, done: true }))
170 // A resumed session already has its name: the person spoke before this prompt.
171 const earlier = (await $.session.messages()).filter(
172 m => m.role === 'user' && m.text.trim() !== text && isPersonText(m.text.trim()),
173 )
174 if (earlier.length > 0) return result
175
176 // Titled from a timer, off the turn's critical path: the prompt has
177 // already entered, and the hook returns without waiting on the model.
178 $.clock.after(0, async () => {
179 const title = await generate($, `Request:\n${clip(text, 1500)}`).catch(() => undefined)
180 if (!title) return
181 await update($, state, x => ({ ...x, title }))
182 rename($, title, COMMAND)
183 })
184 return result
185 })
186}
187types/index.d.ts 12 lines1/**
2 * `title` is the title this mod produced, if any; `done` is set once the
3 * session's first prompt has been handled, so the mod never runs again.
4 */
5export type TitleState = { title?: string; done: boolean }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'session-management': { sessionTitle: TitleState }
10 }
11}
12