A sidebar listing todos Claude left undone; press a letter to flag one, then send Claude back to finish it.

Claude Code mods.
A sidebar that tracks what Claude said it would do and has not done yet. After each turn a small model audits Claude's reply: it adds new promises and closes the ones that were kept. Unfinished TodoWrite items show too.
Keys in the pane: a letter flags an entry, 1 sends the flagged ones back to Claude as "You left this undone: ...", 2 marks them done, 3 exports the list to ~/.claude/left-undone/latest.md, 4 imports it. /left-undone-export and /left-undone-import [path] do the same. The list is per session.
Install, typed at a Claude Code prompt:
/plugin install left-undone --marketplace <owner>/<repo>
Answer y to add the marketplace, then pick the user scope to have it in every session.
When auto mode's classifier blocks a tool call, this retries the identical call up to three times so Claude Code's own permission prompt appears after three blocks in a row. It retries calls the classifier denied, so read its source before installing. Whether the retries count toward Claude Code's threshold is not confirmed.
/plugin install approve-blocked --marketplace <owner>/<repo>hooks/register.tsx 249 lines1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import type { Undone } from '../types'
5
6const PANE = 'left-undone'
7const LETTERS = 'abcdefghijklmnopqrstuvwxyz'
8const items = atom({ plugin: 'left-undone', key: 'items' } as const, [])
9
10// Promises live in the session's own state: each agent keeps its own list.
11// Export and import carry them across a restart.
12const change = ($: Engine, fn: (old: Undone[]) => Undone[]) => update($, items, fn)
13
14export const toMarkdown = (list: Undone[], when: string): string =>
15 `# Left undone\n\nExported ${when}\n\n${list.map(i => `- [ ] ${i.text}`).join('\n')}\n`
16
17export const fromMarkdown = (text: string): string[] =>
18 text
19 .split('\n')
20 .map(line => line.match(/^\s*[-*]\s+\[[ xX]\]\s+(.+?)\s*$/)?.[1])
21 .filter((t): t is string => t !== undefined && t.length > 0)
22 .map(t => t.slice(0, 240))
23
24const DIR = '.claude/left-undone'
25const LATEST = 'latest.md'
26const home = async ($: Engine) => (await $.process.run(['printenv', 'HOME'])).stdout.trim()
27
28const exportList = async ($: Engine) => {
29 const list = (await read($, items)).filter(i => i.text.length > 0)
30 if (list.length === 0) return 'Nothing to export.'
31 const dir = `${await home($)}/${DIR}`
32 await $.process.run(['mkdir', '-p', dir])
33 const now = new Date()
34 const stamp = now.toISOString().slice(0, 16).replace('T', ' ')
35 const text = toMarkdown(list, stamp)
36 await $.fs.write(`${dir}/${LATEST}`, text)
37 await $.fs.write(`${dir}/${stamp.replace(/[ :]/g, '-')}.md`, text)
38
39 return `Exported ${list.length} to ~/${DIR}/${LATEST}`
40}
41
42const importList = async ($: Engine, path?: string) => {
43 const file = path?.trim() || `${await home($)}/${DIR}/${LATEST}`
44 if (!(await $.fs.exists(file))) return `No export found at ${file}`
45 const added = fromMarkdown(await $.fs.read(file))
46 await change($, old => [
47 ...old,
48 ...[...new Set(added)]
49 .filter(text => !old.some(o => o.text === text))
50 .map(text => ({ text, kind: 'open' as const, isFlagged: false })),
51 ].slice(-MAX_OPEN))
52
53 return `Imported ${added.length} from ${file}`
54}
55
56const MAX_OPEN = 300
57const RULES = `You audit an AI assistant's promises. You are given PROMISES it made earlier that are still open, and its latest REPLY, both inside tags. Answer with a single JSON object and nothing else: {"done": [numbers], "add": ["..."]}
58- "done": numbers of open promises that the reply shows were now carried out, or are clearly dropped or no longer applicable.
59- "add": new promises the ASSISTANT made in the reply and has NOT carried out by the end of it: "I'll do X", "next I will Y", "I can add Z if you want" (an offer), "still to do on my side". At most 5, each under 140 characters, phrased as the assistant's own task.
60Never add things the USER has to do or decide, questions for the user, descriptions of work already done, or explanations. If there is nothing, answer {"done": [], "add": []}. Never ask for input and never explain: the data is already provided.`
61
62const ask = (current: string[], reply: string) => `<open_promises>
63${current.length ? current.map((t, i) => `${i + 1}. ${t}`).join('\n') : '(none)'}
64</open_promises>
65
66<reply>
67${reply}
68</reply>
69
70Output the JSON object now.`
71
72export const parseAudit = (reply: string): { done: number[]; add: string[] } => {
73 const from = reply.indexOf('{')
74 const to = reply.lastIndexOf('}')
75 if (from < 0 || to < from) return { done: [], add: [] }
76 try {
77 const r: { done?: unknown; add?: unknown } = JSON.parse(reply.slice(from, to + 1))
78 const done = Array.isArray(r.done) ? r.done.filter((x): x is number => Number.isInteger(x)) : []
79 const add = Array.isArray(r.add)
80 ? r.add.filter((x): x is string => typeof x === 'string' && x.trim().length > 0).map(x => x.trim().slice(0, 240)).slice(0, 5)
81 : []
82 return { done, add }
83 } catch {
84 return { done: [], add: [] }
85 }
86}
87
88export const register: Register = on => {
89 on('session.start', async ($, e, next) => {
90 await $.command.register({
91 name: 'left-undone',
92 description: 'Show the list of todos Claude left undone',
93 })
94 await $.command.register({
95 name: 'left-undone-export',
96 description: 'Export the left-undone list to a Markdown file',
97 })
98 await $.command.register({
99 name: 'left-undone-import',
100 description: 'Import a left-undone Markdown export (default: the latest)',
101 })
102 void $.ui.open({ id: PANE, title: 'Left undone' })
103
104 return next(e)
105 })
106
107 on('command.run', { command: 'left-undone' }, async $ => {
108 await $.ui.open({ id: PANE, title: 'Left undone' })
109
110 return { text: 'Left undone pane opened.' }
111 })
112
113 on('command.run', { command: 'left-undone-export' }, async $ => ({ text: await exportList($) }))
114
115 on('command.run', { command: 'left-undone-import' }, async ($, e) => ({
116 text: await importList($, e.args),
117 }))
118
119 // Every TodoWrite replaces the todo entries; whatever is not completed is
120 // "undone". Flags survive a rewrite when the todo's text is unchanged.
121 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
122 const ran = await next(e)
123 await change($, old => [
124 ...e.todos
125 .filter(t => t.status !== 'completed')
126 .map(t => ({
127 text: t.content,
128 kind: 'todo' as const,
129 isFlagged: old.some(o => o.kind === 'todo' && o.text === t.content && o.isFlagged),
130 })),
131 ...old.filter(o => o.kind === 'open'),
132 ])
133
134 return ran
135 })
136
137 // After each turn a small model reads the answer against the promises still
138 // open: it closes the ones the answer kept and adds the new ones it made.
139 on('turn.complete', async ($, e, next) => {
140 const done = await next(e)
141 if (e.agentId === undefined && e.reason === 'answer' && done.text.trim().length > 40) {
142 const open = (await read($, items)).filter(i => i.kind === 'open')
143 const r = await $.model.complete({
144 model: 'haiku',
145 system: RULES,
146 prompt: ask(open.map(i => i.text), done.text.slice(0, 6000)),
147 effort: 'low',
148 maxTokens: 500,
149 timeoutMs: 10000,
150 })
151 const audit = r.isAnswered ? parseAudit(r.text) : { done: [], add: [] }
152 if (!r.isAnswered) $.ui.toast(`left-undone: model call failed (${r.reason})`)
153 const closed = new Set(audit.done.map(n => open[n - 1]?.text))
154 if (closed.size > 0 || audit.add.length > 0) {
155 await change($, old => [
156 ...old.filter(o => !(o.kind === 'open' && closed.has(o.text))),
157 ...audit.add
158 .filter(text => !old.some(o => o.text === text))
159 .map(text => ({ text, kind: 'open' as const, isFlagged: false })),
160 ].slice(-MAX_OPEN))
161 }
162 }
163
164 return done
165 })
166
167 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
168 const { Box, Text, Button } = $.ui.resolve(e)
169 const all: Undone[] = await read($, items)
170 const todos = all.filter(i => i.kind === 'todo').slice(0, LETTERS.length)
171 // Flagged lines stay pinned; the rest of the letters go to the newest lines.
172 const room = LETTERS.length - todos.length
173 const saidAll = all.filter(i => i.kind === 'open')
174 const pinned = saidAll.filter(i => i.isFlagged).slice(-room)
175 const recent = saidAll.filter(i => !i.isFlagged).slice(-(room - pinned.length))
176 const said = saidAll.filter(i => pinned.includes(i) || recent.includes(i))
177 const list = [...todos, ...said]
178 const flagged = list.filter(i => i.isFlagged)
179
180 const toggle = (target: Undone) =>
181 change($, cur =>
182 cur.map(one =>
183 one.kind === target.kind && one.text === target.text
184 ? { ...one, isFlagged: !one.isFlagged }
185 : one,
186 ),
187 )
188
189 const send = async () => {
190 if (flagged.length === 0) return
191 const text = flagged.map(i => `You left this undone: ${i.text}`).join('\n')
192 await change($, all => all.map(one => ({ ...one, isFlagged: false })))
193 await $.prompt.submit({ text, asUser: true })
194 }
195
196 const markDone = async () => {
197 if (flagged.length === 0) return
198 await change($, cur =>
199 cur.filter(one => !flagged.some(f => f.kind === one.kind && f.text === one.text)),
200 )
201 }
202
203 return (
204 <Box flexDirection="column">
205 {list.length === 0 && <Text dimColor>Nothing yet: todos and Claude's replies show here.</Text>}
206 {list.map((item, i) => (
207 <Button
208 key={`item-${i}`}
209 plain
210 hotkey={LETTERS[i]}
211 label={`${item.isFlagged ? '[x]' : '[ ]'} ${item.kind === 'todo' ? '(todo) ' : ''}${item.text}`}
212 onPress={() => void toggle(item)}
213 />
214 ))}
215 {flagged.length > 0 && (
216 <Button
217 key="done"
218 hotkey="2"
219 label={`Mark ${flagged.length} flagged as done`}
220 onPress={() => void markDone()}
221 />
222 )}
223 {flagged.length > 0 && (
224 <Button
225 key="send"
226 variant="primary"
227 hotkey="1"
228 label={`Send ${flagged.length} flagged to Claude`}
229 onPress={() => void send()}
230 />
231 )}
232 <Button
233 key="export"
234 hotkey="3"
235 label="Export to Markdown"
236 onPress={() => void exportList($).then(t => $.ui.toast(t))}
237 />
238 <Button
239 key="import"
240 hotkey="4"
241 label="Import latest export"
242 onPress={() => void importList($).then(t => $.ui.toast(t))}
243 />
244 <Text dimColor>letter = flag, 1 = send, 2 = done, 3 = export, 4 = import</Text>
245 </Box>
246 )
247 })
248}
249types/index.d.ts 8 lines1export type Undone = { text: string; kind: 'todo' | 'open'; isFlagged: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'left-undone': { items: Undone[] }
6 }
7}
8