SLOPSHOPPER

session-namer

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

newbandcommandtoastmodeltimer
v0.8.0MITupdated 2026-10-10lucaslenglet/session-namer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-namer
› fix the failing auth test and add an audit log call ⏺ 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 › /rs ⎿ session-namer: No suggestion: could not read Haiku's answer. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

session-namer

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.

Install

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.

Usage

Band above the promptClick a name, or ctrl+x tab then 1-3; r rerolls, x dismisses, s stops for this session
/rsAsk for suggestions now (different ones if the band is shown); works even when stopped
/rs stop / /rs startTurn automatic suggestions off / back on for this session
/rs helpHow 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

Settings

In /config, search for session name:

SettingDefault
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 displayonShow the Haiku cost in the band and in /rs (/rs help always shows the session total)

Format

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.

FieldMeaningFilled
{type}kind of work item: bug, us, pr, feat…only if stated
{id}its ticket / PR numberonly 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 testsalways (required)

With the default format:

bug-1234/clmod-f: fix login redirect
clmod-cli: plan tests
clmod: init spec

Field options

{field:option:option|fallback}, options can be combined:

OptionEffectExample
U L CUPPER, lower, Capitalized{type:U} → BUG
K Skebab-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 lengthpad with x on the left{id:4,_0} → 0042
w2,5 w,3 w3number 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)

Cost and privacy

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.

Development

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 command
  • hooks/naming.ts: project code, template engine, Haiku prompt (pure functions)
  • tests/: claude plugin test

License

MIT

Source 3 files
hooks/register.tsx 303 lines
1import { 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}
303
hooks/naming.ts 403 lines
1// 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}
403
types/index.d.ts 28 lines
1/** 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