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.

A band above the prompt that tells you, at a glance, what a session was about:
It also renames the session to say what the work is. "DP1-676" becomes something like "Triage DP1 queue: 7 routed, 3 held".
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.
| Command | What it does |
|---|---|
/brief | Writes the brief again now |
/brief hide / /brief show | Hides or shows the band for this session |
/brief rename | Applies the brief's title to the session now |
/brief autorename off / on | Turns automatic renaming off or on, for every session |
The band also has Collapse, Refresh and Hide buttons.
/plugin install session-brief@personal-plugins
claude plugin test plugins/session-briefhooks/register.tsx 369 lines1import { 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}
369types/index.d.ts 30 lines1export 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