Gives each session a short findable name on exit, and re-checks it on later exits

Periodically proposes a session name from the work, without replacing a title you set yourself. Uses the session model and consumes model usage.
claude plugin install session-namer --marketplace sruthik27/claude-foundry
Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.
Work normally, then use /resume to find the session.
MIT licensed. Maintained by Sruthik Issac.
hooks/register.ts 147 lines1import type { Register } from 'claude-code'
2import { askFor, enoughToName, parseReply, promptsOf } from './name.js'
3
4/**
5 * The name is worked out while the session runs (after a turn, throttled: a model call is too
6 * slow for session.end). It is applied through the classic hooks' `sessionTitle` field, which
7 * Claude Code saves as the session's title: on the next prompt (UserPromptSubmit), and on a
8 * resume (SessionStart). An earlier version applied it with `$.command.run('rename')` from
9 * session.end, which never ran: a queued command waits for an idle session the exit never gives.
10 * Per session the store remembers the name applied, whether the person named it themselves
11 * (then it is left alone), and the candidate waiting to be applied.
12 */
13type Memo = { applied?: string; human?: boolean; pending?: string; checkedAt?: number }
14
15const EVERY = 6
16
17/**
18 * The /resume list reads a title only from the transcript's last 64 KB, then from this sidecar
19 * (`<dir>/<session id>/custom-title.json`). A long session buries the title record past 64 KB, so
20 * the list falls back to the last prompt; the sidecar keeps the name findable at any size.
21 */
22function sidecarOf(transcript: string, sid: string): string {
23 return `${transcript.slice(0, transcript.lastIndexOf('/'))}/${sid}/custom-title.json`
24} // re-judge the name after this many more turns
25
26export const register: Register = on => {
27 let turns = 0
28 /** The transcript path, seen on every prompt: where the /resume sidecar goes. */
29 let transcript = ''
30 let busy = false
31
32 on('command.run', { command: 'rename' }, async ($, e, next) => {
33 // The person renamed it: theirs wins from now on. Our own /rename runs as origin plugin.
34 const o = e.origin as { kind?: string; name?: string } | undefined
35 if (!(o?.kind === 'plugin' && o.name === 'session-namer')) {
36 const k = `memo:${await $.session.id()}`
37 const memo = ((await $.store.get(k)) ?? {}) as Memo
38 await $.store.set(k, { ...memo, human: true, pending: undefined })
39 }
40 return next(e)
41 })
42
43 on('turn.complete', async ($, e, next) => {
44 const r = await next(e)
45 if (e.agentId || busy) return r
46 turns += 1
47 const k = `memo:${await $.session.id()}`
48 const memo = ((await $.store.get(k)) ?? {}) as Memo
49 if (memo.human) return r
50 const due = turns >= 1 && (memo.checkedAt === undefined || turns - memo.checkedAt >= EVERY || turns < memo.checkedAt)
51 if (!due) return r
52
53 busy = true
54 try {
55 const prompts = promptsOf((await $.session.messages()) as { role: string; text: string }[])
56 if (!enoughToName(prompts)) return r
57 const start = prompts.slice(0, 3).join(' | ').slice(0, 1200)
58 const recent = prompts.slice(-4).join(' | ').slice(-1200)
59 const cwd = await $.session.cwd()
60 const repo = (await $.session.root().catch(() => cwd)).split('/').pop() ?? cwd
61 const current = memo.pending ?? memo.applied
62 const reply = await $.model.complete({
63 model: await $.session.model(), effort: 'low', maxTokens: 40, timeoutMs: 30000,
64 prompt: askFor(repo, current, start, recent),
65 })
66 const verdict = reply.isAnswered ? parseReply(reply.text, current) : undefined
67 const next_: Memo = { ...memo, checkedAt: turns }
68 if (verdict && verdict !== 'keep') {
69 next_.pending = verdict
70 // Write the /resume sidecar now: a session closed right after this turn is still named
71 // in the list. The transcript title follows on the next prompt (or on resume).
72 if (transcript) {
73 const sid = await $.session.id()
74 try { await $.fs.write(sidecarOf(transcript, sid), JSON.stringify({ customTitle: verdict })) } catch { /* next prompt */ }
75 }
76 }
77 await $.store.set(k, next_)
78 } finally {
79 busy = false
80 }
81 return r
82 })
83
84 // Applies a waiting name as the session's title on the next prompt; re-asserts an applied one the
85 // session has lost (a resume that did not find it), and keeps the /resume sidecar written.
86 on('classic.UserPromptSubmit', async ($, e, next) => {
87 if (e.transcript_path) transcript = e.transcript_path
88 const r = await next(e)
89 try {
90 const sid = await $.session.id()
91 const k = `memo:${sid}`
92 const memo = ((await $.store.get(k)) ?? {}) as Memo
93 const existing = e.session_title
94 // A title the mod did not set (named by hand, or before the mod existed) is the person's.
95 if (!memo.human && existing && existing !== memo.applied) {
96 await $.store.set(k, { ...memo, human: true, pending: undefined })
97 return r
98 }
99 if (memo.human || r.sessionTitle) return r
100 const want = memo.pending && memo.pending !== memo.applied ? memo.pending : memo.applied
101 if (!want) return r
102 if (want !== memo.applied || memo.pending) await $.store.set(k, { ...memo, applied: want, pending: undefined })
103 if (e.transcript_path) {
104 try { await $.fs.write(sidecarOf(e.transcript_path, sid), JSON.stringify({ customTitle: want })) } catch { /* list falls back to the tail */ }
105 }
106 return existing === want ? r : { ...r, sessionTitle: want }
107 } catch {
108 return r
109 }
110 })
111
112 on('session.start', async ($, e, next) => {
113 const r = await next(e)
114 turns = 0 // a resumed session counts afresh, so the name is re-judged after 2 turns
115 const k = `memo:${await $.session.id()}`
116 const memo = (await $.store.get(k)) as Memo | undefined
117 if (memo && !memo.human) await $.store.set(k, { ...memo, checkedAt: undefined })
118 return r
119 })
120
121 // A resumed session gets its name at once: a waiting one, or the applied one it lost.
122 on('classic.SessionStart', async ($, e, next) => {
123 const r = await next(e)
124 if (e.source !== 'resume' || r.sessionTitle) return r
125 try {
126 const sid = await $.session.id()
127 const k = `memo:${sid}`
128 const memo = ((await $.store.get(k)) ?? {}) as Memo
129 const existing = e.session_title
130 if (!memo.human && existing && existing !== memo.applied) {
131 await $.store.set(k, { ...memo, human: true, pending: undefined })
132 return r
133 }
134 if (memo.human) return r
135 const want = memo.pending && memo.pending !== memo.applied ? memo.pending : memo.applied
136 if (!want) return r
137 await $.store.set(k, { ...memo, applied: want, pending: undefined })
138 if (e.transcript_path) {
139 try { await $.fs.write(sidecarOf(e.transcript_path, sid), JSON.stringify({ customTitle: want })) } catch { /* tail only */ }
140 }
141 return existing === want ? r : { ...r, sessionTitle: want }
142 } catch {
143 return r
144 }
145 })
146}
147hooks/name.ts 53 lines1/** Pure helpers: the prompt, and cleaning what the model replies. */
2
3export function askFor(repo: string, current: string | undefined, start: string, recent: string): string {
4 return `Name a Claude Code session so its owner can find it later by skimming a resume list.
5Rules: 2-5 lowercase words joined by hyphens, max 40 chars. Name the concrete subject (system, feature, bug, device, repo area), not the activity. No dates, no filler words (session, work, stuff, misc, task).
6Repo/dir: ${repo}
7Current name: ${current ?? '<none>'}
8What the user asked, from the start: ${start}
9What the user asked, most recently: ${recent}
10
11If a current name exists and still names the session's main subject well, reply exactly: KEEP
12Otherwise reply with only the new name, nothing else.`
13}
14
15/** 'keep', a cleaned name, or undefined when the reply is unusable. */
16export function parseReply(reply: string, current: string | undefined): 'keep' | string | undefined {
17 const line = reply.trim().split('\n').pop()?.trim() ?? ''
18 if (/^keep\.?$/i.test(line)) return 'keep'
19 const name = line.toLowerCase().replace(/[`"'*]/g, '').replace(/[\s_]+/g, '-')
20 .replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 40).replace(/-$/, '')
21 if (name.length < 3 || name.split('-').length > 6) return undefined
22 return name === current ? 'keep' : name
23}
24
25/**
26 * What the person actually asked, one entry per prompt. A slash command counts by its name and
27 * arguments (`/skill do x` is a real request); a bare command, its output, task notifications
28 * and other machine-made messages do not.
29 */
30export function promptsOf(messages: readonly { role: string; text: string }[]): string[] {
31 const out: string[] = []
32 for (const m of messages) {
33 if (m.role !== 'user') continue
34 const t = m.text.trim()
35 if (!t) continue
36 if (/^<(task-notification|local-command|system-reminder|bash-|user-memory)/.test(t)) continue
37 if (/^<command-(name|message)>/.test(t)) {
38 const name = /<command-name>([^<]*)<\/command-name>/.exec(t)?.[1]?.trim() ?? ''
39 const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(t)?.[1]?.trim() ?? ''
40 if (args) out.push(`${name} ${args}`.replace(/\s+/g, ' ').trim())
41 continue
42 }
43 const clean = t.replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
44 if (clean) out.push(clean)
45 }
46 return out
47}
48
49/** Enough to name a session: two prompts, or one that says something (>= 8 words). */
50export function enoughToName(prompts: readonly string[]): boolean {
51 return prompts.length >= 2 || (prompts[0]?.split(/\s+/).length ?? 0) >= 8
52}
53