Tracks what the AI is waiting on you to decide in the main conversation: a reminder above the prompt, and /decisions opens a pane to quote, accept or remove…

A Claude Code mod that keeps a list of what Claude is waiting on you to decide in the main conversation.
⚖ 2 pending decisions ①Storage format ②Commit it with an Open button./decisions opens or closes a pane listing each item with its kind (Decide, Confirm, Info) and Claude's recommendation:Re "<title>": in the prompt so you can answer it.Re "<title>": going with your recommendation (<recommendation>) in the prompt.At the prompt of a Claude Code terminal session:
/plugin install pending-decisions --marketplace HuliLulu46/pending-decisions
Answer y to add the marketplace, then pick a scope (user is first).
From a shell:
claude plugin install pending-decisions --marketplace HuliLulu46/pending-decisions
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with HuliLulu46/pending-decisions, then Add plugin.
After each main-thread turn that ends with an answer, the mod sends the turn's user message, Claude's replies and the open items to claude-haiku-5-5, which returns the items added, resolved and updated. The call runs after the turn, so the prompt stays free while it reads.
/clear empties it.Built against Claude Code 2.1.300. A mod is an early-access API that may change between releases.
claude plugin validate .
claude plugin test .
tsc -p .
scripts/e2e.mjs runs two headless turns against real Haiku and prints the stored list after each. scripts/eval-prompt.py replays one transcript turn through the prompt several times to compare prompt versions. Both call a live model.
hooks/register.tsx 393 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Decision, DecisionKind, Resolution, ResolutionBy } from '../types'
5
6const PLUGIN = 'pending-decisions'
7const PANE = 'pending-decisions'
8const MODEL = 'claude-haiku-5-5'
9const AI_TEXT_LIMIT = 12_000
10const USER_TEXT_LIMIT = 4_000
11const REMOVED_KEPT = 10
12const SESSIONS_KEPT = 20
13const SESSIONS_KEY = 'sessions'
14const SESSION_KEY_PREFIX = 'session:'
15
16const BOARD_REF = { plugin: 'pending-decisions', key: 'board' } as const
17const EMPTY: Board = { sessionId: '', items: [], removed: [], nextSeq: 1 }
18const board = atom(BOARD_REF, EMPTY)
19const showRemoved = atom({ plugin: 'pending-decisions', key: 'showRemoved' } as const, false)
20
21const KIND_LABEL: Record<DecisionKind, string> = { decide: 'Decide', confirm: 'Confirm', info: 'Info' }
22const KIND_COLOR: Record<DecisionKind, string> = { decide: 'warning', confirm: 'suggestion', info: 'subtle' }
23const RESOLUTION_LABEL: Record<ResolutionBy, string> = {
24 accepted: '✓ Accepted',
25 answered: '✓ Answered',
26 delegated: 'Left to AI',
27 withdrawn: 'AI withdrew',
28 manual: 'Removed by you',
29}
30const RESOLUTION_COLOR: Record<ResolutionBy, string> = {
31 accepted: 'success',
32 answered: 'success',
33 delegated: 'suggestion',
34 withdrawn: 'subtle',
35 manual: 'subtle',
36}
37
38export type SidecarReply = {
39 resolved: ({ id: string } & Resolution)[]
40 added: { title: string; kind: DecisionKind; recommendation?: string }[]
41 updated: { id: string; recommendation: string }[]
42}
43
44export const SYSTEM = `You track the decisions an AI coding assistant is waiting on from its user.
45Reply with one JSON object and nothing else.`
46
47export const buildPrompt = (items: readonly Decision[], userText: string, aiText: string) => `Open items:
48${JSON.stringify(items.map(({ id, title }) => ({ id, title })))}
49
50<user_message>
51${userText.slice(-USER_TEXT_LIMIT)}
52</user_message>
53
54<assistant_messages>
55${aiText.slice(-AI_TEXT_LIMIT)}
56</assistant_messages>
57
581. "resolved": open items no longer pending, each {"id", "by", "answer"}. "by" says how:
59 - "accepted": the user took the assistant's recommendation;
60 - "answered": the user gave another answer, chose another option, rejected it, or supplied the asked-for fact; "answer" is what they chose, a short phrase in the conversation's language;
61 - "delegated": the user handed it back ("you decide", "up to you");
62 - "withdrawn": the assistant settled or withdrew it itself, without the user.
63 Omit "answer" unless "by" is "answered".
642. "added": things the assistant's messages now wait on the user for, not already open. List each one separately; a single message often holds several. Count only:
65 - "decide": the assistant asks the user to choose between options;
66 - "confirm": the assistant asks for a yes/no, or proposes a concrete next step and waits for approval. This includes conditional offers that name the action, wherever they sit in the message: "需要的话我来做 X", "要我顺手把 Y 也改了吗", "I can also do Z if you want";
67 - "info": the assistant asks the user to provide a fact it needs.
68 Skip rhetorical questions and offers that name no action ("有问题随时告诉我", "let me know if you need anything").
69 Each: {"title": a short noun phrase in the conversation's language (at most 20 Chinese characters or 8 words), "kind": "decide" | "confirm" | "info", "recommendation": the assistant's recommended answer, omitted when it gave none}.
703. "updated": open items the assistant asked again with a new recommendation: {"id", "recommendation"}.
71
72Reply exactly as {"resolved": [], "added": [], "updated": []}.`
73
74const isKind = (value: unknown): value is DecisionKind => value === 'decide' || value === 'confirm' || value === 'info'
75const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
76const isResolutionBy = (value: unknown): value is ResolutionBy => typeof value === 'string' && value in RESOLUTION_LABEL
77
78export const parseReply = (text: string): SidecarReply | null => {
79 const start = text.indexOf('{')
80 const end = text.lastIndexOf('}')
81 if (start === -1 || end < start) return null
82 let raw: unknown
83 try {
84 raw = JSON.parse(text.slice(start, end + 1))
85 } catch {
86 return null
87 }
88 if (typeof raw !== 'object' || raw === null) return null
89 const { resolved, added, updated } = raw as Record<string, unknown>
90 const list = (value: unknown) => (Array.isArray(value) ? (value as Record<string, unknown>[]) : [])
91 return {
92 resolved: list(resolved)
93 .filter(one => isText(one?.id))
94 .map(one => {
95 const by = isResolutionBy(one.by) && one.by !== 'manual' ? one.by : 'answered'
96 return {
97 id: one.id as string,
98 by,
99 ...(by === 'answered' && isText(one.answer) ? { answer: one.answer.trim() } : {}),
100 }
101 }),
102 added: list(added)
103 .filter(one => isText(one?.title) && isKind(one?.kind))
104 .map(one => ({
105 title: (one.title as string).trim(),
106 kind: one.kind as DecisionKind,
107 ...(isText(one.recommendation) ? { recommendation: one.recommendation.trim() } : {}),
108 })),
109 updated: list(updated)
110 .filter(one => isText(one?.id) && isText(one?.recommendation))
111 .map(one => ({ id: one.id as string, recommendation: (one.recommendation as string).trim() })),
112 }
113}
114
115const withRemoved = (current: Board, resolutionOf: (item: Decision) => Resolution | undefined): Board => {
116 const gone = current.items.flatMap(item => {
117 const resolution = resolutionOf(item)
118 return resolution === undefined ? [] : [{ ...item, resolution }]
119 })
120 const goneIds = new Set(gone.map(item => item.id))
121 return {
122 ...current,
123 items: current.items.filter(item => !goneIds.has(item.id)),
124 removed: [...gone, ...current.removed].slice(0, REMOVED_KEPT),
125 }
126}
127
128export const applyReply = (current: Board, reply: SidecarReply): Board => {
129 const resolutions = new Map(reply.resolved.map(({ id, ...resolution }) => [id, resolution]))
130 const next = withRemoved(current, item => resolutions.get(item.id))
131 const recommendations = new Map(reply.updated.map(one => [one.id, one.recommendation]))
132 const items = next.items.map(item => {
133 const recommendation = recommendations.get(item.id)
134 return recommendation === undefined ? item : { ...item, recommendation }
135 })
136 let nextSeq = next.nextSeq
137 for (const one of reply.added) {
138 items.push({ id: `d${nextSeq}`, seq: nextSeq, ...one })
139 nextSeq += 1
140 }
141 return { ...next, items, nextSeq }
142}
143
144const MANUAL: Resolution = { by: 'manual' }
145
146export const dismiss = (current: Board, id: string): Board =>
147 withRemoved(current, item => (item.id === id ? MANUAL : undefined))
148
149export const clearAll = (current: Board): Board => withRemoved(current, () => MANUAL)
150
151export const restore = (current: Board, id: string): Board => {
152 const gone = current.removed.find(item => item.id === id)
153 if (gone === undefined) return current
154 const { resolution: _, ...back } = gone
155 return {
156 ...current,
157 items: [...current.items, back].sort((a, b) => a.seq - b.seq),
158 removed: current.removed.filter(item => item !== gone),
159 }
160}
161
162export const quoteText = (item: Decision) => `Re "${item.title}": `
163export const acceptText = (item: Decision) => `Re "${item.title}": going with your recommendation (${item.recommendation ?? ''})`
164
165const CIRCLED = '①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳'
166const ordinal = (index: number) => CIRCLED[index] ?? `(${index + 1})`
167
168export const summary = (items: readonly Decision[]) => items.map((item, i) => `${ordinal(i)}${item.title}`).join(' ')
169
170const isBoard = (value: unknown): value is Board =>
171 typeof value === 'object' &&
172 value !== null &&
173 Array.isArray((value as Board).items) &&
174 Array.isArray((value as Board).removed) &&
175 typeof (value as Board).nextSeq === 'number'
176
177// Loads the current session's board when the state holds another's (a fresh
178// process, a resume, a /clear), so every path goes through one check.
179const sync = async ($: EngineInterface) => {
180 const id = await $.session.id()
181 if ((await read($, board)).sessionId === id) return
182 const stored = await $.store.get(SESSION_KEY_PREFIX + id)
183 await $.state.set(BOARD_REF, isBoard(stored) ? { ...stored, sessionId: id } : { ...EMPTY, sessionId: id })
184}
185
186const save = async ($: EngineInterface, value: Board) => {
187 const id = value.sessionId
188 await $.store.set(SESSION_KEY_PREFIX + id, value)
189 const held = await $.store.get(SESSIONS_KEY)
190 const ids = Array.isArray(held) ? (held as string[]) : []
191 if (ids.includes(id)) return
192 ids.push(id)
193 for (const old of ids.splice(0, Math.max(0, ids.length - SESSIONS_KEPT))) {
194 await $.store.delete(SESSION_KEY_PREFIX + old)
195 }
196 await $.store.set(SESSIONS_KEY, ids)
197}
198
199const change = async ($: EngineInterface, fn: (current: Board) => Board) => {
200 await sync($)
201 await save($, await update($, board, fn))
202}
203
204const fill = async ($: EngineInterface, text: string) => {
205 const box = await $.prompt.read()
206 const separator = box.text === '' || box.text.endsWith('\n') ? '' : '\n'
207 const filled = await $.prompt.fill({ text: separator + text, mode: 'append' })
208 if (!filled.isFilled) $.ui.toast('Can\'t fill the prompt right now; close the dialog holding the keyboard first')
209}
210
211const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Pending decisions' })
212
213type Turn = { userText: string; start: number }
214
215const track = async ($: EngineInterface, turn: Turn | undefined, answer: string) => {
216 const rows = await $.session.messages()
217 const aiText =
218 turn !== undefined && turn.start <= rows.length
219 ? rows
220 .slice(turn.start)
221 .filter(row => row.role === 'assistant' && row.text !== '')
222 .map(row => row.text)
223 .join('\n\n')
224 : answer
225 await sync($)
226 const current = await read($, board)
227 if (aiText.trim() === '' && current.items.length === 0) return
228
229 const reply = await $.model.complete({
230 model: MODEL,
231 system: SYSTEM,
232 prompt: buildPrompt(current.items, turn?.userText ?? '', aiText),
233 maxTokens: 1024,
234 timeoutMs: 20_000,
235 })
236 if (!reply.isAnswered) {
237 $.ui.log(`sidecar failed: ${reply.reason}`, { to: 'debug' })
238 return
239 }
240 const parsed = parseReply(reply.text)
241 if (parsed === null) {
242 $.ui.log(`sidecar replied without valid JSON: ${reply.text.slice(0, 200)}`, { to: 'debug' })
243 return
244 }
245 await change($, latest => applyReply(latest, parsed))
246}
247
248export const register: Register = on => {
249 const turns = new Map<string, Turn>()
250 // One sidecar at a time, so each sees the board the previous one left.
251 let queue: Promise<void> = Promise.resolve()
252
253 on('session.start', async ($, e, next) => {
254 await $.command.register({ name: 'decisions', description: 'Open or close the pending decisions pane' })
255 await sync($)
256 return next(e)
257 })
258
259 on('session.end', async ($, e, next) => {
260 if (e.reason === 'clear' || e.reason === 'resume') {
261 await $.state.set(BOARD_REF, EMPTY)
262 if (e.reason === 'clear') await $.store.delete(SESSION_KEY_PREFIX + e.sessionId)
263 }
264 return next(e)
265 })
266
267 on('turn.start', async ($, e, next) => {
268 await sync($)
269 turns.set(e.turnId, { userText: e.text, start: (await $.session.messages()).length })
270 return next(e)
271 })
272
273 on('turn.complete', async ($, e, next) => {
274 const result = await next(e)
275 const turn = turns.get(e.turnId)
276 turns.delete(e.turnId)
277 if (e.agentId !== undefined || e.reason !== 'answer') return result
278 // Off the turn's own dispatch, so the prompt is free while Haiku reads.
279 $.clock.after(0, () => {
280 queue = queue
281 .then(() => track($, turn, e.answer))
282 .catch(error => $.ui.log(`sidecar error: ${String(error)}`, { to: 'debug' }))
283 })
284 return result
285 })
286
287 on('command.run', { command: 'decisions' }, async $ => {
288 const isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
289 if (isShown) {
290 await $.ui.close({ id: PANE })
291 return { text: 'Closed the pending decisions pane.' }
292 }
293 await openPane($)
294 return { text: 'Opened the pending decisions pane.' }
295 })
296
297 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
298 const { items } = await read($, board)
299 if (e.props.hasSurvey || e.props.view.agentId !== undefined || items.length === 0) return next(e)
300 const { Box, Text, Button } = $.ui.resolve(e)
301 return (
302 <Box flexDirection="row" gap={1} width={e.props.bodyColumns}>
303 <Text color="warning" bold>
304 ⚖ {items.length} pending decisions
305 </Text>
306 <Box flexGrow={1} flexShrink={1}>
307 <Text dimColor wrap="truncate-end">
308 {summary(items)}
309 </Text>
310 </Box>
311 <Button key="open" label="Open" onPress={() => void openPane($)} />
312 </Box>
313 )
314 })
315
316 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
317 const { Box, Text, Button } = $.ui.resolve(e)
318 const { items, removed } = await read($, board)
319 const isRemovedShown = await read($, showRemoved)
320 return (
321 <Box flexDirection="column" width={e.props.bodyColumns}>
322 <Box flexDirection="row" justifyContent="space-between">
323 <Text bold>{items.length === 0 ? 'No pending decisions' : `${items.length} pending decisions`}</Text>
324 {items.length > 0 && (
325 <Button key="clear" label="Clear all" dimColor onPress={() => change($, clearAll)} />
326 )}
327 </Box>
328 {items.map((item, i) => (
329 <Box key={`item:${item.id}`} flexDirection="column" marginTop={1}>
330 <Text>
331 {ordinal(i)} <Text color={KIND_COLOR[item.kind]}>[{KIND_LABEL[item.kind]}]</Text> {item.title}
332 </Text>
333 {item.recommendation !== undefined && <Text dimColor>{` Recommended: ${item.recommendation}`}</Text>}
334 <Box flexDirection="row" gap={1} paddingLeft={3}>
335 <Button key={`quote:${item.id}`} label="Quote" onPress={() => fill($, quoteText(item))} />
336 {item.recommendation !== undefined && (
337 <Button
338 key={`accept:${item.id}`}
339 label="Accept"
340 variant="primary"
341 onPress={() => fill($, acceptText(item))}
342 />
343 )}
344 <Button
345 key={`dismiss:${item.id}`}
346 label="Done"
347 dimColor
348 onPress={() => change($, current => dismiss(current, item.id))}
349 />
350 </Box>
351 </Box>
352 ))}
353 {removed.length > 0 && (
354 <Box flexDirection="column" marginTop={1}>
355 <Button
356 key="toggle-removed"
357 plain
358 dimColor
359 label={`${isRemovedShown ? '▾' : '▸'} Recently removed (${removed.length})`}
360 onPress={() => update($, showRemoved, shown => !shown)}
361 />
362 {isRemovedShown &&
363 removed.map(item => {
364 const by = item.resolution?.by
365 return (
366 <Box key={`removed:${item.id}`} flexDirection="column" paddingLeft={2}>
367 <Box flexDirection="row" gap={1}>
368 <Box flexGrow={1} flexShrink={1}>
369 <Text dimColor wrap="truncate-end">
370 {item.title}
371 </Text>
372 </Box>
373 {by !== undefined && <Text color={RESOLUTION_COLOR[by]}>{RESOLUTION_LABEL[by]}</Text>}
374 <Button
375 key={`restore:${item.id}`}
376 label="Restore"
377 dimColor
378 onPress={() => change($, current => restore(current, item.id))}
379 />
380 </Box>
381 {item.resolution?.answer !== undefined && (
382 <Text dimColor wrap="truncate-end">{` ↳ ${item.resolution.answer}`}</Text>
383 )}
384 </Box>
385 )
386 })}
387 </Box>
388 )}
389 </Box>
390 )
391 })
392}
393types/index.d.ts 38 lines1export type DecisionKind = 'decide' | 'confirm' | 'info'
2
3// How an item left the list: the user took the recommendation, gave another
4// answer or handed it back; the assistant withdrew it; or the user removed it.
5export type ResolutionBy = 'accepted' | 'answered' | 'delegated' | 'withdrawn' | 'manual'
6
7export type Resolution = {
8 by: ResolutionBy
9 // What the user chose, for `answered`.
10 answer?: string
11}
12
13export type Decision = {
14 id: string
15 // Raised order; restoring a removed item puts it back in place.
16 seq: number
17 title: string
18 kind: DecisionKind
19 recommendation?: string
20 // Set while the item sits in `removed`.
21 resolution?: Resolution
22}
23
24export type Board = {
25 // The session this board belongs to; '' before one is loaded.
26 sessionId: string
27 items: Decision[]
28 // Newest first, at most REMOVED_KEPT.
29 removed: Decision[]
30 nextSeq: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'pending-decisions': { board: Board; showRemoved: boolean }
36 }
37}
38