Keeps Claude's todo list in a side pane beside the transcript, open only while the list has items.

Mods for Claude Code: small plugins of function hooks that draw inside the terminal or the desktop app's Code tab and react to what the session does. Each folder here is one self-contained mod that works in any repository.
| Mod | What it does |
|---|---|
| cache-watch | Shows the prompt cache's hit rate and time left, and suggests the cheapest way to carry on before the cache expires. |
| sticky-todos | Keeps Claude's todo list in a pane beside the transcript, open only while the list has items. |
[!note] The function-hooks API is in early access and changes between Claude Code releases. These mods were built and checked against Claude Code 2.1.286. If a mod stops loading after an update, run
claude plugin validate <mod folder>to see what the engine now refuses.
This repository is a plugin marketplace. Add it once, then install the mods you want. Inside Claude Code:
/plugin marketplace add paweechinagarn/claude-code-mods
/plugin install cache-watch@claude-code-mods
/plugin install sticky-todos@claude-code-mods
/reload-plugins
The same commands work from a shell as claude plugin marketplace add ... and claude plugin install .... If a mod does not appear after /reload-plugins, restart Claude Code.
To pick up new versions, refresh the marketplace with /plugin marketplace update claude-code-mods.
A mod is code that runs inside Claude Code with the same access Claude Code has. Read it before you install it.
An installed mod is a cached copy. To edit a mod and see changes live, load it from a clone instead:
git clone https://github.com/paweechinagarn/claude-code-mods.git
For one terminal session, pass the mod's folder:
claude --plugin-dir <clone>/cache-watch
For every session, the desktop app included, list the folder in the env block of ~/.claude/settings.json. Separate several folders with ; on Windows and : elsewhere:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "<clone>/cache-watch;<clone>/sticky-todos"
}
}
Interactive sessions watch these folders, so an edit or a git pull reloads the mod without a restart. Do not load the same mod both ways at once.
Claude Code caches the start of every request. A cached token costs about a tenth of a normal input token, but the cache only lives for a while after it was last used: one hour on most plans, five minutes on others or during usage overage. Once it expires, the next message pays to write the whole conversation into the cache again. On a long session that is the most expensive message you send.
One row above the prompt, readable at a glance. These images are mockups drawn with the mod's own bar code and styled like the desktop app, so spacing in the app differs slightly.
Fresh, with the details panel open (more, or the d key):

About to expire, with the advice card:

Expired:

d) opens the details: the cache lifetime and how the mod knows it, the last request's and the session's token counts, and what an expiry would cost.Claude Code does not report the cache lifetime on each request, so the mod assumes one hour until it sees proof: a request after a pause of six or more minutes that still hits the cache (one hour), or misses it (five minutes). A model switch reports the lifetime directly. The details panel says which of these it is. On narrow windows the row drops the sparkline and the lifetime.
The desktop app, the editor extension and the phone draw both bars as vector graphics: a rounded gradient pill and a row of rounded columns. The terminal draws them with line and block characters instead.
Five minutes before a one-hour cache expires, while you are idle, the mod asks the model one side question over your conversation and shows its answer with three buttons:
| Option | When the model picks it |
|---|---|
| Continue here | The remaining work is short, or it really needs the full history. |
| Compact now | The task is mid-flight and the history holds a lot that is no longer needed. Compacting while the cache is warm is cheaper than after. |
| Copy handoff prompt | The work has reached a natural break. The mod has already written a self-contained prompt; paste it into a new session. Show prompt opens it in a pane. |
All three buttons are always there; the recommended one is highlighted. With under 30,000 tokens of context the mod skips the question and says to continue, because re-caching a small context costs little. Run /cache-advice to get the same advice at any time.
[!important] The advice itself spends a little The side question reads the warm cache, about a twentieth of the cost of re-writing it, and that read also restarts the cache's one-hour clock. The mod asks once per idle stretch, so a session left alone all day spends one read, not one per hour.
The constants at the top of cache-watch/hooks/register.tsx:
| Constant | Default | Meaning |
|---|---|---|
WARN_MIN | 5 | Minutes before expiry that the advice runs. |
SMALL_CONTEXT | 30_000 | Below this many tokens, advise continuing without asking the model. |
PROBE_GAP_MIN | 6 | Idle minutes after which a request tells five-minute from one-hour caching. |
Version 0.2.0. Checked with claude plugin validate and a strict TypeScript build, and the line above the prompt is confirmed drawing in the desktop app. The advice flow, the copy button on the desktop surface and Compact now have not yet run in a real session.
When Claude works through a multi-step task it keeps a checklist, but the checklist sits in the transcript and scrolls away. This mod keeps it in a pane of its own, beside the transcript and clear of the messages and the prompt.
A pane beside the transcript, clear of the messages and the prompt. These images are mockups styled like the desktop app. The ring and the step icons come from the mod's own drawing code, but spacing in the app differs slightly. docs/sticky-todos/mockup.html is their source.

The pane up close:

/todos opens it at any time./todos opens it at any width.The mod reads whichever todo tool the session gives the model:
| Tool | How the mod reads it |
|---|---|
TodoWrite | Each call carries the whole list, which replaces the pane's. |
TaskCreate and TaskUpdate | Each call adds one task or changes one task's status or wording. |
mcp__sticky-todos__set_todos | The mod's own tool, in TodoWrite's shape. |
Some sessions, such as the desktop app's Code tab, give the model neither built-in tool. For those the mod registers its own set_todos tool, so the model can keep a list anyway. The tool's description tells the model to prefer a built-in todo tool when it has one.
Version 0.2.0. Checked with claude plugin validate and a strict TypeScript build. In a real desktop-app session, the mod's own set_todos tool filled the pane, and the ring, the stepper and the spinning icon drew as designed. Reading TodoWrite, TaskCreate and TaskUpdate has not yet run in a real session.
A mod is a folder with three files: .claude-plugin/plugin.json, hooks/hooks.json naming the module, and the hooks module exporting register(on). A mod that keeps state adds a types/index.d.ts contract. To ship a new mod from this repository, add its folder and list it in .claude-plugin/marketplace.json with its name and source. Inside Claude Code, the bundled plugin-authoring skill holds the full API for the version you run. cache-watch is a working example of a line above the prompt, a pane, a slash command, a timer and a model fork.
hooks/register.tsx 271 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Todo } from '../types'
5
6const PANE = 'sticky-todos'
7const TITLE = 'Todos'
8const todos = atom({ plugin: 'sticky-todos', key: 'todos' } as const, [])
9
10type Loose = Record<string, unknown>
11const asStatus = (s: unknown): Todo['status'] =>
12 s === 'completed' || s === 'in_progress' ? s : 'pending'
13
14// Colours that read on the desktop app's light and dark themes alike.
15const GREEN = '#22c55e'
16const ACCENT = '#d97757'
17
18// Surfaces that draw Svg (desktop, editor, phone) get a ring and a stepper; the terminal gets glyphs.
19// docs/sticky-todos/mockup.html holds a copy of these builders: change both together.
20const TRACK = 'stroke="#808080" stroke-opacity="0.3"'
21const RING = 36
22const STEP_W = 18
23const STEP_H = 30
24
25/** The header ring: the share done, as an arc over a faint track, with the count inside. */
26const ringSvg = (done: number, total: number) => {
27 const r = 15
28 const c = 2 * Math.PI * r
29 const f = total ? done / total : 0
30 return (
31 `<svg xmlns="http://www.w3.org/2000/svg" width="${RING}" height="${RING}" viewBox="0 0 36 36">` +
32 `<circle cx="18" cy="18" r="${r}" fill="none" stroke-width="3.5" ${TRACK}/>` +
33 `<circle cx="18" cy="18" r="${r}" fill="none" stroke="${GREEN}" stroke-width="3.5" stroke-linecap="round" ` +
34 `stroke-dasharray="${(f * c).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 18 18)"/>` +
35 `<text x="18" y="22" text-anchor="middle" font-family="Segoe UI, system-ui, sans-serif" font-size="11" ` +
36 `font-weight="600" fill="${GREEN}">${done}/${total}</text></svg>`
37 )
38}
39
40/** One step of the stepper: a status mark, with rail segments joining it to its neighbours. */
41const stepSvg = (status: Todo['status'], isFirst: boolean, isLast: boolean, isPrevDone: boolean) => {
42 const mid = STEP_H / 2
43 const x = STEP_W / 2
44 const rail = (y1: number, y2: number, isLit: boolean) =>
45 `<line x1="${x}" y1="${y1}" x2="${x}" y2="${y2}" stroke-width="2" ` +
46 (isLit ? `stroke="${GREEN}" stroke-opacity="0.6"` : TRACK) +
47 '/>'
48 const up = isFirst ? '' : rail(0, mid - 8, isPrevDone)
49 const down = isLast ? '' : rail(mid + 8, STEP_H, status === 'completed')
50 const mark =
51 status === 'completed'
52 ? `<circle cx="${x}" cy="${mid}" r="7" fill="${GREEN}"/>` +
53 `<path d="M${x - 3.2} ${mid} l2.2 2.3 l4.2 -4.6" fill="none" stroke="#1d1d1b" stroke-width="2" ` +
54 `stroke-linecap="round" stroke-linejoin="round"/>`
55 : status === 'in_progress'
56 ? `<circle cx="${x}" cy="${mid}" r="6.5" fill="none" stroke-width="2" stroke="${ACCENT}" stroke-opacity="0.3"/>` +
57 `<circle class="spin" cx="${x}" cy="${mid}" r="6.5" fill="none" stroke-width="2" stroke="${ACCENT}" ` +
58 `stroke-linecap="round" stroke-dasharray="12 29"/><circle cx="${x}" cy="${mid}" r="2.5" fill="${ACCENT}"/>`
59 : `<circle cx="${x}" cy="${mid}" r="6.5" fill="none" stroke-width="2" ${TRACK}/>`
60 const motion =
61 status === 'in_progress'
62 ? // The interactive frame paints an opaque white backdrop when its color scheme differs from the page's.
63 `<style>:root{color-scheme:light dark;background:transparent}.spin{transform-origin:${x}px ${mid}px;animation:s 1.1s linear infinite}` +
64 `@keyframes s{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.spin{animation:none}}</style>`
65 : ''
66 return (
67 `<svg xmlns="http://www.w3.org/2000/svg" width="${STEP_W}" height="${STEP_H}" viewBox="0 0 ${STEP_W} ${STEP_H}">` +
68 `${motion}${up}${down}${mark}</svg>`
69 )
70}
71
72const GLYPH: Record<Todo['status'], string> = { completed: '✔', in_progress: '◐', pending: '○' }
73const STATUS_WORD: Record<Todo['status'], string> = { completed: 'Done', in_progress: 'In progress', pending: 'To do' }
74
75// The whole list in TodoWrite's shape: { content, status, activeForm? }[].
76const fromTodoWrite = (items: Loose[]): Todo[] =>
77 items.map((t, i) => ({
78 id: String(i),
79 text: String(t.status === 'in_progress' && t.activeForm ? t.activeForm : t.content ?? ''),
80 status: asStatus(t.status),
81 }))
82
83// The mod's own tool, for sessions whose model has no built-in todo tool (the desktop app's, for one).
84const OWN_TOOL = 'mcp__sticky-todos__set_todos'
85const OWN_SCHEMA = {
86 type: 'object',
87 properties: {
88 todos: {
89 type: 'array',
90 description: 'The whole list, in order. Send an empty array to clear it.',
91 items: {
92 type: 'object',
93 properties: {
94 content: { type: 'string', description: 'The task, in the imperative: "Run the tests".' },
95 status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
96 activeForm: { type: 'string', description: 'Shown while in progress: "Running the tests".' },
97 },
98 required: ['content', 'status'],
99 },
100 },
101 },
102 required: ['todos'],
103}
104
105// Writes the list, then opens the pane when it gains its first item and closes it when it empties.
106// Opening only on that change means a pane the person closed stays closed until the list restarts.
107const setTodos = async ($: EngineInterface, fn: (list: Todo[]) => Todo[]) => {
108 const before = (await read($, todos)).length
109 await update($, todos, fn)
110 const after = (await read($, todos)).length
111
112 if (before === 0 && after > 0) {
113 void $.ui.open({ id: PANE, title: TITLE })
114 } else if (before > 0 && after === 0) {
115 void $.ui.close({ id: PANE })
116 }
117}
118
119export const register: Register = on => {
120 on('session.start', async ($, e, next) => {
121 await $.command.register({ name: 'todos', description: 'Show the sticky todo pane' })
122 await $.tool.register({
123 name: 'set_todos',
124 description:
125 'Replace the todo checklist shown in the side pane. Use it for multi-step work when you have ' +
126 'no built-in todo tool (TodoWrite or TaskCreate); keep one item in_progress at a time.',
127 inputSchema: OWN_SCHEMA,
128 })
129
130 // A reload or resume keeps the list: show it again if it has items.
131 if ((await read($, todos)).length > 0) {
132 void $.ui.open({ id: PANE, title: TITLE })
133 }
134
135 return next(e)
136 })
137
138 on('command.run', { command: 'todos' }, async $ => {
139 await $.ui.open({ id: PANE, title: TITLE })
140
141 return { text: 'Todo pane opened.' }
142 })
143
144 // Claude's todo tools are not typed in every build, so read their arguments loosely.
145 on('tool.call', async ($, e, next) => {
146 const tool = e.tool as string
147 const input = e as unknown as Loose
148
149 // The mod's own tool: nothing beneath serves it, so answer here.
150 if (tool === OWN_TOOL) {
151 const items = Array.isArray(input.todos) ? (input.todos as Loose[]) : []
152 const list = fromTodoWrite(items)
153 await setTodos($, () => list)
154 const done = list.filter(t => t.status === 'completed').length
155
156 return { result: `Todo list updated: ${done} of ${list.length} done.` }
157 }
158
159 // Older tool: the whole list is rewritten on every call.
160 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
161 const list = fromTodoWrite(input.todos as Loose[])
162 await setTodos($, () => list)
163
164 return next(e)
165 }
166
167 // Newer tools: one task created or updated per call.
168 if (tool === 'TaskCreate') {
169 const ran = await next(e)
170 const text = 'text' in ran && typeof ran.text === 'string' ? ran.text : ''
171 const id = /#?(\d+)/.exec(text)?.[1] ?? String(Date.now())
172 await setTodos($, list => [
173 ...list,
174 { id, text: String(input.subject ?? ''), status: 'pending' as const },
175 ])
176
177 return ran
178 }
179
180 if (tool === 'TaskUpdate') {
181 const id = String(input.taskId ?? '')
182 await setTodos($, list =>
183 input.status === 'deleted'
184 ? list.filter(t => t.id !== id)
185 : list.map(t =>
186 t.id === id
187 ? {
188 ...t,
189 text: typeof input.subject === 'string' ? input.subject : t.text,
190 status: input.status ? asStatus(input.status) : t.status,
191 }
192 : t,
193 ),
194 )
195 }
196
197 return next(e)
198 })
199
200 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
201 const elements = $.ui.resolve(e)
202 const { Box, Text, Button } = elements
203 const Svg = 'Svg' in elements ? elements.Svg : undefined
204 const list = await read($, todos)
205 const done = list.filter(t => t.status === 'completed').length
206 const now = list.filter(t => t.status === 'in_progress').length
207 const summary =
208 list.length === 0
209 ? "No todos yet. Claude's checklist appears here."
210 : `${done} of ${list.length} done` + (now ? ` · ${now} in progress` : '')
211
212 return (
213 <Box flexDirection="column">
214 <Box gap={1} alignItems="center" marginBottom={1}>
215 {Svg && list.length > 0 && (
216 <Svg
217 source={ringSvg(done, list.length)}
218 alt={`${done} of ${list.length} done`}
219 width={RING}
220 height={RING}
221 />
222 )}
223 <Box flexDirection="column">
224 <Text bold>{TITLE}</Text>
225 <Text dimColor>{summary}</Text>
226 </Box>
227 </Box>
228 {list.map((t, i) => (
229 <Box key={t.id} gap={1} alignItems="center">
230 {Svg ? (
231 <Svg
232 source={stepSvg(t.status, i === 0, i === list.length - 1, list[i - 1]?.status === 'completed')}
233 alt={STATUS_WORD[t.status]}
234 width={STEP_W}
235 height={STEP_H}
236 // Only the running step animates, and only an interactive Svg plays CSS animation.
237 isInteractive={t.status === 'in_progress' || undefined}
238 />
239 ) : (
240 <Text
241 color={t.status === 'completed' ? GREEN : t.status === 'in_progress' ? ACCENT : undefined}
242 dimColor={t.status === 'pending'}
243 >
244 {GLYPH[t.status]}
245 </Text>
246 )}
247 <Text
248 wrap="truncate-end"
249 bold={t.status === 'in_progress'}
250 color={t.status === 'in_progress' ? ACCENT : undefined}
251 dimColor={t.status === 'completed'}
252 strikethrough={t.status === 'completed'}
253 >
254 {t.text}
255 </Text>
256 </Box>
257 ))}
258 {done > 0 && (
259 <Box marginTop={1}>
260 <Button
261 key="clear"
262 label="Clear done"
263 onPress={() => setTodos($, l => l.filter(t => t.status !== 'completed'))}
264 />
265 </Box>
266 )}
267 </Box>
268 )
269 })
270}
271types/index.d.ts 8 lines1export type Todo = { id: string; text: string; status: 'pending' | 'in_progress' | 'completed' }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'sticky-todos': { todos: Todo[] }
6 }
7}
8