SLOPSHOPPER

session-brief

Shows a short brief above the prompt (how the session started, where it left off, next steps, things to know) and renames the session to say what the work is.

newbandcommandtoastmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-brief
› 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 › /brief ⎿ session-brief: Writing the session brief. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

session-brief

A band above the prompt that tells you, at a glance, what a session was about:

  • Started: the command or skill the session opened with, and what it was asked to do.
  • Left off: what is done, what is not, and whether the last turn waits on you.
  • Next: up to three concrete steps. After a planning or triage session, "go implement" is one option, not the default.
  • You should know: up to three risks, blockers, open decisions or things that were not verified.

It also renames the session to say what the work is. "DP1-676" becomes something like "Triage DP1 queue: 7 routed, 3 held".

How it works

  1. After each main turn ends, the mod reads the session's messages: the first four (where the commands and skills are) and the last sixteen.
  2. It sends that digest to Haiku with a fixed set of rules and gets back a small JSON brief.
  3. It saves the brief per session, so reopening the session shows it at once with no model call.
  4. It renames the session through the desktop app's own session tool (set_session_title on ccd_session_mgmt). If you typed the current title yourself, the app asks you before it changes it.

A resumed session that has no brief yet gets one when it starts. Subagent turns, interrupted turns and sessions nobody watches (claude -p, scheduled tasks) are skipped. A turn that did not add messages does not call the model again.

Commands

CommandWhat it does
/briefWrites the brief again now
/brief hide / /brief showHides or shows the band for this session
/brief renameApplies the brief's title to the session now
/brief autorename off / onTurns automatic renaming off or on, for every session

The band also has Collapse, Refresh and Hide buttons.

Install

/plugin install session-brief@personal-plugins

Tests

claude plugin test plugins/session-brief
Source 2 files
hooks/register.tsx 369 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Brief, BriefStatus } from '../types'
5
6const brief = atom({ plugin: 'session-brief', key: 'brief' } as const, null as Brief | null)
7const status = atom({ plugin: 'session-brief', key: 'status' } as const, 'idle' as BriefStatus)
8const isCollapsed = atom({ plugin: 'session-brief', key: 'isCollapsed' } as const, false)
9const isHidden = atom({ plugin: 'session-brief', key: 'isHidden' } as const, false)
10
11// The desktop app's own session tools. "self" is the session this mod runs in.
12const SESSION_SERVER = 'ccd_session_mgmt'
13const MAX_DIGEST_CHARS = 40000
14
15export const BRIEF_RULES = `You write a short brief of a Claude Code working session, so the person can come back to it days later and know at once what it was about.
16
17You get a digest of the session: how it opened, and the last part of the conversation. Return JSON only, no prose and no code fence, in this shape:
18{"title": string, "started": string, "leftOff": string, "next": string[], "know": string[]}
19
20Rules:
21- title: at most 60 characters, sentence case, no trailing punctuation. Say the KIND of work and its subject, so it is clear without opening the session: "Triage DP1 queue: 7 routed, 3 held", "Plan duplicate-email check (DP1-687)", "Fix deal tax rounding (NEO-412)". Put a ticket key in brackets at the end only when one ticket is the subject. Never a bare ticket key. If a current title is given and still fits, return it unchanged.
22- started: one sentence. Name the slash command or skill the session opened with (for example /dealerpull-engineering:triage-queue) and what it was asked to do. If it opened with a plain request, say what was asked.
23- leftOff: one or two sentences. Where the work stopped: what is done, what is not, and whether the last turn waits on the person (a question, an approval, a choice).
24- next: at most 3 items, most important first. Concrete actions with the exact command, ticket key or file. If the session was planning or triage, say so and keep "go implement" as one option, not the default. Empty array when nothing is pending.
25- know: at most 3 items. Things the person must know before going on: decisions waiting on them, risks, blockers, open questions, things that were NOT done or NOT verified. Skip anything already in leftOff. Empty array when there is nothing.
26- Each item under 140 characters. Plain words. No markdown, no emojis.
27- Never include customer personal data: names, emails, phone numbers, addresses, VINs, payment details.`
28
29// Drops stale runs: a newer turn starts a newer brief.
30let runId = 0
31
32export const register: Register = on => {
33  on('session.start', async ($, e, next) => {
34    await $.command.register({
35      name: 'brief',
36      description: 'Session brief: refresh, show, hide, rename, or autorename on|off',
37      argumentHint: '[refresh|show|hide|rename|autorename on|off]',
38    })
39    // A reload cuts off a brief in progress: never start stuck on "updating".
40    await update($, status, () => 'idle')
41    const id = await $.session.id()
42    const saved = await $.store.get(`brief:${id}`)
43    if (isBrief(saved)) await update($, brief, () => saved)
44
45    // A resumed session with no brief yet gets one; a new session waits for its first turn.
46    if (!isBrief(saved) && (await $.session.surfaces()).length > 0) {
47      $.clock.after(0, () => void refresh($, { isForced: false }))
48    }
49
50    return next(e)
51  })
52
53  on('command.run', { command: 'brief' }, async ($, e) => {
54    const [word = 'refresh', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
55    if (word === 'hide') {
56      await update($, isHidden, () => true)
57      return { text: 'Session brief hidden. Type /brief show to bring it back.' }
58    }
59    if (word === 'show') {
60      await update($, isHidden, () => false)
61      return { text: 'Session brief shown.' }
62    }
63    if (word === 'autorename') {
64      const want = value !== 'off'
65      await $.store.set('autoRename', want)
66      return { text: want ? 'Sessions will be renamed to match the work.' : 'Automatic renaming is off.' }
67    }
68    if (word === 'rename') {
69      const current = await read($, brief)
70      if (current === null) return { text: 'No brief yet. Type /brief to write one first.' }
71      const isDone = await applyTitle($, current.title, { isForced: true })
72      return { text: isDone ? `Renamed to "${current.title}".` : 'Could not rename this session.' }
73    }
74    await update($, isHidden, () => false)
75    $.clock.after(0, () => void refresh($, { isForced: true }))
76
77    return { text: 'Writing the session brief.' }
78  })
79
80  on('turn.complete', async ($, e, next) => {
81    const result = await next(e)
82    if (e.agentId !== undefined || e.isAborted) return result
83    if ((await $.session.surfaces()).length === 0) return result
84    // The brief outlasts the hook's time budget, so it runs on a timer of its own.
85    $.clock.after(0, () => void refresh($, { isForced: false }))
86
87    return result
88  })
89
90  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91    // Other mods may draw in this band too: keep theirs, under the brief.
92    const below = await next(e)
93    // `view` may be absent on some surfaces: read it with care, or the hook throws and is skipped.
94    if (e.props.hasSurvey || e.props.view?.agentId !== undefined) return below
95    if (await read($, isHidden)) return below
96
97    const current = await read($, brief)
98    const now = await read($, status)
99    if (current === null && now === 'idle') return below
100
101    const { Box, Button, Text } = $.ui.resolve(e)
102    const collapsed = await read($, isCollapsed)
103    const age = current === null ? '' : ago((await $.clock.now()) - current.at)
104    const note = now === 'writing' ? 'updating…' : now === 'failed' ? 'update failed' : `updated ${age}`
105
106    const header = (
107      <Box flexDirection="row" gap={1} alignItems="center">
108        <Text bold>Session brief</Text>
109        <Text dimColor>{note}</Text>
110        <Box flexGrow={1} />
111        <Button
112          key="collapse"
113          label={collapsed ? 'Expand' : 'Collapse'}
114          dimColor
115          onPress={() => update($, isCollapsed, c => !c)}
116        />
117        <Button key="refresh" label="Refresh" dimColor onPress={() => void refresh($, { isForced: true })} />
118        <Button key="hide" label="Hide" dimColor onPress={() => update($, isHidden, () => true)} />
119      </Box>
120    )
121
122    const body =
123      current === null ? null : collapsed ? (
124        <Text dimColor wrap="truncate-end">
125          Left off: {current.leftOff}
126        </Text>
127      ) : (
128        <Box flexDirection="column" rowGap={1}>
129          <Box flexDirection="column">
130            <Text>
131              <Text bold>Started: </Text>
132              {current.started}
133            </Text>
134            <Text>
135              <Text bold>Left off: </Text>
136              {current.leftOff}
137            </Text>
138          </Box>
139          {current.next.length > 0 && (
140            <Box flexDirection="column">
141              <Text bold>Next</Text>
142              {current.next.map((step, i) => (
143                <Text key={`next-${i}`}>
144                  {'  '}
145                  {i + 1}. {step}
146                </Text>
147              ))}
148            </Box>
149          )}
150          {current.know.length > 0 && (
151            <Box flexDirection="column">
152              <Text bold>You should know</Text>
153              {current.know.map((fact, i) => (
154                <Text key={`know-${i}`} color="warning">
155                  {'  - '}
156                  {fact}
157                </Text>
158              ))}
159            </Box>
160          )}
161        </Box>
162      )
163
164    return (
165      <Box flexDirection="column" rowGap={1}>
166        <Box flexDirection="column" rowGap={1} borderStyle="round" borderDimColor paddingX={1}>
167          {header}
168          {body}
169        </Box>
170        {below}
171      </Box>
172    )
173  }).catch(($, e, next) => {
174    // A band that fails is skipped with no sign on the desktop: say why, once per load.
175    if (!hasWarnedRender && next.error !== undefined && next.error.kind !== 're-entry') {
176      hasWarnedRender = true
177      try {
178        $.ui.toast(`session-brief: the band failed to draw (${next.error.kind}). Type /brief to retry.`)
179      } catch {
180        // Nothing to show it on.
181      }
182    }
183    return next(e)
184  })
185}
186
187let hasWarnedRender = false
188
189async function refresh($: EngineInterface, { isForced }: { isForced: boolean }): Promise<void> {
190  runId += 1
191  const id = runId
192  const messages = await $.session.messages()
193  if (!Array.isArray(messages) || messages.length < 2) return
194  const previous = await read($, brief)
195  if (!isForced && previous !== null && previous.messages === messages.length) return
196
197  await update($, status, () => 'writing')
198  const written = await writeBrief($, messages, previous)
199  if (id !== runId) return
200  if (written === undefined) {
201    await update($, status, () => 'failed')
202    return
203  }
204  await update($, brief, () => written)
205  await update($, status, () => 'idle')
206  await $.store.set(`brief:${await $.session.id()}`, written)
207
208  if ((await $.store.get('autoRename')) !== false) {
209    await applyTitle($, written.title, { isForced: false })
210  }
211}
212
213export async function writeBrief(
214  $: EngineInterface,
215  messages: readonly SessionMessage[],
216  previous: Brief | null,
217): Promise<Brief | undefined> {
218  const currentTitle = (await $.store.get(`title:${await $.session.id()}`)) as string | undefined
219  const prompt = [
220    currentTitle === undefined ? 'No current title.' : `Current title: ${currentTitle}`,
221    '',
222    digest(messages),
223  ].join('\n')
224  const reply = await $.model.complete({
225    model: 'haiku',
226    system: [{ text: BRIEF_RULES, cache: true }],
227    prompt,
228    maxTokens: 800,
229    effort: 'low',
230    timeoutMs: 30000,
231  })
232  if (!reply.isAnswered) {
233    $.ui.log(`session-brief: no brief (${reply.reason})`, { to: 'debug' })
234    return undefined
235  }
236  const parsed = parseBrief(reply.text)
237  if (parsed === undefined) {
238    $.ui.log('session-brief: the model did not return a brief in JSON', { to: 'debug' })
239    return previous ?? undefined
240  }
241
242  return { ...parsed, messages: messages.length, at: await $.clock.now() }
243}
244
245// The opening (where the commands and skills are) plus the latest stretch of the conversation.
246export function digest(messages: readonly SessionMessage[]): string {
247  const opening = messages.slice(0, 4)
248  const tailStart = Math.max(opening.length, messages.length - 16)
249  const tail = messages.slice(tailStart)
250  const parts = ['## How the session opened', ...opening.map(m => line(m, 2000))]
251  if (tailStart > opening.length) {
252    parts.push(`\n(${tailStart - opening.length} messages left out)\n`)
253  }
254  parts.push('## The latest part of the session', ...tail.map(m => line(m, 3000)))
255  const text = parts.filter(part => part !== '').join('\n')
256
257  return text.length > MAX_DIGEST_CHARS ? text.slice(-MAX_DIGEST_CHARS) : text
258}
259
260function line(m: SessionMessage, max: number): string {
261  const who = m.role === 'user' ? 'USER' : 'ASSISTANT'
262  const text = m.text.trim()
263  const body = text.length > max ? `${text.slice(0, max)}…` : text
264  const tools = m.toolUses.map(useLabel).filter(t => t !== '')
265  const used = tools.length > 0 ? `\n  [tools: ${unique(tools).join(', ')}]` : ''
266  if (body === '' && used === '') return ''
267
268  return `${who}: ${body}${used}`
269}
270
271function useLabel(use: SessionMessage['toolUses'][number]): string {
272  const input = use.input
273  if (use.tool === 'Skill' && typeof input.skill === 'string') return `skill ${input.skill}`
274  if (use.tool === 'Agent' && typeof input.subagent_type === 'string') return `agent ${input.subagent_type}`
275
276  return use.tool.startsWith('mcp__') ? `mcp:${use.tool.split('__').slice(2).join('__')}` : use.tool
277}
278
279function unique(list: string[]): string[] {
280  return [...new Set(list)]
281}
282
283export function parseBrief(text: string): Omit<Brief, 'messages' | 'at'> | undefined {
284  const start = text.indexOf('{')
285  const end = text.lastIndexOf('}')
286  if (start < 0 || end <= start) return undefined
287  try {
288    const raw = JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
289    const title = clean(raw.title, 60)
290    const started = clean(raw.started, 400)
291    const leftOff = clean(raw.leftOff, 500)
292    if (title === '' || leftOff === '') return undefined
293
294    return {
295      title,
296      started,
297      leftOff,
298      next: list(raw.next, 3),
299      know: list(raw.know, 3),
300    }
301  } catch {
302    return undefined
303  }
304}
305
306function clean(value: unknown, max: number): string {
307  return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : ''
308}
309
310function list(value: unknown, max: number): string[] {
311  if (!Array.isArray(value)) return []
312
313  return value
314    .map(item => clean(item, 200))
315    .filter(item => item !== '')
316    .slice(0, max)
317}
318
319// Renames through the desktop app's own session tool. A title the person typed themselves
320// makes the app ask them first, so an automatic rename only runs when the title changed.
321async function applyTitle(
322  $: EngineInterface,
323  title: string,
324  { isForced }: { isForced: boolean },
325): Promise<boolean> {
326  const id = await $.session.id()
327  const applied = await $.store.get(`title:${id}`)
328  if (!isForced && applied === title) return true
329  try {
330    const result = await $.mcp.call(SESSION_SERVER, 'set_session_title', {
331      session_id: 'self',
332      title,
333    })
334    if (result.isError) {
335      $.ui.log(`session-brief: rename refused: ${textOf(result.content)}`, { to: 'debug' })
336      return false
337    }
338    await $.store.set(`title:${id}`, title)
339    return true
340  } catch (err) {
341    $.ui.log(`session-brief: rename failed: ${String(err)}`, { to: 'debug' })
342    return false
343  }
344}
345
346function textOf(content: readonly { type: string }[]): string {
347  return content
348    .map(block => ('text' in block && typeof block.text === 'string' ? block.text : ''))
349    .join(' ')
350    .slice(0, 300)
351}
352
353function isBrief(value: unknown): value is Brief {
354  if (typeof value !== 'object' || value === null) return false
355  const v = value as Record<string, unknown>
356
357  return typeof v.title === 'string' && typeof v.leftOff === 'string' && Array.isArray(v.next)
358}
359
360function ago(ms: number): string {
361  const minutes = Math.max(0, Math.round(ms / 60000))
362  if (minutes < 1) return 'just now'
363  if (minutes < 60) return `${minutes} min ago`
364  const hours = Math.round(minutes / 60)
365  if (hours < 48) return `${hours} h ago`
366
367  return `${Math.round(hours / 24)} days ago`
368}
369
types/index.d.ts 30 lines
1export type Brief = {
2  /** Short session title that says what the work is. */
3  title: string
4  /** How the session started: the first commands or skills, and what they were for. */
5  started: string
6  /** Where the session stopped. */
7  leftOff: string
8  /** Next steps, most important first. Empty when nothing is pending. */
9  next: string[]
10  /** Facts, risks or open questions worth knowing before going on. */
11  know: string[]
12  /** Main-thread messages the brief was written from. */
13  messages: number
14  /** When it was written, in $.clock.now() milliseconds. */
15  at: number
16}
17
18export type BriefStatus = 'idle' | 'writing' | 'failed'
19
20declare module 'claude-code' {
21  interface PluginState {
22    'session-brief': {
23      brief: Brief | null
24      status: BriefStatus
25      isCollapsed: boolean
26      isHidden: boolean
27    }
28  }
29}
30