SLOPSHOPPER

next-prompts

Band above the prompt with 3 next-step suggestions from a free Ollama cloud model; press one to draft it

newbandcommandprocessnetwork
v0.1.0MITupdated 2026-10-19Covre81/claude-code-mods/next-prompts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-prompts
› 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 › /next-prompts ⎿ next-prompts: next-prompts esta ativado. Uso: /next-prompts on · /next-prompts off next: agy gemini-3.8-flash-low: LOCALAPPDATA unset ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
next: agy gemini-3.8-flash-low: LOCALAPPDATA unset
README

claude-code-mods

Function-hook mods for Claude Code: shell safety, secret guarding, session telemetry, context panels, and workflow tools. Each mod is an isolated plugin with its own tests, and you enable only the ones you want.

Mods

Security

ModDescription
guardaIntercepts risky Bash commands (rm -rf, git reset --hard, force push, new dependencies) and asks for confirmation explaining the impact.
lixeiraSends rm / Remove-Item targets to the Windows Recycle Bin instead of deleting them permanently.
cofreKeeps API keys out of the conversation: /chave stores them in .env, secret file reads display only variable names, outputs are masked, and pasted keys are quarantined.
atomic-commit-guardBlocks commits that mix top-level directories, violate Conventional Commits, or lack a Co-Authored-By trailer.
universal-audit-logA hook on * that logs every event (tool calls, prompts, turns, fs/http/process calls from other plugins) with secret redaction.

Session & Context

ModDescription
context-monitorContext weather and token usage above the prompt, replay and activity panels, plain-language explanation before each edit, and ai-memory notes.
cache-frioConfirms before submitting a prompt to a large conversation whose prompt cache has gone cold.
session-telemetryTimestamp on each response, TTFT and tok/s per step, and visual notice whenever the responding model switches.
session-time-machine/timemachine: renders the session as a timeline of prompts, tool calls, and turn completions.
turn-inspectorSide pane displaying the session turn-by-turn: every model request and what it carried.
clean-viewCollapses each successful tool call into a single concise line (Edit file +12 −3); errors and in-flight calls retain the full block.
webfetch-cacheServes repeated WebFetch requests (identical URL and prompt) directly from the session cache.
reflexoDetects corrections and constraints in prompts, offering to save them as rules in CLAUDE.md.

Agents & Workflow

ModDescription
agent-flowLive diagram of agent flow: main loop context, subagent tree, and context passed/returned by each agent.
agent-radarOne live line per running subagent above the prompt; /radar opens a pane with messages from each.
delegation-dockStatus line indicator showing in-flight delegations (agy/codex/ollama) and alerting when one returns empty.
phase-trackerTracks and displays the current development phase (Spec → Red Test → Surgical Code → Verification).
next-promptsBand with 3 next-step suggestions generated by a free Ollama cloud model; click one to draft.
git-sidebarLazygit-style side pane: worktrees, local branches, branch switching, and worktree checkout.
wait-quizQuizzes a flashcard in a band above the prompt while Claude is working.
meus-modsCentral hub: quick-access bar above the prompt and /mods command displaying the status of each mod.

Installation

Requires Claude Code with function-hook mods support.

git clone https://github.com/Covre81/claude-code-mods.git ~/.claude/mods

Enable the mods by listing their absolute paths in CLAUDE_CODE_PLUGIN_DIRS inside ~/.claude/settings.json. On Windows, paths are separated by ;:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "C:/Users/me/.claude/mods/guarda;C:/Users/me/.claude/mods/cofre;C:/Users/me/.claude/mods/session-telemetry"
  }
}

Order matters: the last plugin in the list sits closest to the engine. For instance, lixeira should come after guarda so guarda inspects the original rm -rf first.

Several mods include a command to toggle them on or off without restarting (/webfetch-cache on|off, /radar-toggle, /telemetria…); /mods displays the status of all mods.

Development

Each mod follows the same structure:

<mod>/
  .claude-plugin/plugin.json   manifest
  .claude-plugin/types/        engine types (git-ignored, see below)
  hooks/hooks.json             hooks registered by the mod
  hooks/register.ts(x)         code
  hooks/*.test.ts(x)           tests

Engine types are not committed to the repository. To typecheck and test a mod, copy .claude-plugin/types from an existing mod or generate them with your Claude Code instance.

Tests run directly within Claude Code, not via vitest/jest:

claude plugin test guarda               # single mod
for d in */hooks/hooks.json; do claude plugin test "${d%%/*}"; done   # all mods

Commits follow Conventional Commits, scoped to one mod per commit (enforced by atomic-commit-guard).

Credits

Six mods (agent-flow, git-sidebar, session-time-machine, turn-inspector, universal-audit-log, webfetch-cache) originate from davila7/claude-code-templates, and agent-radar originates from hamzafer/claude-code-mods, both under the MIT License and adapted here. Copyright notices are preserved in THIRD_PARTY_NOTICES.md.

License

MIT

Source 2 files
hooks/register.tsx 229 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Band, Suggestion } from '../types'
5
6const band = atom({ plugin: 'next-prompts', key: 'band' } as const, { items: [], note: null } as Band)
7const isEnabledRef = atom({ plugin: 'next-prompts', key: 'isEnabled' } as const, true)
8
9/** Past this the context is where the money goes (jev-router telemetry, week to 2026-10-06). */
10const CONTEXT_WARN_TOKENS = 200_000
11const TRANSCRIPT_CHARS = 4_000
12const MAX_COMMANDS = 120
13
14/**
15 * The model's answer is untrusted: cloud gemma wraps JSON in a markdown fence
16 * and ignores `format`, and any model can invent a slash command. Anything
17 * off-shape or naming a command this session doesn't have is dropped.
18 */
19export function parseSuggestions(raw: string, commands: ReadonlySet<string>): Suggestion[] {
20  const json = raw.match(/\{[\s\S]*\}/)?.[0]
21  if (!json) return []
22  let list: unknown
23  try {
24    list = (JSON.parse(json) as { suggestions?: unknown }).suggestions
25  } catch {
26    return []
27  }
28  if (!Array.isArray(list)) return []
29  return list
30    .filter((s): s is Suggestion => typeof s?.label === 'string' && typeof s?.text === 'string' && s.text.trim() !== '')
31    .filter(s => {
32      const cmd = s.text.trim().match(/^\/([\w:-]+)/)?.[1]
33      return !cmd || commands.has(cmd)
34    })
35    .slice(0, 3)
36    .map(s => ({ label: s.label.trim().slice(0, 40), text: s.text.trim() }))
37}
38
39function transcript(messages: { role: string; text: string }[]): string {
40  let out = ''
41  for (const m of [...messages].reverse()) {
42    if (!m.text.trim()) continue
43    const line = `${m.role.toUpperCase()}: ${m.text.trim()}\n`
44    if (out.length + line.length > TRANSCRIPT_CHARS) break
45    out = line + out
46  }
47  return out
48}
49
50const OLLAMA_CLOUD = 'https://ollama.com'
51
52/** One place the suggestions can come from: `ask` resolves the model's raw text or throws why not. */
53type Source = { name: string; ask: (prompt: string) => Promise<string> }
54
55async function ollamaChat($: EngineInterface, url: string, model: string, prompt: string, key?: string): Promise<string> {
56  let res
57  try {
58    res = await $.http.fetch(`${url}/api/chat`, {
59      method: 'POST',
60      headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
61      body: JSON.stringify({
62        model,
63        stream: false,
64        format: 'json',
65        think: false,
66        options: { temperature: 0.3 },
67        messages: [{ role: 'user', content: prompt }],
68      }),
69    })
70  } catch {
71    throw new Error(`offline (${url})`)
72  }
73  let body: { message?: { content?: string }; error?: string } = {}
74  try {
75    body = JSON.parse(res.text)
76  } catch {}
77  if (!res.ok || !body.message?.content) throw new Error(body.error ?? `HTTP ${res.status}`)
78  return body.message.content
79}
80
81/** Gemini through the Antigravity CLI: prompt on stdin only (with -p it answers empty, exit 0); plan mode cannot write. */
82async function agyChat($: EngineInterface, model: string, prompt: string): Promise<string> {
83  const base = await $.env.get('LOCALAPPDATA')
84  if (!base) throw new Error('LOCALAPPDATA unset')
85  const { exitCode, stdout, stderr } = await $.process.run(
86    [`${base}/agy/bin/agy.exe`, '--mode', 'plan', '--model', model, '--print-timeout', '60s'],
87    { stdin: prompt, timeoutMs: 90_000 },
88  )
89  if (exitCode !== 0 || !stdout.trim()) throw new Error(stderr.trim().split('\n').pop() || `exit ${exitCode}, empty`)
90  return stdout
91}
92
93/** Rule-based items first, then the model's; `isCurrent` drops an answer a newer turn superseded. */
94async function refresh(
95  $: EngineInterface,
96  models: string[],
97  ollamaUrl: string,
98  agyModel: string,
99  isCurrent: () => boolean,
100): Promise<void> {
101  const rules: Suggestion[] = []
102  const { context } = await $.session.usage()
103  if ((context.tokens ?? 0) >= CONTEXT_WARN_TOKENS) {
104    rules.push({ label: `/compact (ctx ${Math.round((context.tokens ?? 0) / 1000)}k)`, text: '/compact' })
105  }
106
107  const commands = (await $.command.list()).map(c => c.name)
108  const known = new Set(commands)
109  const prompt = `You suggest the user's NEXT prompt in a Claude Code session. Read the recent conversation and propose exactly 3 short next steps the user would plausibly want, in the user's language.
110Each suggestion is either a natural-language prompt, or starts with one of these available commands: ${commands.slice(0, MAX_COMMANDS).map(c => `/${c}`).join(' ')}.
111Never use a slash command that is not in that list. Be specific to this conversation, not generic.
112Return ONLY JSON: {"suggestions":[{"label":"<=40 chars","text":"<full prompt to put in the input box>"}]} with EXACTLY 3 items.
113
114Recent conversation:
115${transcript(await $.session.messages())}`
116
117  // In order of preference, tried every turn: one out of quota or erroring hands over to the
118  // next, and the first comes back on its own once its quota resets.
119  const sources: Source[] = models.map(model => ({ name: model, ask: p => ollamaChat($, ollamaUrl, model, p) }))
120  // A second Ollama account: the local server holds one sign-in, the cloud API takes any account's key.
121  const key = await $.env.get('NEXT_PROMPTS_OLLAMA_KEY')
122  if (key) {
123    for (const model of models) {
124      const cloud = model.replace(/-cloud$/, '')
125      sources.push({ name: `ollama.com ${cloud}`, ask: p => ollamaChat($, OLLAMA_CLOUD, cloud, p, key) })
126    }
127  }
128  if (agyModel) sources.push({ name: `agy ${agyModel}`, ask: p => agyChat($, agyModel, p) })
129
130  let items: Suggestion[] = []
131  let note: string | null = null
132  for (const source of sources) {
133    try {
134      items = parseSuggestions(await source.ask(prompt), known)
135      if (items.length > 0) {
136        note = null
137        break
138      }
139      note = `${source.name}: sem sugestões válidas`
140    } catch (err) {
141      note = `${source.name}: ${(err as Error).message.slice(0, 120)}`
142    }
143  }
144
145  // A newer turn already started its own refresh: this answer is stale.
146  if (!isCurrent()) return
147  await update($, band, () => ({ items: [...rules, ...items].slice(0, 3), note, picked: [] }))
148}
149
150/** The first pick replaces the draft; later ones stack under it, one per line, never twice. */
151async function pick($: EngineInterface, indexes: number[]): Promise<void> {
152  const { items, picked = [] } = await read($, band)
153  const fresh = indexes.filter(i => items[i] && !picked.includes(i))
154  if (fresh.length === 0) return
155  const text = fresh.map(i => items[i]!.text).join('\n')
156  await $.prompt.fill(picked.length === 0 ? { text } : { text: `\n${text}`, mode: 'append' })
157  await update($, band, b => ({ ...b, picked: [...picked, ...fresh] }))
158}
159
160export const register: Register = (on, options) => {
161  const models = String(options.model ?? 'gemma4:31b-cloud,gpt-oss:20b-cloud')
162    .split(',')
163    .map(m => m.trim())
164    .filter(Boolean)
165  const ollamaUrl = String(options.ollamaUrl ?? 'http://127.0.0.1:11434').replace(/\/$/, '')
166  // Empty turns the Gemini fallback off.
167  const agyModel = String(options.agyModel ?? 'gemini-3.8-flash-low').trim()
168  let generation = 0
169
170  on('session.start', async ($, e, next) => {
171    const saved = await $.store.get('isEnabled').catch(() => undefined)
172    await update($, isEnabledRef, () => (typeof saved === 'boolean' ? saved : isEnabledRef.initial))
173    await $.command.register({
174      name: 'next-prompts',
175      description: 'Liga/desliga as sugestões de próximo passo acima do prompt.',
176      argumentHint: 'on|off',
177    })
178    return next(e)
179  })
180
181  on('command.run', { command: 'next-prompts' }, async ($, e) => {
182    const arg = e.args.trim().toLowerCase()
183    if (arg !== 'on' && arg !== 'off') {
184      const isEnabled = await read($, isEnabledRef)
185      return { text: `next-prompts esta ${isEnabled ? 'ativado' : 'desativado'}. Uso: /next-prompts on · /next-prompts off` }
186    }
187    await update($, isEnabledRef, () => arg === 'on')
188    await $.store.set('isEnabled', arg === 'on') // $.state is per session; the store survives a restart
189    return { text: `next-prompts ${arg === 'on' ? 'ativado' : 'desativado'}.` }
190  })
191
192  on('turn.complete', async ($, e, next) => {
193    const result = await next(e)
194    // Subagents end turns too; only the main conversation's next step matters.
195    if (e.agentId || e.isAborted || !(await read($, isEnabledRef))) return result
196    // Unawaited: the band fills in when the model answers, the turn never waits on it.
197    const mine = ++generation
198    void refresh($, models, ollamaUrl, agyModel, () => mine === generation).catch(() => undefined)
199    return result
200  })
201
202  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
203    const { items, note, picked = [] } = await read($, band)
204    if (e.props.hasSurvey || e.props.isWorking || (items.length === 0 && !note) || !(await read($, isEnabledRef))) return next(e)
205
206    const { Box, Button, Text } = $.ui.resolve(e)
207    // Stack over whatever the plugins beneath draw (painel's weather), so load order never hides either.
208    const below = await next(e)
209    const mine = (
210      <Box gap={1}>
211        <Text dimColor>next:</Text>
212        {items.map((s, i) => (
213          <Button
214            key={`s${i}`}
215            label={`${i + 1} ${picked.includes(i) ? '✓ ' : ''}${s.label}`}
216            hotkey={String(i + 1)}
217            onPress={() => pick($, [i])}
218          />
219        ))}
220        {items.length > 1 ? (
221          <Button key="all" label="0 todas" hotkey="0" onPress={() => pick($, items.map((_, i) => i))} />
222        ) : null}
223        {note ? <Text dimColor>{note}</Text> : null}
224      </Box>
225    )
226    return below.type === 'engine' ? mine : <Box flexDirection="column">{mine}{below}</Box>
227  })
228}
229
types/index.d.ts 10 lines
1export type Suggestion = { label: string; text: string }
2/** `picked`: indexes already put in the prompt box, so later presses stack under them. */
3export type Band = { items: Suggestion[]; note: string | null; picked?: number[] }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'next-prompts': { band: Band; isEnabled: boolean }
8  }
9}
10