SLOPSHOPPER

conversations

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

panecommandtoastmodelprocesstimer
v?no licenseupdated 2026-10-11nadesh00/setup-code/plugins/conversations
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · conversations
│ ┃ Past conversations ✕ › fix the failing auth test and add an audit log call │ ┃ No past conversations in this project. │ ⏺ 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 │ │ › /conversations │ ⎿ conversations: Past conversations: pick one to resume it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Past conversations
No past conversations in this project.
README

setup-code

A Claude Code plugin marketplace with three mods.

PluginWhat it does
prompt-enhanceEnd 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.

Plugins

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.

prompt-enhance

What it adds

  • A prompt.submit hook for prompts typed in the prompt bar. No slash commands.

How to use it

  1. Type a prompt and end it with a space and ++, e.g. fix the flaky login test ++.
  2. Press Enter. The prompt is not sent; the status line shows Enhancing prompt….
  3. The rewritten prompt lands in the prompt bar. If the bar is empty or still holds your original, it is replaced; if you started typing something else, the rewrite is appended after it.
  4. Review it, edit if you like, and press Enter to send.

How it rewrites

  • Uses the model the chat is on now (as /model shows it), at high effort, with a 120 s timeout.
  • Gets the working directory and the last 6 messages of the conversation, only to resolve references like "that bug" or "this file". It never adds tasks from them.
  • Keeps your intent, language, tone, code and paths. Short prompts stay short; a clear prompt comes back nearly unchanged.
  • For a change task it adds a one-line finish line ("Done means: …"); for a long task with a real decision point, "Stop and ask me only if …"; for research, a note to mark anything unconfirmed.
  • Removes "think step by step", ALL-CAPS / "IMPORTANT" emphasis and "You are an expert…" preambles.

If something goes wrong

  • ++ on its own is dropped with Nothing to enhance.
  • If the model doesn't answer, your original prompt is put back in the bar and a toast says why.
  • If the hook itself fails, your prompt is sent as normal.

session-boost

What it adds

ItemKindWhat it does
/subagentsCommandOpens the Subagents side panel.
/boostCommandStarts a watcher that launches read-only helper subagents for the turn in progress.
/boost stopCommandStops a running boost.
Subagent model defaultagent.spawn hookSubagents run on the chat's own model unless the caller names one.
session-boost:watcherAgent (internal)The /boost watcher. Hidden from the main model.
session-boost:helperAgent (internal)A read-only helper started by the watcher. Hidden from the main model.
mcp__session-boost__progressTool (internal)Lets the watcher wait for new main-chat activity. Only the watcher may call it.
mcp__session-boost__spawn_helperTool (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:

  • A status mark (● 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.
  • What it is doing now: its in-flight tool call, or its last line of text. Finished agents show their final answer.
  • The kind of job it reads as: lookup, coding or reasoning, judged from its type, its description and the tools it has used.
  • The last 12 finished agents are kept below the running ones.

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.

  • Click it and future subagents of that kind (agent type + job, e.g. Explore lookup jobs) start on that model. The button turns to ✓ <model>.
  • Click ✓ again to undo; that kind follows the chat's model again.
  • Picks are saved across sessions and are never pricier than the chat's own model.
  • Rows already on the cheapest suitable model say so instead.

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)

  1. While Claude is working on a turn, run /boost. (With nothing in progress it tells you so.)
  2. A watcher subagent starts. It follows what the main chat does and, when a clear win shows up, launches read-only helpers: finding call sites, running the tests or build, reviewing changes so far, reading docs the main chat will need.
  3. At most 3 helpers run at once; more are queued. The panel shows a line like Boost watching: 2 running, 1 queued, 3 done.
  4. When a helper finishes, its answer is added to the main chat as a [session-boost] message and a toast appears. The main chat uses it if it helps.
  5. The boost ends by itself when the main turn ends. /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.

conversations

What it adds

  • /conversations: a command that opens the Past conversations panel.

How to use it

  1. Run /conversations. The panel opens focused on the newest conversation.
  2. Each row shows a title, how long ago it was last active and how many prompts you typed in it.
  3. Click a row (or press Enter on it) to run /resume <id> for that conversation.

What it lists

  • This project's transcripts from ~/.claude/projects/<project>/ (or under $CLAUDE_CONFIG_DIR if set), newest first, up to 60.
  • The current session and conversations with no typed prompt are left out.

Where titles come from, in order:

  1. Your own title, set with /rename.
  2. Claude Code's own title or summary for the conversation.
  3. A 3–7 word title written by Haiku from your first 6 prompts.
  4. Your first prompt, shortened.

Titles are cached per transcript and only worked out again when the transcript changes, so the panel fills at once on later opens.

Install

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.

Updates

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.

Developing

Each plugin is a hooks module under plugins/<name>/hooks/. Check one with:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>
Source 3 files
hooks/register.tsx 172 lines
1import { 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}
172
hooks/core.ts 111 lines
1export 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}
111
types/index.d.ts 25 lines
1/** 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