A band above the prompt that lists the actions and questions the agent left for you

Two mods for Claude Code. They work in the terminal and in the Code tab of the Claude desktop app.
| Mod | What it shows |
|---|---|
ci-pipeline | A pane with the CI/CD stage of the branch you work on |
my-actions | A box above the prompt with the actions and questions the agent left for you |
Early access. Mods use the Claude Code function-hooks API. This API changes between releases. I tested these mods on Claude Code 2.1.288.
In Claude Code, add this repo as a plugin marketplace:
/plugin marketplace add alamine42/claude-mods
Then install the mods you want:
/plugin install ci-pipeline@claude-mods
/plugin install my-actions@claude-mods
To try a mod without installing it, clone this repo and start Claude Code with the mod folder:
claude --plugin-dir ./plugins/my-actions
<!-- TODO: screenshot -->
A pane that shows where the current branch is in 12 stages:
| Code | Staging | Production |
|---|---|---|
| Local dev | Deploying to Staging | Deploying to Prod |
| PR open | Staging Deployed | Prod Deployed |
| PR checks running | Smoke Testing Staging | Smoke Testing Prod |
| PR merged | Staging Smoke Tested | Prod Smoke Tested |
Each stage has a mark: ✓ done, ● running, ✗ failed, ◌ the next stage it waits for, · not yet reached. The status line shows the current stage. A toast tells you when the stage changes.
Commands
/pipeline opens the pane./pipeline 123 follows PR #123./pipeline branch follows the current branch again.r to refresh.How it finds each stage
git and gh pr view.workflow / job against a regex for each stage.Options
| Option | Default | |
|---|---|---|
deployStaging | `deploy.*stag\ | stag.*deploy` |
smokeStaging | `smoke.*stag\ | stag.*smoke` |
deployProd | `deploy.*prod\ | prod.*deploy` |
smokeProd | `smoke.*prod\ | prod.*smoke` |
pollSeconds | 20 |
Change them in the Claude Code config menu, or under pluginConfigs.ci-pipeline in your settings.
Needs: git and the GitHub CLI, logged in.
Limits
main, the pane follows main. Use /pipeline <number> to keep a PR in view.<!-- TODO: screenshot -->
When you come back to a long session, the asks for you are easy to miss in the text. This mod lists them above the prompt, in two boxes:
▸): a task only you can do, such as a check in the app or a test by hand. It shows the exact command when there is one.↻, dim): a task the agent asked you to do but could do itself. Tell the agent to do it, or do it yourself.?): a question the agent waits on. A request for your go-ahead ("Shall I push?") is a question too.Each item has a Done button. When the box has focus, keys 1–9 press it. Clear empties one box. The boxes sit side by side when the window is wide enough. Long items wrap, and a long list scrolls. A task shows in one box only. The status line shows a count, for example Your move: 2 actions, 1 question.
How it works. When a reply ends, the mod sends the reply, your last message, and the open list to a small model. The model returns the new list. It adds new asks. It removes the asks that you answered or that the agent did. When you send a message, the mod also checks it at once and clears the questions it answers, so they do not wait for the reply to end. When the mod loads or a session resumes, it reads the last few messages.
Commands
/my-actions builds the list again from the whole session./my-actions clear empties the list.Options
| Option | Default |
|---|---|
model | haiku |
Cost and privacy. The mod makes one model call per reply, on your own Claude account. Each call sends the reply text and your last message to that model.
Limits
/my-actions to correct the list.MIT
hooks/register.tsx 322 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ActionItem } from '../types'
5
6const items = atom({ plugin: 'my-actions', key: 'items' } as const, [])
7const isUpdating = atom({ plugin: 'my-actions', key: 'isUpdating' } as const, false)
8
9const SYSTEM = `You track the open to-do list that a coding agent has left for its human user.
10
11An item belongs on the list only when the HUMAN must do something:
12- "action": a thing the human must do. Examples: run a command, merge or check a PR, set a secret, restart an app, test something by hand, look at the screen.
13- "question": a question the agent asked the human and is waiting on, or a choice the agent wants the human to make.
14
15A request for permission is a "question", not an "action". When the agent offers to do a task once the human says yes ("Tell me if you want me to commit and push", "Shall I change the setting?"), write it as a question: "Commit and push the box changes?".
16
17For each "action", set "owner":
18- "you": only the human can do it. Examples: look at the app, test by hand, type in a password, use an account or device the agent cannot reach, start a new session.
19- "agent": the agent has the tools to do it itself, but asked the human to. Examples: run a plain shell command in the repo, edit a file, check a PR with gh.
20
21Not items: work the agent did, work the agent says it will do, findings, summaries, and offers the agent makes in passing with no real need for an answer.
22
23You get the open list, the human's latest message, and the agent's latest reply. Return the new open list:
24- Never add back a question that the human's message answers.
25- Keep an open item unless the human's message answers it, the human says it is done, or the reply shows it is done or no longer needed.
26- Add new items from the agent's latest reply. Do not add an item that is already on the list.
27- Keep the "id" of each kept item. Give each new item the id "new".
28- "text": one short imperative line, 12 words or fewer. For a question, write the question itself.
29- "command": the exact shell command when the action is to run one; otherwise leave it out.
30- Never list one task twice. A task is an "action" or a "question", not both. When the agent waits for a go-ahead to do it, it is a question; drop the action.
31
32Reply with JSON only, no prose and no code fence: {"items":[{"id":"...","kind":"action"|"question","owner":"you"|"agent","text":"...","command":"..."}]}`
33
34let model = 'haiku'
35let lastPrompt = ''
36let lastReply = ''
37let counter = 0
38let chain: Promise<void> = Promise.resolve()
39
40function parse(text: string): ActionItem[] | null {
41 const start = text.indexOf('{')
42 const end = text.lastIndexOf('}')
43 if (start < 0 || end <= start) return null
44 try {
45 const raw = JSON.parse(text.slice(start, end + 1)) as { items?: unknown }
46 if (!Array.isArray(raw.items)) return null
47 return raw.items.flatMap((one: Record<string, unknown>) => {
48 const kind = one.kind === 'question' ? 'question' : one.kind === 'action' ? 'action' : null
49 const body = typeof one.text === 'string' ? one.text.trim() : ''
50 if (!kind || !body) return []
51 const item: ActionItem = { id: String(one.id ?? 'new'), kind, text: body }
52 if (typeof one.command === 'string' && one.command.trim()) item.command = one.command.trim()
53 if (kind === 'action') item.owner = one.owner === 'agent' ? 'agent' : 'you'
54 return [item]
55 })
56 } catch {
57 return null
58 }
59}
60
61// Asks the small model for the new list and stores it. Calls run one after another.
62function refresh($: EngineInterface, prompt: string, isRebuild: boolean) {
63 chain = chain.then(async () => {
64 await update($, isUpdating, () => true)
65 try {
66 const before = await read($, items)
67 const r = await $.model.complete({ model, system: SYSTEM, prompt, maxTokens: 1500, timeoutMs: 45000 })
68 if (!r.isAnswered) {
69 $.ui.log(`my-actions: the model gave no answer (${r.reason})`)
70 return
71 }
72 const next = parse(r.text)
73 if (!next) {
74 $.ui.log('my-actions: the model reply was not valid JSON')
75 return
76 }
77 const known = new Set(before.map(i => i.id))
78 const list = dedupe(next).map(i => (known.has(i.id) && !isRebuild ? i : { ...i, id: `a${++counter}` }))
79 await update($, items, () => list)
80 const added = list.filter(i => !known.has(i.id)).length
81 if (added && !isRebuild) $.ui.toast(`${added} new item${added === 1 ? '' : 's'} for you`)
82 showStatus($, list)
83 } finally {
84 await update($, isUpdating, () => false)
85 }
86 }).catch(() => undefined)
87}
88
89const WORD = /[a-z0-9]+/g
90const STOP = new Set(['the', 'a', 'an', 'to', 'and', 'or', 'of', 'in', 'on', 'for', 'it', 'is', 'you', 'me', 'my', 'your', 'this', 'that', 'should', 'shall', 'do', 'i', 'we'])
91const words = (text: string) => new Set((text.toLowerCase().match(WORD) ?? []).filter(w => !STOP.has(w)))
92
93// True when two lines name the same task: most of their words are the same.
94function isSameTask(a: ActionItem, b: ActionItem) {
95 const x = words(a.text)
96 const y = words(b.text)
97 if (!x.size || !y.size) return false
98 let shared = 0
99 for (const w of x) if (y.has(w)) shared++
100 return shared / Math.min(x.size, y.size) >= 0.6
101}
102
103// A task in both boxes keeps one place: a go-ahead the agent waits on stays a question,
104// a task only the person can do stays an action.
105function dedupe(list: ActionItem[]) {
106 const actions = list.filter(i => i.kind === 'action')
107 return list.filter(item => {
108 if (item.kind === 'action') {
109 return item.owner !== 'agent' || !list.some(q => q.kind === 'question' && isSameTask(q, item))
110 }
111 return !actions.some(a => a.owner !== 'agent' && isSameTask(a, item))
112 })
113}
114
115const ANSWERED = `A human sent a message to a coding agent. Decide which open items on the human's list the message settles.
116
117An item is settled when the message answers the question (a plain "yes", "no", "go ahead" or a choice answers the question the agent asked last), or says the task is done, or says to drop it.
118
119Reply with JSON only, no prose and no code fence: {"settled":["id", ...]}. Use [] when the message settles nothing.`
120
121// Clears the items the person's message settles, as soon as they send it.
122function settle($: EngineInterface, message: string) {
123 chain = chain.then(async () => {
124 const open = await read($, items)
125 if (!open.length || !message.trim()) return
126 const r = await $.model.complete({
127 model,
128 system: ANSWERED,
129 prompt: [
130 `OPEN ITEMS:\n${JSON.stringify({ items: open })}`,
131 `AGENT'S LAST REPLY:\n${clip(lastReply, 8000) || '(none)'}`,
132 `HUMAN'S MESSAGE:\n${clip(message, 4000)}`,
133 ].join('\n\n'),
134 maxTokens: 300,
135 timeoutMs: 20000,
136 })
137 if (!r.isAnswered) return
138 const start = r.text.indexOf('{')
139 const end = r.text.lastIndexOf('}')
140 if (start < 0 || end <= start) return
141 try {
142 const { settled } = JSON.parse(r.text.slice(start, end + 1)) as { settled?: unknown }
143 if (!Array.isArray(settled) || !settled.length) return
144 const ids = new Set(settled.map(String))
145 await update($, items, l => l.filter(one => !ids.has(one.id)))
146 showStatus($, await read($, items))
147 } catch {
148 // A reply that is not JSON settles nothing; the end-of-turn update still runs.
149 }
150 }).catch(() => undefined)
151}
152
153function showStatus($: EngineInterface, list: ActionItem[]) {
154 const actions = list.filter(i => i.kind === 'action').length
155 const questions = list.length - actions
156 if (!list.length) {
157 $.ui.status(undefined)
158 return
159 }
160 const parts = [
161 actions ? `${actions} action${actions === 1 ? '' : 's'}` : '',
162 questions ? `${questions} question${questions === 1 ? '' : 's'}` : '',
163 ].filter(Boolean)
164 $.ui.status(`Your move: ${parts.join(', ')}`)
165}
166
167async function dismiss($: EngineInterface, id: string | null) {
168 await update($, items, l => (id === null ? [] : l.filter(one => one.id !== id)))
169 showStatus($, await read($, items))
170}
171
172// On load or resume, read the last exchanges so the box is right before the next turn ends.
173async function bootstrap($: EngineInterface) {
174 const rows = (await $.session.messages()).filter(row => row.text.trim())
175 const lastAgent = rows.map(row => row.role).lastIndexOf('assistant')
176 if (lastAgent < 0) return
177 lastReply = rows[lastAgent].text
178 if ((await read($, items)).length) return
179 const recent = rows
180 .slice(Math.max(0, lastAgent - 5), lastAgent + 1)
181 .map(row => `${row.role === 'user' ? 'HUMAN' : 'AGENT'}: ${row.text.trim()}`)
182 .join('\n\n')
183 refresh($, [
184 'OPEN LIST:\n{"items":[]}',
185 'Build the list from the recent conversation below. An item stays open only if nothing later in it closes it.',
186 `CONVERSATION:\n${clip(recent, 30000)}`,
187 ].join('\n\n'), true)
188}
189
190async function clearKind($: EngineInterface, kind: ActionItem['kind']) {
191 await update($, items, l => l.filter(one => one.kind !== kind))
192 showStatus($, await read($, items))
193}
194
195const clip = (text: string, max: number) => (text.length > max ? `…${text.slice(-max)}` : text)
196
197export const register: Register = (on, options) => {
198 if (typeof options.model === 'string' && options.model) model = options.model
199
200 on('session.start', async ($, e, next) => {
201 await $.command.register({
202 name: 'my-actions',
203 description: 'Rebuild the list of your open actions and questions from the whole session. "clear" empties it',
204 })
205 showStatus($, await read($, items))
206 $.clock.after(10, () => void bootstrap($))
207
208 return next(e)
209 })
210
211 on('prompt.submit', ($, e, next) => {
212 lastPrompt = e.text
213 // The model call outlives this dispatch, so start it from a timer.
214 $.clock.after(10, () => settle($, e.text))
215
216 return next(e)
217 })
218
219 on('turn.complete', async ($, e, next) => {
220 const result = await next(e)
221 if (e.agentId || !e.answer.trim()) return result
222 lastReply = e.answer
223
224 const open = await read($, items)
225 const prompt = [
226 `OPEN LIST:\n${JSON.stringify({ items: open })}`,
227 `HUMAN'S LATEST MESSAGE:\n${clip(lastPrompt, 4000) || '(none)'}`,
228 `AGENT'S LATEST REPLY:\n${clip(e.answer, 20000)}`,
229 ].join('\n\n')
230 // The model call outlives this dispatch, so start it from a timer.
231 $.clock.after(10, () => refresh($, prompt, false))
232
233 return result
234 })
235
236 on('command.run', { command: 'my-actions' }, async ($, e) => {
237 if (e.args.trim() === 'clear') {
238 await update($, items, () => [])
239 showStatus($, [])
240 return { text: 'Cleared your action list.' }
241 }
242 const rows = await $.session.messages()
243 const transcript = rows
244 .filter(row => row.text.trim())
245 .map(row => `${row.role === 'user' ? 'HUMAN' : 'AGENT'}: ${row.text.trim()}`)
246 .join('\n\n')
247 const prompt = [
248 'OPEN LIST:\n{"items":[]}',
249 'Build the list from the whole conversation below. An item stays open only if nothing later in the conversation closes it.',
250 `CONVERSATION:\n${clip(transcript, 80000)}`,
251 ].join('\n\n')
252 $.clock.after(10, () => refresh($, prompt, true))
253
254 return { text: 'Rebuilding your action list from this session.' }
255 })
256
257 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
258 const list = await read($, items)
259 const busy = await read($, isUpdating)
260 if (e.props.hasSurvey || list.length === 0) return next(e)
261
262 const { Box, Text, Button } = $.ui.resolve(e)
263 // The tasks only you can do come first.
264 const todos = list.filter(i => i.kind === 'action' && i.owner !== 'agent').concat(list.filter(i => i.kind === 'action' && i.owner === 'agent'))
265 const questions = list.filter(i => i.kind === 'question')
266 const boxes = (todos.length ? 1 : 0) + (questions.length ? 1 : 0)
267
268 // Side by side when there is room, else one box above the other.
269 // A band taller than the room scrolls, so every item shows in full.
270 const isWide = boxes === 2 && e.props.bodyColumns >= 100
271
272 const row = (item: ActionItem, n: number) => {
273 const isAgents = item.kind === 'action' && item.owner === 'agent'
274 return (
275 <Box>
276 <Text color={item.kind === 'question' ? 'cyan' : isAgents ? 'gray' : 'yellow'}>
277 {item.kind === 'question' ? '? ' : isAgents ? '↻ ' : '▸ '}
278 </Text>
279 <Box flexGrow={1}>
280 <Text wrap="wrap" dimColor={isAgents}>
281 {item.text}
282 {item.command ? <Text dimColor> `{item.command}`</Text> : null}
283 {isAgents ? <Text dimColor italic> · the agent can do this</Text> : null}
284 </Text>
285 </Box>
286 <Text> </Text>
287 <Button
288 key={`done-${item.id}`}
289 label="Done"
290 plain
291 hotkey={n <= 9 ? String(n) : undefined}
292 onPress={() => dismiss($, item.id)}
293 />
294 </Box>
295 )
296 }
297
298 const box = (kind: ActionItem['kind'], title: string, color: string, group: ActionItem[], first: number, isFirst: boolean) =>
299 group.length > 0 && (
300 <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor={color} paddingX={1}>
301 <Box marginBottom={1}>
302 <Box flexGrow={1}>
303 <Text bold color={color}>
304 {title} ({group.length})
305 {busy && isFirst ? <Text dimColor> · updating…</Text> : null}
306 </Text>
307 </Box>
308 <Button key={`clear-${kind}`} label="Clear" plain onPress={() => clearKind($, kind)} />
309 </Box>
310 {group.map((item, i) => row(item, first + i))}
311 </Box>
312 )
313
314 return (
315 <Box flexDirection={isWide ? 'row' : 'column'}>
316 {box('action', 'To do', 'yellow', todos, 1, true)}
317 {box('question', 'Questions', 'cyan', questions, todos.length + 1, todos.length === 0)}
318 </Box>
319 )
320 })
321}
322types/index.d.ts 15 lines1export type ActionItem = {
2 id: string
3 kind: 'action' | 'question'
4 // For an action: 'you' when only the person can do it, 'agent' when the agent could.
5 owner?: 'you' | 'agent'
6 text: string
7 command?: string
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'my-actions': { items: ActionItem[]; isUpdating: boolean }
13 }
14}
15