SLOPSHOPPER

auto-session-title

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.

newcommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-session-title
› fix the failing auth test and add an audit log call ● auto-session-title: auto-title: renamed "OK" ⏺ 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 › /done ⎿ auto-session-title: Couldn't read this session's title. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Small Claude Code mods, installed as plugins from this repo.

ModWhat it does
auto-session-titleNames a session from its conversation, and lets you mark it done with a ✅

auto-session-title

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.

What it does

  • Titles the session on its own. After the 3rd prompt it reads the conversation and gives the session a short title (3–6 words). It does this a limited number of times (2 by default: the first title plus one refresh 10 prompts later), then stops looking at the session.
  • /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.

Install

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.

Settings

Each setting appears in the plugin's config menu, and is stored in your settings under pluginConfigs. A change reloads the mod.

SettingDefaultMeaning
firstTitleAtPrompt3Give the session its first title once this many prompts have been sent. 0 turns automatic titles off.
refreshEveryPrompts10Re-title this many prompts after the last automatic title, in case the work has moved on. 0 never refreshes.
maxAutoTitles2Stop titling on its own after this many automatic titles. 1 titles once and never looks again. 0 means no limit.
undoneAfterPrompts3After /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.
modelclaude-haiku-5-5The 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.

Cost

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.

Good to know

  • A session that was started before you installed the mod gets one automatic title the next time it completes a turn, because the mod has no record of it yet.
  • If you set a session's title by hand, the app may ask you to approve the mod's rename.
  • The mod's per-session counts are kept in the plugin's own store, so reloading or resuming a session doesn't restart them.

Update and uninstall

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.

Try it from a local checkout

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.

Source 1 files
hooks/register.tsx 156 lines
1import 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