SLOPSHOPPER

session-namer

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

newmodel
A shopper browsing a rack in a slop shop
README

session-namer

Periodically proposes a session name from the work, without replacing a title you set yourself. Uses the session model and consumes model usage.

Install

claude plugin install session-namer --marketplace sruthik27/claude-foundry

Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.

Try it

Work normally, then use /resume to find the session.

Details

MIT licensed. Maintained by Sruthik Issac.

Source 2 files
hooks/register.ts 147 lines
1import 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}
147
hooks/name.ts 53 lines
1/** 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