Gives each session a colored badge with an emoji and a short topic label, so many sessions can be told apart at a glance

session-badge is a Claude Code mod. The mod shows a badge above the prompt in each session.
<img src="docs/screenshot.png" alt="An example badge above the Claude Code prompt" width="600"> <em>Example: the badge of a session in the lab-website folder.</em>
The badge shows:
Each session has a different color. The color helps you identify each session.
/plugin install session-badge --marketplace shreyashankar/session-badge
y to add the marketplace.The mod updates the task label after each turn.
To set the task label manually, type /badge and the task. Example:
/badge writing the lesson 1 slides
The mod does not change a label that you set manually.
To start the automatic labels again, type /badge auto.
To show the current label, type /badge.
~/.claude/projects. A local python3 script does this count. The mod keeps only the counts.hooks/register.tsx 321 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Badge, Progress, TurnHistory } from '../types'
5
6const badge = atom({ plugin: 'session-badge', key: 'badge' } as const, null)
7const progress = atom({ plugin: 'session-badge', key: 'progress' } as const, null)
8const history = atom({ plugin: 'session-badge', key: 'history' } as const, null)
9
10const MINUTE = 60 * 1000
11// Store key for {sessionId: turn count}, kept across sessions and cleanups.
12const COUNTS_KEY = 'turn-counts'
13// The turn distribution across sessions is recounted this often.
14const HISTORY_REFRESH_MS = 2 * 60 * MINUTE
15
16// Counts the prompts the person typed in each saved session transcript and
17// prints {sessionId: count}. Tool results, slash command output, interrupts,
18// meta rows and subagent rows are not prompts. Streams each file, so the
19// large transcripts cost little memory; all of them take about a second.
20const COUNT_TURNS_PY = [
21 'import glob, json, os',
22 'SKIP = ("<command-", "<local-command", "<bash-", "<task-notification", "[Request interrupted", "Caveat:", "<system-reminder")',
23 'def is_prompt(rec):',
24 ' if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"): return False',
25 ' c = (rec.get("message") or {}).get("content")',
26 ' if isinstance(c, list):',
27 ' if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in c): return False',
28 ' c = " ".join(b.get("text", "") for b in c if isinstance(b, dict))',
29 ' return isinstance(c, str) and bool(c.strip()) and not c.lstrip().startswith(SKIP)',
30 'out = {}',
31 'for f in glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl")):',
32 ' n = 0',
33 ' with open(f, errors="ignore") as fh:',
34 ' for line in fh:',
35 ' if \'"user"\' not in line: continue',
36 ' try: rec = json.loads(line)',
37 ' except Exception: continue',
38 ' if is_prompt(rec): n += 1',
39 ' out[os.path.basename(f)[:-6]] = n',
40 'print(json.dumps(out))',
41].join('\n')
42
43// Distinct, readable colors; the session id picks one.
44const PALETTE = [
45 '#E8937F', '#6BA3D6', '#7BC89C', '#E8C87A', '#B39DDB',
46 '#F48FB1', '#4DD0E1', '#FFB74D', '#A1887F', '#90A4AE',
47]
48
49// How many of the person's latest messages the label is based on. Older
50// messages are left out so the label follows the current topic.
51const RECENT_MESSAGES = 10
52
53const LABEL_PROMPT = `You name a coding agent session so a person juggling many sessions can recognize it at a glance.
54
55Below are the person's latest messages in this session, oldest first, and the session's current label (if any). Summarize what these messages, taken together, are working on: the task they share, not the subject of the last message alone. If they cover two unrelated tasks, name the one most of the recent messages are about.
56
57Keep the current label unless the topic has actually changed. A follow-up, a refinement, a fix or a question about the same thing is the same topic. If the current label still fits, reply with exactly: SAME
58
59Rules:
60- The label is 3 to 7 lowercase words. It starts with a verb ending in -ing that says what is being done, followed by the concrete thing from the messages: a file, deck, feature, bug, homework, or document. Examples: "building a session indicator mod", "fixing the hw2 session id", "redesigning the lesson 1 trace slide", "uploading slides to docsend".
61- It must read naturally after "This agent is".
62- Never use vague words alone such as harness, code, project, task, work, setup, misc, or help.
63- Do not repeat the folder name. No emoji, no quotes, no period.
64
65Otherwise reply with exactly one line: the label.`
66
67let isLabeling = false
68
69function colorFor(id: string): string {
70 let hash = 0
71 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
72 return PALETTE[hash % PALETTE.length] ?? '#90A4AE'
73}
74
75function folderOf(cwd: string): string {
76 const parts = cwd.split('/').filter(Boolean)
77 return parts[parts.length - 1] ?? cwd
78}
79
80// Takes the first line, drops anything before the first letter (a stray
81// emoji or bullet), quotes and a trailing period.
82function parseTask(text: string): string | undefined {
83 const line = text.trim().split('\n')[0] ?? ''
84 const task = line
85 .replace(/^[^a-zA-Z]+/, '')
86 .replace(/["'.]+$/g, '')
87 .trim()
88 .toLowerCase()
89 return task.length >= 3 && task.length <= 80 ? task : undefined
90}
91
92// Badges saved by earlier versions may hold a placeholder or a label that is
93// not verb-first; both get relabeled.
94function isNamed(b: Badge | null): boolean {
95 return !!b && !!b.task && b.task !== 'new session'
96}
97
98function isVerbFirst(b: Badge | null): boolean {
99 return !!b && /^[a-z]+ing\b/.test(b.task)
100}
101
102function badgeText(b: Badge): string {
103 return isNamed(b)
104 ? `This agent is ${b.task} in the ${b.folder} folder.`
105 : `This agent is in the ${b.folder} folder.`
106}
107
108function formatDuration(ms: number): string {
109 const minutes = Math.max(0, Math.floor(ms / MINUTE))
110 const days = Math.floor(minutes / (24 * 60))
111 const hours = Math.floor((minutes % (24 * 60)) / 60)
112 if (days > 0) return `${days}d ${hours}h`
113 if (hours > 0) return `${hours}h ${minutes % 60}m`
114 return `${minutes}m`
115}
116
117function median(sorted: number[]): number {
118 if (sorted.length === 0) return 0
119 const mid = Math.floor(sorted.length / 2)
120 return sorted.length % 2 ? sorted[mid] ?? 0 : Math.round(((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2)
121}
122
123// "Running 3h 12m · 65 turns · longer than 84% of your sessions (median 9)"
124export function progressText(p: Progress, past: TurnHistory | null): string {
125 const parts = [`Running ${formatDuration(p.now - p.startedAt)}`, `${p.turns} ${p.turns === 1 ? 'turn' : 'turns'}`]
126 if (past && past.counts.length > 0) {
127 const shorter = past.counts.filter(n => n < p.turns).length
128 const percent = Math.round((100 * shorter) / past.counts.length)
129 parts.push(`longer than ${percent}% of your sessions (median ${median(past.counts)})`)
130 }
131 return parts.join(' · ')
132}
133
134// Turn-count buckets for the card's histogram, and the bar characters.
135const BUCKETS: readonly [number, number][] = [
136 [1, 1], [2, 3], [4, 10], [11, 25], [26, 50], [51, 100], [101, 200], [201, Infinity],
137]
138const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
139
140// One bar per bucket, scaled to the fullest bucket, and which bucket this
141// session falls in (-1 before its first prompt).
142export function histogram(past: TurnHistory, turns: number): { bars: string[]; current: number } {
143 const sizes = BUCKETS.map(([lo, hi]) => past.counts.filter(n => n >= lo && n <= hi).length)
144 const most = Math.max(1, ...sizes)
145 const bars = sizes.map(size => BARS[Math.round((size / most) * (BARS.length - 1))] ?? '▁')
146 const current = BUCKETS.findIndex(([lo, hi]) => turns >= lo && turns <= hi)
147 return { bars, current }
148}
149
150async function refreshProgress($: EngineInterface): Promise<void> {
151 const [usage, turns, now] = await Promise.all([$.session.usage(), $.session.turns(), $.clock.now()])
152 await update($, progress, () => ({ startedAt: usage.startedAt, now, turns }))
153}
154
155async function refreshHistory($: EngineInterface): Promise<void> {
156 const ran = await $.process
157 .run(['python3', '-c', COUNT_TURNS_PY], { timeoutMs: 60000 })
158 .catch(() => undefined)
159 if (!ran || ran.exitCode !== 0) return
160 const own = await $.session.id()
161 // Claude Code deletes transcripts after cleanupPeriodDays (30 by default),
162 // so every count seen is kept in the store and merged with the transcripts
163 // still on disk; the history then reaches back past the cleanup.
164 const kept = ((await $.store.get(COUNTS_KEY)) ?? {}) as Record<string, number>
165 const onDisk = JSON.parse(ran.stdout) as Record<string, number>
166 const byId: Record<string, number> = { ...kept }
167 for (const [id, n] of Object.entries(onDisk)) byId[id] = Math.max(n, byId[id] ?? 0)
168 await $.store.set(COUNTS_KEY, byId)
169 const counts = Object.entries(byId)
170 .filter(([id, n]) => id !== own && n > 0)
171 .map(([, n]) => n)
172 .sort((a, b) => a - b)
173 await update($, history, () => ({ counts }))
174}
175
176async function storeKey($: EngineInterface): Promise<string> {
177 return `badge:${await $.session.id()}`
178}
179
180// Saves the badge, so a resumed session keeps it.
181async function publish($: EngineInterface, next: Badge): Promise<void> {
182 await update($, badge, () => next)
183 await $.store.set(await storeKey($), next)
184}
185
186async function relabel($: EngineInterface): Promise<void> {
187 if (isLabeling) return
188 const current = await read($, badge)
189 if (current?.isPinned) return
190 isLabeling = true
191 try {
192 const said = (await $.session.messages())
193 .filter(m => m.role === 'user' && m.text.trim())
194 .map(m => m.text.trim().slice(0, 400))
195 if (said.length === 0) return
196 const picked = said.slice(-RECENT_MESSAGES)
197 const folder = current?.folder ?? folderOf(await $.session.root())
198 const known = current && isNamed(current) && isVerbFirst(current) ? current.task : '(none)'
199 const reply = await $.model.complete({
200 model: 'haiku',
201 prompt: `${LABEL_PROMPT}\n\nFolder: ${folder}\nCurrent label: ${known}\n\nMessages:\n${picked.map((t, i) => `${i + 1}. ${t}`).join('\n')}`,
202 maxTokens: 40,
203 })
204 if (!reply.isAnswered) return
205 if (reply.text.trim().toUpperCase().startsWith('SAME')) return
206 const task = parseTask(reply.text)
207 if (!task) return
208 const color = current?.color ?? colorFor(await $.session.id())
209 await publish($, { folder, color, task, isPinned: false })
210 } finally {
211 isLabeling = false
212 }
213}
214
215export const register: Register = on => {
216 on('session.start', async ($, e, next) => {
217 await $.command.register({
218 name: 'badge',
219 description: "Show or set what this session's badge says: /badge <task>, or /badge auto",
220 })
221
222 const saved = (await $.store.get(await storeKey($))) as Badge | undefined
223 const start: Badge = saved ?? {
224 folder: folderOf(await $.session.root()),
225 task: '',
226 color: colorFor(await $.session.id()),
227 isPinned: false,
228 }
229 await publish($, start)
230 // The badge lives in the band alone; clear a status line an older version pinned.
231 $.ui.status(undefined)
232 await refreshProgress($)
233 const started = await next(e)
234 // A session that already has a conversation (resumed, or the mod loaded
235 // mid-session) gets its label now instead of after the next turn.
236 if ((await $.session.turns()) > 0) void relabel($)
237
238 // The elapsed time moves every minute; the distribution is recounted
239 // now and every couple of hours. Both timers stop when the module reloads.
240 $.clock.every(MINUTE, () => {
241 void update($, progress, p => (p ? { ...p, now: p.now + MINUTE } : p))
242 })
243 void refreshHistory($)
244 $.clock.every(HISTORY_REFRESH_MS, () => {
245 void refreshHistory($)
246 })
247
248 return started
249 })
250
251 on('turn.complete', async ($, e, next) => {
252 const result = await next(e)
253 if (e.agentId !== undefined) return result
254 await refreshProgress($)
255 // Checked after every turn; relabel keeps the label unless the topic
256 // changed, and skips a pinned badge and a call already in flight.
257 void relabel($)
258
259 return result
260 })
261
262 on('command.run', { command: 'badge' }, async ($, e) => {
263 const current = await read($, badge)
264 const args = e.args.trim()
265 if (!args) {
266 return { text: current ? `${badgeText(current)}${current.isPinned ? ' (pinned)' : ''}` : 'No badge yet.' }
267 }
268 if (args === 'auto') {
269 if (current) await publish($, { ...current, isPinned: false })
270 void relabel($)
271 return { text: 'Badge set to relabel automatically.' }
272 }
273 const task = parseTask(args)
274 if (!task) return { text: 'Usage: /badge <what the agent is doing>, or /badge auto' }
275 const pinned: Badge = {
276 folder: current?.folder ?? folderOf(await $.session.root()),
277 color: current?.color ?? colorFor(await $.session.id()),
278 task,
279 isPinned: true,
280 }
281 await publish($, pinned)
282 return { text: `Badge pinned: ${badgeText(pinned)}` }
283 })
284
285 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
286 const current = await read($, badge)
287 if (!current || e.props.hasSurvey) return next(e)
288
289 const { Box, Text } = $.ui.resolve(e)
290 const p = await read($, progress)
291 const past = await read($, history)
292 const chart = p && past && past.counts.length > 0 ? histogram(past, p.turns) : null
293
294 return (
295 <Box borderStyle="round" borderColor={current.color} paddingX={1} alignSelf="flex-start" flexDirection="column">
296 <Text dimColor>
297 {'This agent is '}
298 {isNamed(current) ? <Text color={current.color} bold>{`${current.task} `}</Text> : null}
299 {'in the '}
300 <Text color={current.color}>{current.folder}</Text>
301 {' folder.'}
302 </Text>
303 {p ? <Text dimColor>{progressText(p, past)}</Text> : null}
304 {chart ? (
305 <Text dimColor>
306 {'turns per session 1 '}
307 {chart.bars.map((bar, i) =>
308 i === chart.current ? (
309 <Text key={`bar-${i}`} color={current.color} bold>{bar}</Text>
310 ) : (
311 bar
312 ),
313 )}
314 {' 500'}
315 </Text>
316 ) : null}
317 </Box>
318 )
319 })
320}
321types/index.d.ts 34 lines1/** What the badge shows: what the agent is doing, the folder it works in, and a color. */
2export type Badge = {
3 /** The last segment of the session's working directory, e.g. "evals-course-revamp". */
4 folder: string
5 /** What the agent is doing, starting with an -ing verb, e.g. "building a session indicator mod". */
6 task: string
7 /** A hex color picked from the session id, so each session keeps its own color. */
8 color: string
9 /** Set with /badge <task>; automatic relabeling stops while pinned. */
10 isPinned: boolean
11}
12
13/** How long this session has run and how many prompts it has had. */
14export type Progress = {
15 /** When the session first launched, in `$.clock.now()` milliseconds. */
16 startedAt: number
17 /** The latest clock reading; a timer moves it every minute so the card redraws. */
18 now: number
19 /** Prompts the person has sent in this session. */
20 turns: number
21}
22
23/** Prompt counts of the person's other sessions, read from their transcripts. */
24export type TurnHistory = {
25 /** One entry per other session with at least one prompt, sorted ascending. */
26 counts: number[]
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'session-badge': { badge: Badge | null; progress: Progress | null; history: TurnHistory | null }
32 }
33}
34