Suggests session names after each Claude reply (band above the prompt, /rs to force), following a configurable naming convention.

A Claude Code mod that suggests a name for your session, following your naming convention.
After each Claude reply, Haiku reads the conversation (your messages and Claude's text replies, no tool output) and suggests 3 names in a band above the prompt:
Session name: [ feat/clmod-mod: implement session namer ] [ … ] [ … ] [ Reroll ] [ Dismiss ] [ Stop ] ≈$0.0003 (2.6k tokens) · session …
Click one to rename the session (/rename). Once a name is picked or dismissed, the band stays hidden until the topic of the conversation actually changes. Reroll asks for 3 different names; Stop turns the suggestions off for the rest of the session. While names are being written, a spinner turns in the band (⠹ generating names…): on a reroll, on /rs, and after a reply when the band is already up or the session has no name yet.
In a Claude Code terminal session:
/plugin install session-namer --marketplace lucaslenglet/session-namer
Answer y to add the marketplace, pick a scope (user scope = every session), then set the options (or keep the defaults). The mod is active right away.
| Band above the prompt | Click a name, or ctrl+x tab then 1-3; r rerolls, x dismisses, s stops for this session |
/rs | Ask for suggestions now (different ones if the band is shown); works even when stopped |
/rs stop / /rs start | Turn automatic suggestions off / back on for this session |
/rs help | How it works, your current settings and a live preview of the format |
/rename … | A manual rename is remembered too: no suggestion until the topic changes |
In /config, search for session name:
| Setting | Default | |
|---|---|---|
| Session name format | {type:L}-{id}/{project:L:,5}-{kind:L}: {desc:L:w2,5} | How names are built |
| Session name rules for Haiku | (empty) | Free text that overrides the default rules, e.g. description in French; allowed types: feat, fix, chore |
| Session name project code | (empty: derived from the folder name) | Fixed value for {project} |
| Session name cost display | on | Show the Haiku cost in the band and in /rs (/rs help always shows the session total) |
Text is kept as typed; each {field} is replaced by a value Haiku finds in the conversation. An empty field disappears together with the separator next to it.
| Field | Meaning | Filled |
|---|---|---|
{type} | kind of work item: bug, us, pr, feat… | only if stated |
{id} | its ticket / PR number | only if stated, never invented |
{project} | project code (ClaudeMods → clmod, prompt-optimizer → propt) | always |
{kind} | kind of project: f (front), b (back), cli, mod, script… | only if clear |
{desc} | what you are doing, e.g. init spec, plan tests | always (required) |
With the default format:
bug-1234/clmod-f: fix login redirect
clmod-cli: plan tests
clmod: init spec
{field:option:option|fallback}, options can be combined:
| Option | Effect | Example | ||
|---|---|---|---|---|
U L C | UPPER, lower, Capitalized | {type:U} → BUG | ||
K S | kebab-case, snake_case | {desc:K} → init-spec | ||
3 2,5 ,5 3, | length in characters: exact, min-max, max, min (cut / padded) | {type:3} → fea | ||
_x after a length | pad with x on the left | {id:4,_0} → 0042 | ||
w2,5 w,3 w3 | number of words | {desc:w,3} | ||
[a,b,c] | allowed values; anything else counts as empty | {type:[bug,us,pr]} | ||
| `\ | text` | value used when the field is empty | `{kind\ | gen} → gen` |
Haiku is told about lengths and allowed values, so it picks words that fit instead of being cut. Example of another convention:
[{project}] {desc:C:w,5} ({type:U:[FEAT,FIX,CHORE]} #{id:4,_0})
→ [clmod] Fix login redirect (FIX #0042)
One small Haiku call per Claude reply (low effort, at most ~12,000 characters of conversation: the first message plus the most recent ones), through your own Claude Code session like any other request. Subagent turns and interrupted turns are skipped.
The band, /rs and /rs help show what it costs (turn it off with Session name cost display): the call behind the current suggestions and the session total, which also counts calls that changed nothing on screen:
≈$0.00031 (2.6k tokens) · session ≈$0.0021 (17k tokens, 7 calls)
The dollar figure is an estimate at Claude Haiku 5.5 API rates ($0.10 / $0.50 per million input / output tokens). On a Claude subscription you are not billed per token: the calls count toward your usage limits instead.
claude plugin validate .
claude plugin test .
claude --plugin-dir . # run Claude Code with this working copy loaded
hooks/register.tsx: hooks, the band, the /rs commandhooks/naming.ts: project code, template engine, Haiku prompt (pure functions)tests/: claude plugin testMIT
hooks/register.tsx 303 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Baseline, Spend, Suggestions } from '../types'
5import {
6 addUsage,
7 DEFAULT_TEMPLATE,
8 helpText,
9 names,
10 NO_SPEND,
11 parseVerdict,
12 projectCode,
13 prompt,
14 specs,
15 spendText,
16 SYSTEM,
17 transcript,
18} from './naming'
19import type { Settings } from './naming'
20
21const suggestions = atom({ plugin: 'session-namer', key: 'suggestions' } as const, [] as Suggestions)
22const baseline = atom({ plugin: 'session-namer', key: 'baseline' } as const, null as Baseline)
23const lastCall = atom({ plugin: 'session-namer', key: 'lastCall' } as const, null as Spend | null)
24const total = atom({ plugin: 'session-namer', key: 'total' } as const, NO_SPEND as Spend)
25const isStopped = atom({ plugin: 'session-namer', key: 'isStopped' } as const, false)
26const isGenerating = atom({ plugin: 'session-namer', key: 'isGenerating' } as const, false)
27const frame = atom({ plugin: 'session-namer', key: 'frame' } as const, 0)
28
29const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
30
31const sameList = (a: readonly string[], b: readonly string[]) =>
32 a.length === b.length && a.every((x, i) => x === b[i])
33
34// Mod options (/config); a change reloads the module, so register reads them again.
35let settings: Settings = { template: DEFAULT_TEMPLATE, rules: '' }
36let forcedProject = ''
37let isCostShown = true
38let rejectedTemplate: string | undefined
39
40const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
41
42// One request to Haiku at a time, automatic or not: each job waits for the previous one.
43let queue: Promise<unknown> = Promise.resolve()
44const enqueue = <T,>(job: () => Promise<T>): Promise<T> => {
45 const run = queue.then(job, job)
46 queue = run.catch(() => undefined)
47 return run
48}
49// An automatic request already waiting in the queue: another one adds nothing.
50let isAutoQueued = false
51
52const STOPPED_HINT = 'Automatic suggestions are stopped for this session; /rs start turns them back on.'
53
54/**
55 * Asks Haiku for 3 names. Without `force`, shows them only if the subject has
56 * changed compared to the baseline name. `avoid` lists names already shown,
57 * so Haiku proposes different ones. Returns the names shown, or the reason
58 * for the failure.
59 */
60async function suggest($: Engine, force: boolean, avoid: readonly string[] = []): Promise<string[] | string> {
61 const messages = await $.session.messages()
62 if (!Array.isArray(messages)) return 'could not read the conversation'
63 const convo = transcript(messages)
64 if (convo === '') return 'the conversation is empty'
65
66 const project = forcedProject || projectCode(await $.session.cwd())
67 const current = await read($, baseline)
68 const r = await $.model.complete({
69 model: 'haiku',
70 system: SYSTEM,
71 prompt: prompt(convo, project, current, settings, avoid),
72 effort: 'low',
73 maxTokens: 400,
74 timeoutMs: 20000,
75 })
76 // Every call counts, even a failed one or one that shows no suggestion.
77 await update($, total, prev => addUsage(prev ?? NO_SPEND, r.usage))
78 if (!r.isAnswered) return `Haiku did not answer (${r.reason})`
79
80 const verdict = parseVerdict(r.text)
81 if (verdict === undefined) return "could not read Haiku's answer"
82 // Stop pressed while this automatic call was on its way: show nothing.
83 if (!force && ((current !== null && verdict.isSameSubject) || (await read($, isStopped)))) return []
84
85 const next = names(settings.template, project, verdict)
86 await update($, suggestions, prev => (sameList(prev, next) ? prev : next))
87 await update($, lastCall, () => addUsage(NO_SPEND, r.usage))
88 return next
89}
90
91/** Runs `job` with the band's spinner turning. */
92async function spinning<T>($: Engine, job: () => Promise<T>): Promise<T> {
93 await update($, frame, () => 0)
94 await update($, isGenerating, () => true)
95 const tick = $.clock.every(100, () => {
96 void update($, frame, f => f + 1)
97 })
98 try {
99 return await job()
100 } finally {
101 tick.cancel()
102 await update($, isGenerating, () => false)
103 }
104}
105
106/** Asks for 3 names different from those shown (Reroll button, /rs). */
107function reroll($: Engine): Promise<string[] | string> {
108 return enqueue(() => spinning($, async () => suggest($, true, await read($, suggestions))))
109}
110
111/**
112 * An automatic call is shown as generating only when its names will likely be
113 * shown: the band is already up, or the session has no name yet. Otherwise
114 * (same subject, the usual case) the band would flash after every reply.
115 */
116async function auto($: Engine): Promise<string[] | string> {
117 // Stopped: no Haiku call at all.
118 if (await read($, isStopped)) return []
119 const isAwaited = (await read($, suggestions)).length > 0 || (await read($, baseline)) === null
120 return isAwaited ? spinning($, () => suggest($, false)) : suggest($, false)
121}
122
123/** Turns automatic suggestions off (hiding the band) or back on for this session. */
124async function setStopped($: Engine, stopped: boolean) {
125 await update($, isStopped, () => stopped)
126 if (stopped) await update($, suggestions, () => [])
127}
128
129/** Runs a suggestion outside the turn, without delaying it. */
130function schedule($: Engine) {
131 if (isAutoQueued) return
132 isAutoQueued = true
133 $.clock.after(0, () => {
134 enqueue(() => {
135 isAutoQueued = false
136 return auto($)
137 }).catch(() => {
138 // A failed suggestion must never get in the way of the session.
139 })
140 })
141}
142
143export const register: Register = (on, options) => {
144 const template = text(options.template)
145 // A template without {desc} would give meaningless names: keep the default.
146 const isUsable = specs(template).some(s => s.field === 'desc')
147 rejectedTemplate = isUsable || template === '' ? undefined : template
148 settings = { template: isUsable ? template : DEFAULT_TEMPLATE, rules: text(options.rules) }
149 forcedProject = text(options.project)
150 isCostShown = options.showCost !== false
151
152 on('session.start', async ($, e, next) => {
153 await $.command.register({
154 name: 'rs',
155 description: 'Rename suggest: Haiku suggests 3 session names now (/rs stop|start: automatic suggestions off/on; /rs help)',
156 argumentHint: '[help|stop|start]',
157 })
158 return next(e)
159 })
160
161 // Resumed session that already has a name: that name becomes the baseline.
162 on('classic.SessionStart', async ($, e, next) => {
163 const title = e.session_title?.trim()
164 if (title) await update($, baseline, prev => prev ?? title)
165 return next(e)
166 }).catch(($, e, next) => next(e))
167
168 on('turn.complete', ($, e, next) => {
169 if (e.agentId === undefined && e.reason === 'answer') schedule($)
170 return next(e)
171 })
172
173 on('command.run', { command: 'rs' }, async ($, e) => {
174 const arg = e.args.trim()
175 if (arg === 'stop') {
176 await setStopped($, true)
177 return { text: `Session names: ${STOPPED_HINT}` }
178 }
179 if (arg === 'start') {
180 await setStopped($, false)
181 return { text: 'Session names: automatic suggestions back on for this session.' }
182 }
183 if (arg === 'help') {
184 const project = forcedProject || projectCode(await $.session.cwd())
185 const [spent, stopped] = await Promise.all([read($, total), read($, isStopped)])
186 return {
187 text: helpText({
188 ...settings,
189 project,
190 isProjectForced: forcedProject !== '',
191 rejectedTemplate,
192 total: spent,
193 isCostShown,
194 isStopped: stopped,
195 }),
196 }
197 }
198 // With the band shown, /rs asks for different names (like Reroll).
199 const shown = await reroll($)
200 if (typeof shown === 'string') return { text: `No suggestion: ${shown}.` }
201 const [stopped, call, spent] = await Promise.all([read($, isStopped), read($, lastCall), read($, total)])
202 return {
203 text: [
204 'Suggestions in the band above the prompt:',
205 ...shown.map((n, i) => `${i + 1}. ${n}`),
206 ...(isCostShown ? [`Cost: ${call ? spendText(call) : '?'} · this session: ${spendText(spent, true)}`] : []),
207 ...(stopped ? [STOPPED_HINT] : []),
208 ].join('\n'),
209 }
210 })
211
212 // Any /rename (yours or the band's) becomes the new baseline.
213 on('command.run', { command: 'rename' }, async ($, e, next) => {
214 const result = await next(e)
215 const title = e.args.trim()
216 if (title) {
217 await update($, baseline, () => title)
218 await update($, suggestions, () => [])
219 }
220 return result
221 }).catch(($, e, next) => next(e))
222
223 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
224 const shown = await read($, suggestions)
225 const generating = await read($, isGenerating)
226 if (e.props.hasSurvey || (shown.length === 0 && !generating)) return next(e)
227 const spin = generating ? `${SPINNER[(await read($, frame)) % SPINNER.length]} generating names…` : ''
228
229 const { Box, Button, Text } = $.ui.resolve(e)
230 if (shown.length === 0) {
231 return (
232 <Box flexDirection="row" gap={1}>
233 <Text dimColor>Session name:</Text>
234 <Text color="claude">
235 {spin}
236 </Text>
237 </Box>
238 )
239 }
240 const call = await read($, lastCall)
241 const spent = await read($, total)
242
243 const choose = async (name: string) => {
244 try {
245 await $.command.run({ command: 'rename', args: name })
246 } catch {
247 $.ui.toast(`Could not rename, type: /rename ${name}`)
248 }
249 await update($, baseline, () => name)
250 await update($, suggestions, () => [])
251 }
252
253 const dismiss = async () => {
254 // The current subject becomes the baseline: suggestions only come back if it changes.
255 await update($, baseline, () => shown[0])
256 await update($, suggestions, () => [])
257 }
258
259 const onReroll = async () => {
260 try {
261 const result = await reroll($)
262 if (typeof result === 'string') $.ui.toast(`No new names: ${result}.`)
263 } catch {
264 $.ui.toast('No new names: Haiku could not be reached.')
265 }
266 }
267
268 const onStop = async () => {
269 await setStopped($, true)
270 $.ui.toast(STOPPED_HINT)
271 }
272
273 return (
274 <Box flexDirection="row" flexWrap="wrap" gap={1}>
275 <Text dimColor>Session name:</Text>
276 {shown.map((name, i) => (
277 <Button
278 key={`pick-${i}`}
279 label={name}
280 hotkey={String(i + 1)}
281 variant={i === 0 ? 'primary' : 'secondary'}
282 onPress={() => choose(name)}
283 />
284 ))}
285 {generating ? (
286 <Text color="claude">
287 {spin}
288 </Text>
289 ) : (
290 <Button key="reroll" label="Reroll" hotkey="r" onPress={onReroll} />
291 )}
292 <Button key="dismiss" label="Dismiss" hotkey="x" role="dismiss" onPress={dismiss} />
293 <Button key="stop" label="Stop" hotkey="s" onPress={onStop} />
294 {isCostShown && (
295 <Text key="cost" dimColor>
296 {call ? `${spendText(call)} · ` : ''}session {spendText(spent, true)}
297 </Text>
298 )}
299 </Box>
300 )
301 })
302}
303hooks/naming.ts 403 lines1// Pure logic (no `$`): project code, name format, Haiku prompt and parsing.
2
3export type Parts = {
4 type?: string // bug, us, pr, feat, ...
5 id?: string // 1234, ABC-12, ...
6 kind?: string // f, b, cli, mod, script, ...
7 desc: string // 2 to 5 words
8}
9
10export type Verdict = {
11 isSameSubject: boolean
12 suggestions: Parts[]
13}
14
15/** Project code of 3 to 5 characters taken from the folder name: ClaudeMods → clmod. */
16export function projectCode(cwd: string): string {
17 const base = cwd.replace(/\/+$/, '').split('/').pop() ?? ''
18 const words = base
19 .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
20 .split(/[^A-Za-z0-9]+/)
21 .filter(Boolean)
22 .map(w => w.toLowerCase())
23
24 const [first, second] = words
25 if (first === undefined) return 'proj'
26 if (second === undefined) return first.slice(0, 5)
27 if (words.length === 2) return first.slice(0, 2) + second.slice(0, 3)
28
29 return words.map(w => w.charAt(0)).join('').slice(0, 5)
30}
31
32export const DEFAULT_TEMPLATE = '{type:L}-{id}/{project:L:,5}-{kind:L}: {desc:L:w2,5}'
33
34export const FIELDS = ['type', 'id', 'project', 'kind', 'desc'] as const
35export type Field = (typeof FIELDS)[number]
36
37/**
38 * A template field: `{name:mod:mod|fallback}`.
39 * U / L / C UPPERCASE, lowercase, Capitalized
40 * K / S words joined by - (kebab) or _ (snake)
41 * 3 2,5 ,5 3, length in characters: exact, min-max, max, min
42 * …_0 padding character, on the left (4,_0: 42 → 0042)
43 * w2,5 w,5 w3 number of words (same syntax, no padding)
44 * [bug,us,pr] allowed values; any other value counts as empty
45 * |gen value used when the field is empty (otherwise it disappears)
46 */
47export type Spec = {
48 field: Field
49 cases: ('U' | 'L' | 'C' | 'K' | 'S')[]
50 chars?: { min?: number; max?: number; pad?: string }
51 words?: { min?: number; max?: number }
52 allowed?: string[]
53 fallback?: string
54}
55
56const FIELD = /\{([a-z]+)((?::[^:{}|]*)*)(?:\|([^{}]*))?\}/g
57const RANGE = /^(\d*)(,?)(\d*)(?:_(.))?$/
58
59function range(text: string): { min?: number; max?: number; pad?: string } | undefined {
60 const m = RANGE.exec(text)
61 if (!m || (m[1] === '' && m[3] === '')) return undefined
62 const [, a = '', comma, b = '', pad] = m
63 const min = a === '' ? undefined : Number(a)
64 const max = comma ? (b === '' ? undefined : Number(b)) : min
65 return { min, max, pad }
66}
67
68export function parseSpec(name: string, mods: string, fallback: string | undefined): Spec | undefined {
69 if (!(FIELDS as readonly string[]).includes(name)) return undefined
70 const spec: Spec = { field: name as Field, cases: [], fallback: fallback?.trim() || undefined }
71 for (const mod of mods.split(':').map(m => m.trim()).filter(Boolean)) {
72 if (/^[ULCKS]$/.test(mod)) spec.cases.push(mod as Spec['cases'][number])
73 else if (mod.startsWith('[') && mod.endsWith(']'))
74 spec.allowed = mod.slice(1, -1).split(',').map(v => v.trim()).filter(Boolean)
75 else if (mod.startsWith('w')) spec.words = range(mod.slice(1))
76 else spec.chars = range(mod)
77 // An unknown modifier is ignored: the name stays readable rather than broken.
78 }
79 return spec
80}
81
82/** The template's fields, in order (the same field may appear twice). */
83export function specs(template: string): Spec[] {
84 return [...template.matchAll(FIELD)].flatMap(m => parseSpec(m[1] ?? '', m[2] ?? '', m[3]) ?? [])
85}
86
87// Keeps letters (accents included), digits and a few characters useful in ids.
88const clean = (s: string | undefined) =>
89 (s ?? '').replace(/[^\p{L}\p{N}#.\- ]+/gu, '').replace(/\s+/g, ' ').trim()
90
91// Space padding must survive the final whitespace cleanup.
92const PAD_SPACE = '\u0000'
93
94export function applySpec(spec: Spec, raw: string): string {
95 let v = raw
96 if (spec.allowed && !spec.allowed.some(a => a.toLowerCase() === v.toLowerCase())) v = ''
97 else if (spec.allowed) v = spec.allowed.find(a => a.toLowerCase() === v.toLowerCase()) ?? v
98 if (v === '') v = spec.fallback ?? ''
99 if (v === '') return ''
100
101 if (spec.words?.max !== undefined) v = v.split(' ').slice(0, spec.words.max).join(' ')
102 for (const c of spec.cases) {
103 if (c === 'U') v = v.toUpperCase()
104 if (c === 'L') v = v.toLowerCase()
105 if (c === 'C') v = v.charAt(0).toUpperCase() + v.slice(1)
106 if (c === 'K') v = v.replace(/ /g, '-')
107 if (c === 'S') v = v.replace(/ /g, '_')
108 }
109 const chars = spec.chars
110 if (chars?.max !== undefined) v = [...v].slice(0, chars.max).join('').trimEnd()
111 if (chars?.min !== undefined && [...v].length < chars.min) {
112 const fill = (chars.pad ?? PAD_SPACE).repeat(chars.min - [...v].length)
113 v = chars.pad === undefined ? v + fill : fill + v
114 }
115 return v
116}
117
118const OPENERS = /[([{<]/
119
120/**
121 * Fills the template. An empty field disappears together with a neighbouring
122 * separator: the one before it if it follows a filled field and does not open a
123 * parenthesis/bracket, otherwise the one after it. Empty pairs and orphan
124 * openers are then removed.
125 * {type:L}-{id}/{project}-{kind}: {desc} → bug-1234/clmod-mod: init spec
126 * → clmod: init spec
127 */
128export function formatName(template: string, project: string, p: Parts): string {
129 const values: Record<Field, string> = {
130 type: clean(p.type).replace(/ /g, ''),
131 id: clean(p.id).replace(/ /g, ''),
132 project,
133 kind: clean(p.kind).replace(/ /g, ''),
134 desc: clean(p.desc).split(' ').slice(0, 12).join(' '),
135 }
136
137 const out: { text: string; isField: boolean }[] = []
138 let skipNext = false
139 const literal = (text: string) => {
140 if (text === '') return
141 if (skipNext) skipNext = false
142 else out.push({ text, isField: false })
143 }
144
145 let at = 0
146 for (const m of template.matchAll(FIELD)) {
147 literal(template.slice(at, m.index))
148 at = m.index + m[0].length
149 const spec = parseSpec(m[1] ?? '', m[2] ?? '', m[3])
150 if (spec === undefined) {
151 literal(m[0])
152 continue
153 }
154 const value = applySpec(spec, values[spec.field])
155 if (value !== '') {
156 out.push({ text: value, isField: true })
157 skipNext = false
158 continue
159 }
160 const prev = out.at(-1)
161 const hasFieldBefore = out.some(t => t.isField)
162 if (prev && !prev.isField && hasFieldBefore && !OPENERS.test(prev.text)) out.pop()
163 else skipNext = true
164 }
165 literal(template.slice(at))
166
167 let name = out.map(t => t.text).join('')
168 for (let i = 0; i < 3; i++) name = name.replace(/\s*(\(\s*\)|\[\s*\]|\{\s*\}|<\s*>)/g, '')
169 return name
170 .replace(/\s*[([{<]\s*$/, '')
171 .replace(/\s+/g, ' ')
172 .replace(/^[\s\-/:#|_.]+|[\s\-/:#|_.]+$/g, '')
173 .replaceAll(PAD_SPACE, ' ')
174}
175
176const between = (r: { min?: number; max?: number }, unit: string) =>
177 r.min !== undefined && r.min === r.max
178 ? `exactly ${r.min} ${unit}`
179 : [r.min !== undefined && `at least ${r.min} ${unit}`, r.max !== undefined && `at most ${r.max} ${unit}`]
180 .filter(Boolean)
181 .join(' and ')
182
183/** The template's constraints, told to Haiku so it respects them without being truncated. */
184export function constraints(template: string): string {
185 const lines = specs(template)
186 .filter(s => s.field !== 'project')
187 .map(s => {
188 const parts = [
189 s.allowed && `pick one of: ${s.allowed.join(', ')} (otherwise leave empty)`,
190 s.words && between(s.words, 'words'),
191 s.chars && s.chars.max !== undefined && `at most ${s.chars.max} characters`,
192 ].filter(Boolean)
193 return parts.length ? `- ${s.field}: ${parts.join('; ')}` : ''
194 })
195 .filter(Boolean)
196 return [...new Set(lines)].join('\n')
197}
198
199export const SYSTEM = `You name a developer's work sessions.
200You provide fields; the program assembles them according to the FORMAT given in the request and adds the project code itself.
201- type (optional): nature of the item being worked on, one short lowercase word (bug, us, pr, feat, task, doc...). ONLY if it is explicit in the conversation.
202- id (optional): identifier of that item (ticket number, PR number...). ONLY if it appears verbatim in the conversation. Never invent an id.
203- kind (optional): kind of project, short (f = front, b = back, cli, mod, script, lib, api, infra...). Only if it is clear.
204- desc (required): 2 to 5 words in English, lowercase, action verb first, what is being DONE (e.g. "init spec", "update spec", "plan tests", "implement back", "fix login redirect").
205When in doubt, leave an optional field empty rather than guessing.
206The FIELD CONSTRAINTS in the request take precedence over everything else (allowed values, number of words, length).
207If the request lists ALREADY PROPOSED names, the user rejected them: give 3 new variants that differ from them in wording (same fields rules).
208If the request contains USER INSTRUCTIONS, they take precedence over the rules above (language, vocabulary, length...), but never over the response format below.
209
210Reply ONLY with a JSON object, with no text around it:
211{"sameSubject": true|false, "suggestions": [{"type": "", "id": "", "kind": "", "desc": ""}, ...]}
212- suggestions: exactly 3 distinct variants, best first.
213- sameSubject: true if the conversation is still about the same work as the CURRENT NAME provided (a neighbouring step of the same work counts as the same subject only if the description would still be accurate); false if it has changed or if there is no current name.`
214
215/** Keeps the first message and the end of the conversation, within a character budget. */
216export function transcript(
217 messages: readonly { role: 'user' | 'assistant'; text: string }[],
218 budget = 12000,
219 perMessage = 800,
220): string {
221 const lines = messages
222 .filter(m => m.text.trim() !== '')
223 .map(m => {
224 const text = m.text.trim()
225 const cut = text.length > perMessage ? text.slice(0, perMessage) + ' […]' : text
226 return `${m.role === 'user' ? 'USER' : 'CLAUDE'}: ${cut}`
227 })
228 const [first, ...rest] = lines
229 if (first === undefined) return ''
230
231 const tail: string[] = []
232 let size = first.length
233 for (const line of [...rest].reverse()) {
234 if (size + line.length > budget) break
235 size += line.length
236 tail.unshift(line)
237 }
238 const skipped = rest.length - tail.length
239 return [first, ...(skipped > 0 ? [`[… ${skipped} messages omitted …]`] : []), ...tail].join('\n\n')
240}
241
242export type Settings = { template: string; rules: string }
243
244export function prompt(
245 convo: string,
246 project: string,
247 baseline: string | null,
248 settings: Settings,
249 avoid: readonly string[] = [],
250): string {
251 const rules = settings.rules.trim()
252 const limits = constraints(settings.template)
253 return `FORMAT: ${settings.template}
254${limits ? `FIELD CONSTRAINTS (take precedence):\n${limits}\n` : ''}Project code (already set): ${project}
255CURRENT NAME: ${baseline ?? '(none)'}
256${avoid.length > 0 ? `ALREADY PROPOSED (give different ones):\n${avoid.map(n => `- ${n}`).join('\n')}\n` : ''}${rules ? `\nUSER INSTRUCTIONS:\n${rules}\n` : ''}
257<conversation>
258${convo}
259</conversation>`
260}
261
262/** Reads Haiku's JSON reply; undefined if it is unusable. */
263export function parseVerdict(text: string): Verdict | undefined {
264 const start = text.indexOf('{')
265 const end = text.lastIndexOf('}')
266 if (start < 0 || end <= start) return undefined
267 try {
268 const raw = JSON.parse(text.slice(start, end + 1)) as {
269 sameSubject?: unknown
270 suggestions?: unknown
271 }
272 const suggestions = (Array.isArray(raw.suggestions) ? raw.suggestions : [])
273 .filter((s): s is Record<string, unknown> => typeof s === 'object' && s !== null)
274 .map(s => ({
275 type: typeof s.type === 'string' ? s.type : undefined,
276 id: typeof s.id === 'string' ? s.id : undefined,
277 kind: typeof s.kind === 'string' ? s.kind : undefined,
278 desc: typeof s.desc === 'string' ? s.desc : '',
279 }))
280 .filter(s => clean(s.desc) !== '')
281 if (suggestions.length === 0) return undefined
282 return { isSameSubject: raw.sameSubject === true, suggestions }
283 } catch {
284 return undefined
285 }
286}
287
288/** Formatted names, without duplicates, at most 3. */
289export function names(template: string, project: string, v: Verdict): string[] {
290 return [...new Set(v.suggestions.map(p => formatName(template, project, p)))].slice(0, 3)
291}
292
293const SAMPLES: { label: string; parts: Parts }[] = [
294 { label: 'everything found', parts: { type: 'bug', id: '1234', kind: 'f', desc: 'fix login redirect' } },
295 { label: 'no ticket', parts: { kind: 'cli', desc: 'plan tests' } },
296 { label: 'description only', parts: { desc: 'init spec' } },
297]
298
299export type HelpInput = {
300 template: string
301 rules: string
302 project: string
303 isProjectForced: boolean
304 /** The template that was entered, when it was rejected (no {desc}). */
305 rejectedTemplate?: string
306 total?: typeof NO_SPEND
307 isCostShown?: boolean
308 isStopped?: boolean
309}
310
311/** The /rs help manual, with a preview using the current settings. */
312export function helpText(h: HelpInput): string {
313 const preview = SAMPLES.map(s => ` ${s.label.padEnd(18)} → ${formatName(h.template, h.project, s.parts)}`)
314 return [
315 'After each Claude reply, Haiku reads the conversation and suggests 3 session names',
316 'in a band above the prompt. Click one (or ctrl+x tab, then 1-3) to rename; r rerolls',
317 '(3 different names), x dismisses, s stops the suggestions for this session.',
318 "Once named, it stays quiet until the topic changes. /rs asks for suggestions right now",
319 '(different ones if the band is shown). /rs stop and /rs start turn the automatic',
320 `suggestions off and on for this session (now: ${h.isStopped === true ? 'stopped' : 'on'}).`,
321 '',
322 'SETTINGS (/config, search "session name")',
323 ` Session name format ${h.template}`,
324 ...(h.rejectedTemplate !== undefined
325 ? [` ⚠ "${h.rejectedTemplate}" has no {desc} field, so the default format is used.`]
326 : []),
327 ` Session name rules for Haiku ${h.rules || '(none: default rules)'}`,
328 ` Session name project code ${h.isProjectForced ? h.project : `(automatic: ${h.project}, from the folder name)`}`,
329 ` Session name cost display ${h.isCostShown === false ? 'off' : 'on'}`,
330 '',
331 'PREVIEW (current settings, sample values)',
332 ...preview,
333 '',
334 'FORMAT Text is kept as typed; {field} is replaced by a value Haiku finds in the conversation.',
335 ' {type} kind of work item: bug, us, pr, feat... (only if stated)',
336 ' {id} its ticket / PR number (only if stated, never invented)',
337 ' {project} project code (always filled)',
338 ' {kind} kind of project: f, b, cli, mod, script... (only if clear)',
339 ' {desc} what you are doing, e.g. "init spec" (always filled, required)',
340 ' An empty field disappears together with the separator next to it.',
341 '',
342 'FIELD OPTIONS {field:option:option|fallback}, combinable',
343 ' U L C UPPER, lower, Capitalized {type:U} → BUG',
344 ' K S kebab-case, snake_case {desc:K} → init-spec',
345 ' 3 2,5 ,5 3, length in characters: exact, {type:3} → fea',
346 ' min-max, max, min (cut / padded)',
347 ' _x after a length: pad with x on the left {id:4,_0} → 0042',
348 ' w2,5 w,3 number of words {desc:w,3}',
349 ' [a,b,c] allowed values, anything else = empty {type:[bug,us,pr]}',
350 ' |text value used when the field is empty {kind|gen} → gen',
351 'Haiku is told about the limits and allowed values, so it picks words that fit.',
352 '',
353 'RULES FOR HAIKU free text, overrides the default rules, e.g.',
354 ' "description in French; allowed types: feat, fix, chore; never use {kind}"',
355 '',
356 'COST one Haiku call per Claude reply, low effort, ≤ ~12,000 characters of conversation.',
357 ' Estimated at Claude Haiku 5.5 API rates ($0.10 / $0.50 per million input / output tokens);',
358 ' on a Claude subscription it counts toward your usage limits instead.',
359 ` This session: ${h.total ? spendText(h.total, true) : 'no call yet'}`,
360 ` Shown in the band and /rs: ${h.isCostShown === false ? 'no' : 'yes'} (Session name cost display)`,
361 ].join('\n')
362}
363
364// Claude Haiku 5.5 API prices in $ per million tokens (prompts ≤ 100K tokens).
365// Cache: reads cost 0.1× and writes 1.25× the input price (the mod marks nothing for caching).
366const PRICE = { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }
367
368export type Usage = {
369 input_tokens: number
370 output_tokens: number
371 cache_read_input_tokens: number
372 cache_creation_input_tokens: number
373}
374
375export const NO_SPEND = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
376
377export function addUsage<S extends typeof NO_SPEND>(s: S, u: Usage): typeof NO_SPEND {
378 return {
379 calls: s.calls + 1,
380 input: s.input + u.input_tokens,
381 output: s.output + u.output_tokens,
382 cacheRead: s.cacheRead + u.cache_read_input_tokens,
383 cacheWrite: s.cacheWrite + u.cache_creation_input_tokens,
384 }
385}
386
387export function dollars(s: typeof NO_SPEND): number {
388 return (
389 (s.input * PRICE.input + s.output * PRICE.output + s.cacheRead * PRICE.cacheRead + s.cacheWrite * PRICE.cacheWrite) /
390 1e6
391 )
392}
393
394const tokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
395const usd = (x: number) => (x === 0 ? '$0' : x < 0.0001 ? '<$0.0001' : `$${Number(x.toPrecision(2))}`)
396
397/** "≈$0.00037 (2.1k tokens)", plus ", 7 calls" for a total. */
398export function spendText(s: typeof NO_SPEND, isTotal = false): string {
399 const all = s.input + s.output + s.cacheRead + s.cacheWrite
400 const calls = isTotal ? `, ${s.calls} call${s.calls === 1 ? '' : 's'}` : ''
401 return `≈${usd(dollars(s))} (${tokens(all)} tokens${calls})`
402}
403types/index.d.ts 28 lines1/** Names proposed by Haiku, shown in the band; empty = band hidden. */
2export type Suggestions = string[]
3
4/** Baseline name (picked or dismissed): suggestions come back only if the subject diverges from it. */
5export type Baseline = string | null
6
7/** Tokens consumed by one or more Haiku calls. */
8export type Spend = { calls: number; input: number; output: number; cacheRead: number; cacheWrite: number }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'session-namer': {
13 suggestions: Suggestions
14 baseline: Baseline
15 /** The call that produced the suggestions shown. */
16 lastCall: Spend | null
17 /** All calls of the session, including those that showed nothing. */
18 total: Spend
19 /** Automatic suggestions turned off for this session (Stop, /rs stop). */
20 isStopped: boolean
21 /** Haiku is writing names the band waits for: the band shows a spinner. */
22 isGenerating: boolean
23 /** The spinner's current frame, advanced by a timer while generating. */
24 frame: number
25 }
26 }
27}
28