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

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.
| Mod | Description |
|---|---|
guarda | Intercepts risky Bash commands (rm -rf, git reset --hard, force push, new dependencies) and asks for confirmation explaining the impact. |
lixeira | Sends rm / Remove-Item targets to the Windows Recycle Bin instead of deleting them permanently. |
cofre | Keeps 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-guard | Blocks commits that mix top-level directories, violate Conventional Commits, or lack a Co-Authored-By trailer. |
universal-audit-log | A hook on * that logs every event (tool calls, prompts, turns, fs/http/process calls from other plugins) with secret redaction. |
| Mod | Description |
|---|---|
context-monitor | Context weather and token usage above the prompt, replay and activity panels, plain-language explanation before each edit, and ai-memory notes. |
cache-frio | Confirms before submitting a prompt to a large conversation whose prompt cache has gone cold. |
session-telemetry | Timestamp 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-inspector | Side pane displaying the session turn-by-turn: every model request and what it carried. |
clean-view | Collapses each successful tool call into a single concise line (Edit file +12 −3); errors and in-flight calls retain the full block. |
webfetch-cache | Serves repeated WebFetch requests (identical URL and prompt) directly from the session cache. |
reflexo | Detects corrections and constraints in prompts, offering to save them as rules in CLAUDE.md. |
| Mod | Description |
|---|---|
agent-flow | Live diagram of agent flow: main loop context, subagent tree, and context passed/returned by each agent. |
agent-radar | One live line per running subagent above the prompt; /radar opens a pane with messages from each. |
delegation-dock | Status line indicator showing in-flight delegations (agy/codex/ollama) and alerting when one returns empty. |
phase-tracker | Tracks and displays the current development phase (Spec → Red Test → Surgical Code → Verification). |
next-prompts | Band with 3 next-step suggestions generated by a free Ollama cloud model; click one to draft. |
git-sidebar | Lazygit-style side pane: worktrees, local branches, branch switching, and worktree checkout. |
wait-quiz | Quizzes a flashcard in a band above the prompt while Claude is working. |
meus-mods | Central hub: quick-access bar above the prompt and /mods command displaying the status of each mod. |
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.
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).
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.
hooks/register.tsx 229 lines1import { 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}
229types/index.d.ts 10 lines1export 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