A pill above the prompt that says whether Claude is working, done, or waiting on you.

A Claude Code mod, not part of kanigami: a pill above the prompt that says whether Claude is still working, done with nothing pending, or waiting on you — and, once a turn ends, the reply in one line (TL;DR) and explained simply (ELI5).
status tool the mod adds. A turn that tries to end without reporting is refused once, so it reports; one that still does not is shown as unconfirmed, never as done.At the prompt of a terminal Claude Code session:
/plugin install done-pill --marketplace ksenousi/kanigami
Answer y to add the marketplace, then pick the user scope. Sessions the desktop app starts load it too.
claude plugin validate mods/done-pill
claude plugin test mods/done-pillhooks/register.tsx 163 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Pill, Verdict } from '../types'
5
6// The pill: whether Claude is still working, finished with nothing pending,
7// or finished but waiting on you — and under it, once a turn has ended, the
8// reply in one line (TL;DR) and explained simply (ELI5). The engine knows when a turn is running;
9// only Claude knows whether it left a question open, so it says so through
10// the status tool as the last thing it does each turn. A turn that ends
11// without saying is shown as unconfirmed, never as done.
12//
13// Enforced, not just asked: when Claude tries to end a turn without having
14// said, the Stop is refused once with a reminder, so it says and then ends.
15// And background work is not left to its word: the Stop carries the
16// session's running tasks, and a turn that called itself done while any are
17// still going is shown as running in the background.
18
19const declared = atom({ plugin: 'done-pill', key: 'declared' } as const, null)
20const pill = atom({ plugin: 'done-pill', key: 'pill' } as const, null)
21const running = atom({ plugin: 'done-pill', key: 'running' } as const, [])
22
23const TOOL = 'mcp__done-pill__status'
24const STATES = ['done', 'needs-you', 'background'] as const
25
26const INSTRUCTION = `# Status pill
27
28The person cannot tell from the transcript alone whether you are finished, so a pill above their prompt shows it. In every turn where you reply to them, call ${TOOL} exactly once, just before your final message, then write that message and end the turn.
29
30- state "done": everything they asked for is finished and checked, nothing you started is still running, and nothing waits on them. They can close the laptop.
31- state "needs-you": you asked a question, offered options, need a decision, an approval or an action from them, or something failed or is left unresolved.
32- state "background": work you started is still running (a background command, a subagent, a deploy you are watching) and you will report back when it ends.
33
34reason: one short plain sentence saying what is done, or exactly what is pending. Be strict: if anything at all is open, it is not "done".
35
36tldr: your final message in one line — the outcome, not the process.
37
38eli5: the same explained simply, as to someone who has not followed the details: a sentence or two, no jargon, no file names.`
39
40const LOOK: Record<Pill['kind'] | 'working', { label: string; color: 'claude' | 'success' | 'warning' | 'suggestion' | 'subtle' | 'inactive' }> = {
41 working: { label: 'Working', color: 'claude' },
42 done: { label: 'Done · safe to close', color: 'success' },
43 'needs-you': { label: 'Needs you', color: 'warning' },
44 background: { label: 'Running in the background', color: 'suggestion' },
45 unconfirmed: { label: 'Ended · not confirmed done', color: 'subtle' },
46 stopped: { label: 'Stopped', color: 'inactive' },
47}
48
49export const register: Register = on => {
50 on('session.start', async ($, e, next) => {
51 await $.tool.register({
52 name: 'status',
53 description:
54 'Report whether this turn leaves anything pending, for the status pill above the prompt. Call once per turn, just before the final message.',
55 inputSchema: {
56 type: 'object',
57 properties: {
58 state: { type: 'string', enum: [...STATES] },
59 reason: { type: 'string', description: 'One short sentence: what is done, or what is pending.' },
60 tldr: { type: 'string', description: 'The final message in one line: the outcome, not the process.' },
61 eli5: { type: 'string', description: 'The same explained simply, in a sentence or two, no jargon.' },
62 },
63 required: ['state', 'reason', 'tldr', 'eli5'],
64 },
65 isDeferred: false,
66 })
67 return next(e)
68 })
69
70 on('prompt.compose', async ($, e, next) => {
71 const composed = await next(e)
72 return { ...composed, sections: [...composed.sections, { id: 'done-pill', text: INSTRUCTION, scope: 'session' }] }
73 })
74
75 on('tool.call', { tool: TOOL }, async ($, e) => {
76 // A subagent's verdict is about its own errand, not the conversation.
77 if (e.agentId) return { result: 'Ignored: only the main conversation sets the pill.' }
78 const state = STATES.find(s => s === e.state)
79 if (!state) return { deny: `state must be one of ${STATES.join(', ')}` }
80 const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
81 const verdict: Verdict = { state, reason: text(e.reason), tldr: text(e.tldr), eli5: text(e.eli5) }
82 await update($, declared, () => verdict)
83 return { result: 'Recorded.' }
84 }).catch(() => ({ deny: 'done-pill could not record the status; say plainly in your message whether anything is pending.' }))
85
86 on('turn.start', async ($, e, next) => {
87 await update($, declared, () => null)
88 await update($, running, () => [])
89 return next(e)
90 })
91
92 // The main loop's Stop: refuse it once if nothing was reported, and note
93 // what is still running either way.
94 on('classic.Stop', async ($, e, next) => {
95 // Only what is still going, named as the person would recognise it.
96 const tasks = (e.background_tasks ?? [])
97 .filter(t => t.status === 'running' || t.status === 'pending')
98 // An artifact's live-update watch listens for edits for the rest of the
99 // session and never finishes: it is not work anyone is waiting on.
100 .filter(t => !(t.type === 'monitor' && /^live updates for artifact/i.test(t.description)))
101 .map(t => `${t.type}: ${(t.description || t.command || t.agent_type || t.id).slice(0, 60)}`)
102 await update($, running, () => tasks)
103 if (!(await read($, declared)) && !e.stop_hook_active) {
104 return { block: `Before ending the turn, call ${TOOL} with state and reason (done, needs-you or background), then end it.` }
105 }
106 return next(e)
107 })
108
109 on('turn.complete', async ($, e, next) => {
110 const answered = await next(e)
111 if (e.agentId) return answered
112 const verdict = await read($, declared)
113 let shown: Pill = e.isAborted
114 ? { kind: 'stopped', reason: 'You stopped the turn.' }
115 : e.reason !== 'answer'
116 ? { kind: 'needs-you', reason: 'The turn ended on an error.' }
117 : verdict
118 ? { kind: verdict.state, reason: verdict.reason, tldr: verdict.tldr, eli5: verdict.eli5 }
119 : { kind: 'unconfirmed', reason: 'Claude ended the turn without saying whether anything is pending.' }
120 // Done is not done while something is still running.
121 const tasks = await read($, running)
122 if (shown.kind === 'done' && tasks.length > 0) {
123 shown = { ...shown, kind: 'background', reason: `Said done, but still running: ${tasks.join(' · ')}` }
124 }
125 await update($, pill, () => shown)
126 return answered
127 })
128
129 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130 if (e.props.hasSurvey) return next(e)
131 const last = await read($, pill)
132 const kind = e.props.isWorking ? 'working' : last?.kind
133 if (!kind) return next(e)
134
135 const { Box, Text } = $.ui.resolve(e)
136 const look = LOOK[kind]
137 // While a turn runs, the last one's summary would describe the wrong reply.
138 const ended = kind === 'working' ? null : last
139 const reason = ended?.reason ?? ''
140
141 return (
142 <Box flexDirection="column" gap={0}>
143 <Text key="status" color={look.color} bold wrap="truncate-end">
144 ● {look.label}
145 {reason ? <Text dimColor bold={false}>{` ${reason}`}</Text> : null}
146 </Text>
147 {ended?.tldr ? (
148 <Text key="tldr" wrap="wrap">
149 <Text bold>TL;DR </Text>
150 {ended.tldr}
151 </Text>
152 ) : null}
153 {ended?.eli5 ? (
154 <Text key="eli5" wrap="wrap">
155 <Text bold>ELI5 </Text>
156 <Text dimColor>{ended.eli5}</Text>
157 </Text>
158 ) : null}
159 </Box>
160 )
161 })
162}
163types/index.d.ts 31 lines1/** What Claude said at the end of its turn, through the status tool. */
2export type Verdict = {
3 state: 'done' | 'needs-you' | 'background'
4 reason: string
5 /** The reply in one line. */
6 tldr: string
7 /** The reply explained simply, in a sentence or two. */
8 eli5: string
9}
10
11/** What the pill shows once a turn has ended. */
12export type Pill = {
13 kind: 'done' | 'needs-you' | 'background' | 'unconfirmed' | 'stopped'
14 reason: string
15 tldr?: string
16 eli5?: string
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'done-pill': {
22 /** This turn's verdict so far, cleared when a turn starts. */
23 declared: Verdict | null
24 /** The last finished turn's pill; null before the first turn ends. */
25 pill: Pill | null
26 /** Background work still running when the turn last tried to stop. */
27 running: string[]
28 }
29 }
30}
31