/conversations: a panel of this project's past Claude Code conversations by meaningful title; click one to resume it

A Claude Code plugin marketplace with three mods.
| Plugin | What it does |
|---|---|
prompt-enhance | End a prompt with ++ and press Enter: instead of sending it, Claude rewrites it into a sharper prompt and puts it back in the prompt bar for you to review and send. |
session-boost | /subagents opens a live side panel of every subagent, with a one-click cheaper-model suggestion per row. /boost (mid-turn) starts a watcher that launches read-only helper subagents. Subagents run on the chat's own model by default. |
conversations | /conversations lists this project's past conversations by title; click one to resume it. |
None of the plugins ship commands/, skills/ or agents/ folders. Each one is a single hooks module (hooks/hooks.json → hooks/register.ts(x)) that registers its commands, panels, tools and agents when a session starts.
What it adds
prompt.submit hook for prompts typed in the prompt bar. No slash commands.How to use it
++, e.g. fix the flaky login test ++.How it rewrites
/model shows it), at high effort, with a 120 s timeout.If something goes wrong
++ on its own is dropped with Nothing to enhance.What it adds
| Item | Kind | What it does |
|---|---|---|
/subagents | Command | Opens the Subagents side panel. |
/boost | Command | Starts a watcher that launches read-only helper subagents for the turn in progress. |
/boost stop | Command | Stops a running boost. |
| Subagent model default | agent.spawn hook | Subagents run on the chat's own model unless the caller names one. |
session-boost:watcher | Agent (internal) | The /boost watcher. Hidden from the main model. |
session-boost:helper | Agent (internal) | A read-only helper started by the watcher. Hidden from the main model. |
mcp__session-boost__progress | Tool (internal) | Lets the watcher wait for new main-chat activity. Only the watcher may call it. |
mcp__session-boost__spawn_helper | Tool (internal) | Lets the watcher queue a helper. Only the watcher may call it. |
The Subagents panel (/subagents)
The panel opens by itself at the start of an interactive session; /subagents reopens it. It refreshes every 2 seconds and shows one row per subagent:
● running, ○ pending, waiting or idle, ✓ completed, ✗ failed or killed), the description, the agent type, a [watcher] / [helper] tag for boost agents, and the model it runs on.Cheaper-model suggestions
When a cheaper model suits a row's job (Haiku 5.5 for lookup, Sonnet 5.5 for coding, Opus 5.5 for reasoning), the row shows a ▲ use <model> button with the price and the saving, e.g. $0.10/$0.50, 98% less. It blinks while the subagent is still running.
✓ <model>.✓ again to undo; that kind follows the chat's model again.Which model subagents run on
By default every subagent runs on the chat's own model, even when its agent definition pins one (a built-in's haiku, say). A model the caller names explicitly, or a pick you made in the panel, wins. Forks and workflow agents are left alone.
Boost (/boost)
/boost. (With nothing in progress it tells you so.)[session-boost] message and a toast appears. The main chat uses it if it helps./boost stop ends it early: no new helpers, queued ones are dropped, running ones finish and report.Helpers can use Read, Grep, Glob, Bash, WebFetch and WebSearch, and are told never to modify anything (no edits, commits, checkouts, installs). Their answers are capped at about 300 words with file:line citations.
What it adds
/conversations: a command that opens the Past conversations panel.How to use it
/conversations. The panel opens focused on the newest conversation./resume <id> for that conversation.What it lists
~/.claude/projects/<project>/ (or under $CLAUDE_CONFIG_DIR if set), newest first, up to 60.Where titles come from, in order:
/rename.Titles are cached per transcript and only worked out again when the transcript changes, so the panel fills at once on later opens.
Inside Claude Code, add the marketplace once per machine:
/plugin marketplace add nadesh00/setup-code
Then install the plugins you want:
/plugin install prompt-enhance@setup-code
/plugin install session-boost@setup-code
/plugin install conversations@setup-code
Run /reload-plugins (or start a new session) if Claude Code asks you to.
Plugins here carry no version, so an update is any new commit. To get updates automatically, run /plugin, open the Marketplaces tab, select setup-code and choose Enable auto-update (it is off by default for marketplaces you add). To update by hand: /plugin marketplace update setup-code, then update each plugin from the Installed tab.
Each plugin is a hooks module under plugins/<name>/hooks/. Check one with:
claude plugin validate plugins/<name>
claude plugin test plugins/<name>hooks/register.tsx 172 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Conversation, Listing } from '../types'
5import {
6 cleanTitle, MAX_LISTED, oneLine, PANE, projectSlug, promptTitle, relativeTime, scanTranscript, TITLE_MODEL,
7 titlePrompt,
8} from './core'
9
10const EMPTY: Listing = { status: 'idle', conversations: [] }
11
12const listing = atom({ plugin: 'conversations', key: 'listing' } as const, EMPTY)
13const resuming = atom({ plugin: 'conversations', key: 'resuming' } as const, null)
14
15/** What one transcript came to, kept in the store until the file changes. */
16type Cached = { size: number; mtimeMs: number; conversation: Conversation | null }
17
18/** `$.fs.read` takes up to 4 MiB; a longer transcript is grepped for what the scan needs. */
19const READ_LIMIT = 4 * 1024 * 1024
20
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 await $.command.register({
24 name: 'conversations',
25 description: 'List this project\'s past conversations by title; pick one to resume it',
26 immediate: true,
27 })
28
29 return next(e)
30 })
31
32 on('command.run', { command: 'conversations' }, async $ => {
33 const opened = await $.ui.open({ id: PANE, title: 'Past conversations', focus: true })
34 $.clock.after(0, () => void refresh($))
35
36 return { text: opened.isPlaced ? 'Past conversations: pick one to resume it.' : `Past conversations waits: ${opened.reason}` }
37 })
38
39 on('ui.render', { component: 'Pane', requestId: 'conversations' }, async ($, e) => {
40 const { Box, Button, Text } = $.ui.resolve(e)
41 const [list, pending] = await Promise.all([read($, listing), read($, resuming)])
42 const now = Date.now()
43 const width = e.props.bodyColumns
44
45 if (list.status === 'error') return <Text color="red">Could not list conversations: {list.error ?? 'unknown error'}</Text>
46 if (list.conversations.length === 0) {
47 return <Text dimColor>{list.status === 'loading' ? 'Reading past conversations…' : 'No past conversations in this project.'}</Text>
48 }
49
50 return (
51 <Box flexDirection="column" width={width}>
52 {list.status === 'loading' && <Text dimColor>Titling conversations…</Text>}
53 {list.conversations.map((conversation, i) => (
54 <Box key={conversation.id} flexDirection="column" marginBottom={1}>
55 <Button
56 key={`open-${conversation.id}`}
57 label={oneLine(conversation.title, Math.max(10, width - 4))}
58 plain
59 {...(i === 0 ? { autoFocus: true as const } : {})}
60 onPress={() => resume($, conversation)}
61 />
62 <Text dimColor wrap="truncate-end">
63 {' '}
64 {pending === conversation.id ? 'resuming…' : relativeTime(conversation.updatedAt, now)}
65 {` · ${conversation.prompts} prompt${conversation.prompts === 1 ? '' : 's'}`}
66 </Text>
67 </Box>
68 ))}
69 </Box>
70 )
71 })
72}
73
74/** Reopens a conversation: `/resume <id>`, as if the person typed it. */
75async function resume($: EngineInterface, conversation: Conversation) {
76 await update($, resuming, () => conversation.id)
77 try {
78 await $.command.run({ command: 'resume', args: conversation.id })
79 } catch (error) {
80 $.ui.toast(`Could not resume "${conversation.title}": ${error instanceof Error ? error.message : String(error)}`)
81 } finally {
82 await update($, resuming, () => null)
83 }
84}
85
86/** Lists the project's transcripts, newest first, titling each (from the store where unchanged). */
87async function refresh($: EngineInterface) {
88 const current = await read($, listing)
89 if (current.status === 'loading') return
90 await update($, listing, (l): Listing => ({ ...l, status: 'loading' }))
91 try {
92 const [root, home, configDir, self] = await Promise.all([
93 $.session.root(),
94 $.env.get('HOME'),
95 $.env.get('CLAUDE_CONFIG_DIR'),
96 $.session.id(),
97 ])
98 const dir = `${configDir ?? `${home ?? ''}/.claude`}/projects/${projectSlug(root)}`
99 if (!(await $.fs.exists(dir))) {
100 await update($, listing, (): Listing => ({ status: 'idle', conversations: [] }))
101 return
102 }
103 const files = (await $.fs.list(dir))
104 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl') && entry.name !== `${self}.jsonl`)
105 .sort((a, b) => b.mtimeMs - a.mtimeMs)
106 .slice(0, MAX_LISTED)
107
108 // Cached and quick ones first, so the panel fills at once; titles to generate follow.
109 const found: Conversation[] = []
110 const untitled: Array<{ path: string; id: string; size: number; mtimeMs: number }> = []
111 for (const file of files) {
112 const id = file.name.slice(0, -'.jsonl'.length)
113 const cached = (await $.store.get(`t:${id}`)) as Cached | undefined
114 if (cached && cached.size === file.size && cached.mtimeMs === file.mtimeMs) {
115 if (cached.conversation) found.push(cached.conversation)
116 continue
117 }
118 untitled.push({ path: `${dir}/${file.name}`, id, size: file.size, mtimeMs: file.mtimeMs })
119 }
120 await show($, found, 'loading')
121
122 for (const file of untitled) {
123 const conversation = await describe($, file)
124 await $.store.set(`t:${file.id}`, { size: file.size, mtimeMs: file.mtimeMs, conversation } satisfies Cached)
125 if (conversation) {
126 found.push(conversation)
127 await show($, found, 'loading')
128 }
129 }
130 await show($, found, 'idle')
131 } catch (error) {
132 await update($, listing, (): Listing => ({
133 status: 'error',
134 conversations: [],
135 error: error instanceof Error ? error.message : String(error),
136 }))
137 }
138}
139
140async function show($: EngineInterface, found: readonly Conversation[], status: Listing['status']) {
141 const conversations = [...found].sort((a, b) => b.updatedAt - a.updatedAt)
142 await update($, listing, (): Listing => ({ status, conversations }))
143}
144
145/** One transcript as a listed conversation; null when it holds no prompt (nothing to resume). */
146async function describe(
147 $: EngineInterface,
148 file: { path: string; id: string; size: number; mtimeMs: number },
149): Promise<Conversation | null> {
150 const text =
151 file.size <= READ_LIMIT
152 ? await $.fs.read(file.path)
153 : (await $.process.run(['grep', '-E', '"type":"(user|ai-title|custom-title|summary)"', file.path])).stdout
154 const scanned = scanTranscript(text)
155 if (scanned.promptCount === 0) return null
156 const base = { id: file.id, updatedAt: file.mtimeMs, prompts: scanned.promptCount }
157 if (scanned.customTitle) return { ...base, title: scanned.customTitle, titleSource: 'custom' }
158 if (scanned.aiTitle) return { ...base, title: scanned.aiTitle, titleSource: 'ai' }
159
160 const reply = await $.model.complete({
161 model: TITLE_MODEL,
162 prompt: titlePrompt(scanned.prompts),
163 maxTokens: 60,
164 effort: 'low',
165 timeoutMs: 20_000,
166 })
167 const title = reply.isAnswered ? cleanTitle(reply.text) : ''
168 if (title !== '') return { ...base, title, titleSource: 'generated' }
169
170 return { ...base, title: promptTitle(scanned.prompts[0] ?? ''), titleSource: 'prompt' }
171}
172hooks/core.ts 111 lines1export const PANE = 'conversations'
2export const TITLE_MODEL = 'haiku'
3/** Conversations listed, newest first. */
4export const MAX_LISTED = 60
5/** Prompts read from a transcript to title it. */
6export const PROMPTS_FOR_TITLE = 6
7
8/** The folder Claude Code keeps a project's transcripts in: the path with every other character a dash. */
9export function projectSlug(root: string): string {
10 return root.replace(/[^a-zA-Z0-9]/g, '-')
11}
12
13export type Scanned = {
14 customTitle?: string
15 aiTitle?: string
16 /** The person's typed prompts, in order (commands, caveats and tool results left out). */
17 prompts: string[]
18 promptCount: number
19}
20
21/** What a transcript's JSONL lines say about it: its titles and the person's own prompts. */
22export function scanTranscript(text: string): Scanned {
23 const scanned: Scanned = { prompts: [], promptCount: 0 }
24 for (const line of text.split('\n')) {
25 if (line.trim() === '') continue
26 let row: Record<string, unknown>
27 try {
28 row = JSON.parse(line) as Record<string, unknown>
29 } catch {
30 continue
31 }
32 if (row.type === 'custom-title' && typeof row.customTitle === 'string') scanned.customTitle = row.customTitle
33 else if (row.type === 'ai-title' && typeof row.aiTitle === 'string') scanned.aiTitle = row.aiTitle
34 else if (row.type === 'summary' && typeof row.summary === 'string' && !scanned.aiTitle) scanned.aiTitle = row.summary
35 else if (row.type === 'user' && row.isMeta !== true && row.isSidechain !== true) {
36 const prompt = promptText(row.message)
37 if (prompt === null) continue
38 scanned.promptCount += 1
39 if (scanned.prompts.length < PROMPTS_FOR_TITLE) scanned.prompts.push(prompt)
40 }
41 }
42
43 return scanned
44}
45
46/** A user row's typed text, or null for a command, a caveat, an interrupt or a tool result. */
47export function promptText(message: unknown): string | null {
48 if (typeof message !== 'object' || message === null) return null
49 const content = (message as { content?: unknown }).content
50 let text = ''
51 if (typeof content === 'string') text = content
52 else if (Array.isArray(content)) {
53 if (content.some(block => (block as { type?: string })?.type === 'tool_result')) return null
54 text = content
55 .filter(block => (block as { type?: string })?.type === 'text')
56 .map(block => String((block as { text?: unknown }).text ?? ''))
57 .join('\n')
58 }
59 text = text.trim()
60 if (text === '' || text.startsWith('<') || text.startsWith('[Request interrupted')) return null
61
62 return text
63}
64
65export function titlePrompt(prompts: readonly string[]): string {
66 return [
67 'Here are the first prompts a person typed in a Claude Code session. Write a title for the conversation:',
68 '3 to 7 words, sentence case, naming what it was about (the task or topic), no quotes, no trailing period.',
69 'Reply with the title only.',
70 '',
71 ...prompts.map((prompt, i) => `<prompt ${i + 1}>\n${prompt.slice(0, 1500)}\n</prompt ${i + 1}>`),
72 ].join('\n')
73}
74
75/** A model's title, tidied: one line, no quotes or label, capped. */
76export function cleanTitle(text: string): string {
77 const line = (text.trim().split('\n')[0] ?? '')
78 .replace(/^title:\s*/i, '')
79 .replace(/^["'“]+|["'”]+$/g, '')
80 .replace(/[.。]+$/, '')
81 .replace(/["'”]+$/, '')
82 .trim()
83
84 return oneLine(line, 80)
85}
86
87/** A title from the first prompt itself, when no model answers. */
88export function promptTitle(prompt: string): string {
89 return oneLine(prompt.charAt(0).toUpperCase() + prompt.slice(1), 60)
90}
91
92export function relativeTime(then: number, now: number): string {
93 const minutes = Math.floor((now - then) / 60_000)
94 if (minutes < 1) return 'just now'
95 if (minutes < 60) return `${minutes} min ago`
96 const hours = Math.round(minutes / 60)
97 if (hours < 24) return `${hours} h ago`
98 const days = Math.round(hours / 24)
99 if (days === 1) return 'yesterday'
100 if (days < 30) return `${days} days ago`
101 const months = Math.round(days / 30)
102
103 return months < 12 ? `${months} mo ago` : `${Math.round(months / 12)} y ago`
104}
105
106export function oneLine(text: string, max: number): string {
107 const line = text.replace(/\s+/g, ' ').trim()
108
109 return line.length > max ? `${line.slice(0, max - 1)}…` : line
110}
111types/index.d.ts 25 lines1/** One past conversation as the panel lists it. */
2export type Conversation = {
3 /** What `/resume` takes. */
4 id: string
5 title: string
6 /** Where the title came from: the person's /rename, the engine's own, or this mod's model call. */
7 titleSource: 'custom' | 'ai' | 'generated' | 'prompt'
8 /** The transcript's last write, ms since the epoch. */
9 updatedAt: number
10 /** Prompts the person typed in it. */
11 prompts: number
12}
13
14export type Listing = {
15 status: 'idle' | 'loading' | 'error'
16 conversations: Conversation[]
17 error?: string
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 conversations: { listing: Listing; resuming: string | null }
23 }
24}
25