SLOPSHOPPER

session-badge

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

newbandcommandstatusmodelprocess
v0.1.0no licenseupdated 2026-10-14shreyashankar/session-badge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-badge
› 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 › /badge ⎿ session-badge: This agent is in the app folder. ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ This agent is in the app folder. │ │ Running 30m · 1 turn │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ This agent is in the app folder. │ │ Running 30m · 1 turn │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

session-badge

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:

  • The task of the agent, for example "fixing the hw2 session id".
  • The folder of the session.
  • The run time of the session.
  • The number of prompts in the session.
  • A comparison of this number with the number of prompts in your other sessions.

Each session has a different color. The color helps you identify each session.

Install

  1. Open Claude Code in a terminal.
  2. Type this command at the prompt:
   /plugin install session-badge --marketplace shreyashankar/session-badge
  1. Type y to add the marketplace.
  2. Push Enter to select the user scope.

Use

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.

Data that the mod reads

  • The mod sends your last 10 messages in the session to Claude Haiku. Claude Haiku writes the task label.
  • The mod counts the prompts in your saved transcripts in ~/.claude/projects. A local python3 script does this count. The mod keeps only the counts.
Source 2 files
hooks/register.tsx 321 lines
1import { 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}
321
types/index.d.ts 34 lines
1/** 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