Titles the session after a few prompts and refreshes it as the work drifts; /done and /undone add or remove a checkmark in front of the title.

Small Claude Code mods, installed as plugins from this repo.
| Mod | What it does |
|---|---|
auto-session-title | Names a session from its conversation, and lets you mark it done with a ✅ |
Sessions that start with "can you help me with…" end up with titles that say nothing. This mod names the session after a few prompts, and gives you commands to rename it or mark it finished.
/rename-session names the session from the conversation right now. /rename-session My own title sets exactly that title. Either one counts as you taking over: automatic titling stops for that session./keep-title locks the current title: no rename, no model call, and automatic titling stops for that session. /auto-title turns it back on./done puts ✅ in front of the title. /undone takes it off. If you keep working after /done, the ✅ comes off by itself after a few more prompts (no model call). Only a ✅ that /done put on is removed, never one you added by hand. Automatic titles and /rename-session keep the ✅ if it's there.You need Claude Code with hooks modules turned on (the desktop app's Code tab does this). The mod renames sessions through the desktop app's session-management tool, so it has only been tested there.
1. Add the marketplace (once per machine). In Claude Code:
/plugin marketplace add fernandokobayashi-telus/claude-mods
or from a terminal:
claude plugin marketplace add fernandokobayashi-telus/claude-mods
2. Install the mod:
/plugin install auto-session-title@claude-mods
or:
claude plugin install auto-session-title@claude-mods
Pick the user scope if it asks, so the mod works in every project. Installing may show a screen for the settings below; the defaults are fine.
3. Start a new session. The mod loads in sessions started after the install. Check it worked by typing /done and then /undone: the title should gain and lose a ✅.
Check what's installed with claude plugin list.
Each setting appears in the plugin's config menu, and is stored in your settings under pluginConfigs. A change reloads the mod.
| Setting | Default | Meaning |
|---|---|---|
firstTitleAtPrompt | 3 | Give the session its first title once this many prompts have been sent. 0 turns automatic titles off. |
refreshEveryPrompts | 10 | Re-title this many prompts after the last automatic title, in case the work has moved on. 0 never refreshes. |
maxAutoTitles | 2 | Stop titling on its own after this many automatic titles. 1 titles once and never looks again. 0 means no limit. |
undoneAfterPrompts | 3 | After /done, take the ✅ off again once this many more prompts have been sent. Uses no model call. 0 leaves the mark until you run /undone. |
model | claude-haiku-5-5 | The model that writes the title. It's one short call, so a small model is plenty. |
checkmark | ✅ | What /done puts in front of the title and /undone takes off. |
/rename-session, /keep-title, /auto-title, /done and /undone always work, whatever these are set to.
An automatic title is one short model call (a few hundred tokens in, a few words out). With the defaults that is at most two calls per session. /done and /undone make no model call; /rename-session makes one when you don't give it a title.
Update with claude plugin marketplace update claude-mods, then claude plugin update auto-session-title@claude-mods. Remove it with claude plugin uninstall auto-session-title@claude-mods.
git clone https://github.com/fernandokobayashi-telus/claude-mods
claude plugin marketplace add ./claude-mods
claude plugin install auto-session-title@claude-mods
The installed plugin reads from the checkout in place, so don't move or delete the folder.
hooks/register.tsx 156 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const TITLE_SYSTEM =
4 'You name coding sessions. Reply with only a title: 3-6 words, no quotes, no trailing punctuation, specific to what the work is about.'
5
6type Dollar = EngineInterface
7type TitleState = { count: number; at: number; doneAt?: number }
8
9async function currentTitle($: Dollar): Promise<string | undefined> {
10 const got = await $.mcp.call('ccd_session_mgmt', 'get_session', { session_id: 'self' }).catch(() => undefined)
11 const block = got?.content?.[0]
12 if (!block || block.type !== 'text') return undefined
13 try {
14 return JSON.parse(block.text).title
15 } catch {
16 return undefined
17 }
18}
19
20async function rename($: Dollar, title: string): Promise<boolean> {
21 const done = await $.mcp
22 .call('ccd_session_mgmt', 'set_session_title', { session_id: 'self', title })
23 .catch(() => undefined)
24 const isRenamed = done !== undefined && !done.isError
25 $.ui.log(`auto-title: ${isRenamed ? 'renamed' : 'could not rename'} "${title}"`)
26 return isRenamed
27}
28
29async function generateTitle($: Dollar, model: string): Promise<string | undefined> {
30 const transcript = (await $.session.messages())
31 .map(m => `${m.role}: ${m.text.slice(0, 400)}`)
32 .join('\n')
33 .slice(-6000)
34 const reply = await $.model.complete({ model, system: TITLE_SYSTEM, prompt: transcript })
35 if (!reply.isAnswered) {
36 $.ui.log(`auto-title: model reply ${JSON.stringify(reply)}`)
37 return undefined
38 }
39 return reply.text.trim().split('\n')[0].replace(/^["']|["']$/g, '').slice(0, 60) || undefined
40}
41
42async function stateKey($: Dollar): Promise<string> {
43 return `titles:${await $.session.id()}`
44}
45
46async function loadState($: Dollar): Promise<TitleState> {
47 const saved = (await $.store.get(await stateKey($))) as Partial<TitleState> | undefined
48 return { count: saved?.count ?? 0, at: saved?.at ?? 0, doneAt: saved?.doneAt }
49}
50
51async function saveState($: Dollar, patch: Partial<TitleState>): Promise<void> {
52 await $.store.set(await stateKey($), { ...(await loadState($)), ...patch })
53}
54
55export const register: Register = (on, options) => {
56 // 0 turns a number off. Defaults live in plugin.json.
57 const count = (value: unknown) => Math.max(0, Math.floor(Number(value)) || 0)
58 const FIRST_AT = count(options.firstTitleAtPrompt)
59 const REFRESH_EVERY = count(options.refreshEveryPrompts)
60 const MAX_AUTO = count(options.maxAutoTitles) // 0: no limit
61 const UNDONE_AFTER = count(options.undoneAfterPrompts) // 0: a /done mark stays until /undone
62 const MODEL = String(options.model || 'claude-haiku-5-5')
63 const CHECK = String(options.checkmark || '✅')
64 const TAKEN_OVER = Number.MAX_SAFE_INTEGER // a manual rename ends automatic ones for the session
65
66 on('session.start', async ($, e, next) => {
67 await $.command.register({ name: 'done', description: `Mark this session finished: put ${CHECK} in front of its title` })
68 await $.command.register({ name: 'undone', description: `Take the ${CHECK} off this session's title` })
69 await $.command.register({
70 name: 'rename-session',
71 description: 'Rename this session: name it from the conversation, or pass the title you want',
72 argumentHint: '[new title]',
73 })
74 await $.command.register({ name: 'keep-title', description: "Keep this session's title: stop automatic renaming for this session" })
75 await $.command.register({ name: 'auto-title', description: 'Let this session be titled automatically again' })
76 return next(e)
77 })
78
79 on('command.run', { command: 'keep-title' }, async $ => {
80 await saveState($, { count: TAKEN_OVER, at: await $.session.turns() })
81 const title = await currentTitle($)
82 return { text: title ? `Keeping: ${title}` : 'Keeping the current title.' }
83 })
84
85 on('command.run', { command: 'auto-title' }, async $ => {
86 await saveState($, { count: 0, at: await $.session.turns() })
87 return {
88 text: FIRST_AT === 0 ? 'Unlocked, but automatic titles are off in the plugin settings.' : 'Automatic titling is back on for this session.',
89 }
90 })
91
92 on('command.run', { command: 'done' }, async $ => {
93 const title = await currentTitle($)
94 if (!title) return { text: "Couldn't read this session's title." }
95 if (title.startsWith(CHECK)) return { text: `Already marked: ${title}` }
96 await rename($, `${CHECK} ${title}`)
97 await saveState($, { doneAt: await $.session.turns() })
98 return { text: `Marked done: ${CHECK} ${title}` }
99 })
100
101 on('command.run', { command: 'undone' }, async $ => {
102 const title = await currentTitle($)
103 if (!title) return { text: "Couldn't read this session's title." }
104 if (!title.startsWith(CHECK)) return { text: `Not marked: ${title}` }
105 const plain = title.slice(CHECK.length).trim()
106 await rename($, plain)
107 await saveState($, { doneAt: undefined })
108 return { text: `Unmarked: ${plain}` }
109 })
110
111 on('command.run', { command: 'rename-session' }, async ($, e) => {
112 const wanted = e.args.trim().slice(0, 100)
113 const title = wanted || (await generateTitle($, MODEL))
114 if (!title) return { text: "Couldn't come up with a title." }
115
116 const hadCheck = (await currentTitle($))?.startsWith(CHECK) ?? false
117 const full = hadCheck ? `${CHECK} ${title}` : title
118 if (!(await rename($, full))) return { text: "Couldn't rename the session." }
119
120 await saveState($, { count: TAKEN_OVER, at: await $.session.turns() })
121 return { text: `Renamed: ${full}` }
122 })
123
124 // A fitting title after a few prompts, a limited number of times.
125 on('turn.complete', async ($, e, next) => {
126 const result = await next(e)
127 if (e.agentId !== undefined) return result
128
129 const state = await loadState($)
130 const turns = await $.session.turns()
131
132 // Work went on after /done: take the mark off. No model call, only the app's own rename.
133 if (UNDONE_AFTER > 0 && state.doneAt !== undefined && turns - state.doneAt >= UNDONE_AFTER) {
134 const title = await currentTitle($)
135 if (title?.startsWith(CHECK)) {
136 await rename($, title.slice(CHECK.length).trim())
137 $.ui.toast(`Work went on after /done: removed ${CHECK} from the title`)
138 }
139 await saveState($, { doneAt: undefined })
140 }
141
142 if (FIRST_AT === 0 || (MAX_AUTO > 0 && state.count >= MAX_AUTO)) return result
143 const isDue =
144 state.count === 0 ? turns >= FIRST_AT : REFRESH_EVERY > 0 && turns - state.at >= REFRESH_EVERY
145 if (!isDue) return result
146
147 const title = await generateTitle($, MODEL)
148 if (title) {
149 const hadCheck = (await currentTitle($))?.startsWith(CHECK) ?? false
150 await rename($, hadCheck ? `${CHECK} ${title}` : title)
151 await saveState($, { count: state.count + 1, at: turns })
152 }
153 return result
154 })
155}
156