SLOPSHOPPER

avegancafe

Kyle's personal Claude plugin: a home for one-offs that don't warrant their own plugin. Ships the work-notes-vault skill (his private Obsidian work vault and…

newcommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · avegancafe
› 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 › /session-title ⎿ avegancafe: (no title yet) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

avegancafe

Kyle's personal Claude Code plugin — a home for one-off skills that don't warrant a plugin of their own.

Install

/plugin install avegancafe --marketplace avegancafe/avegancafe-marketplace

Skills

SkillPurpose
work-notes-vaultKyle's private Obsidian work vault, avegancafe/notes--work (expected at ~/workspace/notes--work/): locating/refreshing it, its layout, and its weekly-synced Granola meeting archive (granola/notes/, .md + raw .json) — schema, rg/jq mining recipes, freshness limits, and what not to touch. Lets Claude "mine my Granola meetings for X" from any working directory; the vault's own .claude/CLAUDE.md stays the authority on its conventions. No meeting content lives here — only the archive's structure.

Mods

ModPurpose
session-titleKeeps the session title in `feat\fix\chore(Scope): imperative summary form (e.g. feat(ClaudePlugins): auto-name sessions by convention). Haiku titles the first prompt synchronously; after each main-thread turn a background Haiku pass sharpens the title from the conversation so far, and the result lands on your next prompt (titles can only be set as a prompt is submitted). Renaming the session by hand pauses it. /session-title shows the current title; /session-title lock / unlock` toggles it.
Source 2 files
hooks/register.ts 126 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { TitleState } from '../types'
5
6const COMMAND = 'session-title'
7const MAX_LEN = 72
8
9const state = atom({ plugin: 'avegancafe', key: 'sessionTitle' } as const, { locked: false } as TitleState)
10
11export const TITLE_RE = /^(fix|chore|feat)\([A-Z][A-Za-z0-9]*\): [a-z0-9].*\S$/
12
13const RULES = `You name coding sessions. Reply with ONE line and nothing else, in exactly this form:
14
15<type>(<Scope>): <description>
16
17- <type> is one of: feat (new capability), fix (something broken), chore (maintenance, config, docs, refactors, investigations, tooling).
18- <Scope> is ONE PascalCase word naming the high-level area: a product area, system or repo (e.g. Auth, Billing, ClaudePlugins, Beads, CI, DevEnv). No spaces, no dots, no slashes.
19- <description> is an imperative, lowercase phrase that says what the session is doing ("add retry to export uploads", not "added" or "adding"). No trailing period. Keep the whole line under ${MAX_LEN} characters.
20
21If a current title is given, keep it unless the work has clearly shifted or become more specific; when it has, sharpen it rather than rewriting from scratch.`
22
23/** Normalizes a model reply into a valid title, or undefined if it can't. */
24export const clean = (raw: string): string | undefined => {
25  const line = raw
26    .split('\n')
27    .map(l => l.trim().replace(/^[`"'*]+|[`"'*.]+$/g, ''))
28    .find(l => l.length > 0)
29  if (!line) return undefined
30  const m = /^(fix|chore|feat)\s*\(\s*([A-Za-z0-9]+)\s*\)\s*:\s*(.+)$/i.exec(line)
31  const [, type = '', scope = '', desc = ''] = m ?? []
32  if (!type || !scope || !desc) return undefined
33  let title = `${type.toLowerCase()}(${scope.charAt(0).toUpperCase()}${scope.slice(1)}): ${desc.charAt(0).toLowerCase()}${desc.slice(1)}`
34  if (title.length > MAX_LEN) title = title.slice(0, title.lastIndexOf(' ', MAX_LEN))
35  return TITLE_RE.test(title) ? title : undefined
36}
37
38const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
39
40/** A compact digest of the conversation: every user ask plus the latest answer. */
41const digest = async ($: EngineInterface) => {
42  const messages: readonly SessionMessage[] = await $.session.messages()
43  // Skip harness-injected user rows (system reminders, command wrappers).
44  const asks = messages
45    .filter(m => m.role === 'user' && m.text.trim() && !m.text.trimStart().startsWith('<'))
46    .map(m => clip(m.text.trim(), 500))
47  const lastAnswer = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim())
48  const recent = asks.slice(1).slice(-6)
49  return [
50    asks[0] && `First request:\n${asks[0]}`,
51    recent.length > 0 && `Later requests:\n${recent.map(a => `- ${a}`).join('\n')}`,
52    lastAnswer && `Latest assistant reply (excerpt):\n${clip(lastAnswer.text.trim(), 800)}`,
53  ]
54    .filter(Boolean)
55    .join('\n\n')
56}
57
58const generate = async ($: EngineInterface, context: string, current?: string) => {
59  const prompt = `${current ? `Current title: ${current}\n\n` : ''}${context}\n\nTitle:`
60  const r = await $.model.complete({ model: 'haiku', system: RULES, prompt, maxTokens: 60, effort: 'low' })
61  return r.isAnswered ? clean(r.text) : undefined
62}
63
64export const register: Register = on => {
65  on('session.start', async ($, e, next) => {
66    await $.command.register({
67      name: COMMAND,
68      description: 'Show the auto session title, or `lock` / `unlock` it',
69    })
70    return next(e)
71  })
72
73  on('command.run', { command: COMMAND }, async ($, e) => {
74    const arg = e.args.trim().toLowerCase()
75    if (arg === 'lock' || arg === 'unlock') {
76      await update($, state, s => ({ ...s, locked: arg === 'lock' }))
77      return { text: arg === 'lock' ? 'Session title locked.' : 'Session title unlocked; it updates on your next prompt.' }
78    }
79    const s = await read($, state)
80    return { text: `${s.title ?? '(no title yet)'}${s.locked ? ' (locked)' : ''}` }
81  })
82
83  // The title can only be set as a prompt enters, so this applies whatever the
84  // last refinement produced; the very first prompt is titled synchronously.
85  on('classic.UserPromptSubmit', async ($, e, next) => {
86    const result = await next(e)
87    if (result.block) return result
88
89    const s = await read($, state)
90    if (s.locked) return result
91
92    const current = e.session_title
93    if (s.title && current && current !== s.title) {
94      // Renamed by hand since we last set it: leave it alone.
95      await update($, state, x => ({ ...x, locked: true }))
96      $.ui.toast(`Session renamed by hand; auto-title paused (/${COMMAND} unlock to resume)`)
97      return result
98    }
99
100    let title = s.title
101    if (!title && current && TITLE_RE.test(current)) title = current // resumed session
102    if (!title) {
103      const history = (await $.session.turns()) > 1 ? await digest($) : ''
104      title = await generate($, `${history ? `${history}\n\n` : ''}New request:\n${clip(e.prompt, 1500)}`)
105    }
106    if (!title) return result
107
108    if (title !== s.title) await update($, state, x => ({ ...x, title }))
109    return title === current ? result : { ...result, sessionTitle: title }
110  })
111
112  // After each main-thread turn, refine the title in the background so the next
113  // prompt picks it up without waiting on a model call.
114  on('turn.complete', async ($, e, next) => {
115    const result = await next(e)
116    if (e.agentId !== undefined || e.isAborted) return result
117    const s = await read($, state)
118    if (s.locked) return result
119    void (async () => {
120      const title = await generate($, await digest($), s.title)
121      if (title && !(await read($, state)).locked) await update($, state, x => ({ ...x, title }))
122    })().catch(() => {})
123    return result
124  })
125}
126
types/index.d.ts 12 lines
1/**
2 * `title` is the last title this mod produced; `locked` stops it touching the
3 * title (set when someone renamed the session by hand, or via the command).
4 */
5export type TitleState = { title?: string; locked: boolean }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'avegancafe': { sessionTitle: TitleState }
10  }
11}
12