SLOPSHOPPER

my-actions

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

newbandcommandtoaststatusprompt
v0.1.0MITupdated 2026-10-05alamine42/claude-mods/plugins/my-actions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · my-actions
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /my-actions ⎿ my-actions: Rebuilding your action list from this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Two mods for Claude Code. They work in the terminal and in the Code tab of the Claude desktop app.

ModWhat it shows
ci-pipelineA pane with the CI/CD stage of the branch you work on
my-actionsA 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.

Install

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

ci-pipeline

<!-- TODO: screenshot -->

A pane that shows where the current branch is in 12 stages:

CodeStagingProduction
Local devDeploying to StagingDeploying to Prod
PR openStaging DeployedProd Deployed
PR checks runningSmoke Testing StagingSmoke Testing Prod
PR mergedStaging Smoke TestedProd 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.
  • In the pane, press r to refresh.

How it finds each stage

  • Local dev, PR open, PR checks and PR merged come from git and gh pr view.
  • The staging and prod stages come from the GitHub Actions jobs that ran on the merge commit. The mod matches the name workflow / job against a regex for each stage.

Options

OptionDefault
deployStaging`deploy.*stag\stag.*deploy`
smokeStaging`smoke.*stag\stag.*smoke`
deployProd`deploy.*prod\prod.*deploy`
smokeProd`smoke.*prod\prod.*smoke`
pollSeconds20

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

  • Deploys that do not run in GitHub Actions on the merge commit do not show.
  • After you switch to main, the pane follows main. Use /pipeline <number> to keep a PR in view.

my-actions

<!-- 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:

  • To do (▸): 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.
  • To do, agent can do (↻, dim): a task the agent asked you to do but could do itself. Tell the agent to do it, or do it yourself.
  • Questions (?): 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

OptionDefault
modelhaiku

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

  • Asks inside subagent output show only when the main reply repeats them.
  • The model can miss an ask or add one that is not real. Use Done or /my-actions to correct the list.

License

MIT

Source 2 files
hooks/register.tsx 322 lines
1import { 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}
322
types/index.d.ts 15 lines
1export 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