SLOPSHOPPER

prompt-queue

Park prompt drafts in a queue above the prompt box; send, edit or remove each when ready.

bandcommandtoastprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-queue
› 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 › /queue ⎿ prompt-queue: Queue is empty. Use /queue <text> or the Queue draft button. [ + Queue ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ + Queue ]
README

agent-toolkit

My mods, skills and plugins for coding agents. Skills use the open SKILL.md format, so they work in many harnesses. Mods are harness-only and are marked below.

What is inside

PluginWhat it doesWorks in
prompt-queuePark prompt drafts above the prompt box. Send, edit or remove each one later.Claude Code only (mod)
worktree-poolPool of leased git worktree slots. Many agents work on one repo at once and never collide. Safe reclaim of idle slots.Any harness (skills + Node CLI)
worktree-pool-hooksHooks for worktree-pool: block edits in the root checkout, enforce slot ownership, record heartbeats.Claude Code and Codex (hooks)

Featured: worktree-pool

Running more than one coding agent on a repo breaks things: agents overwrite each other, every task reinstalls dependencies, and nobody knows which worktree is safe to delete. worktree-pool gives each agent its own leased slot, keeps installs warm, and frees idle slots only with proof that no work is lost. Abandoned work is saved to a branch first. Read why and how.

Install

Claude Code (mods and plugins):

claude plugin marketplace add alizaeda/agent-toolkit
claude plugin install prompt-queue@agent-toolkit
claude plugin install worktree-pool@agent-toolkit
claude plugin install worktree-pool-hooks@agent-toolkit

Any harness (skills only; detects Claude Code, Codex, Cursor, Gemini CLI and more):

npx skills add alizaeda/agent-toolkit

Codex (plugins): add .agents/plugins/marketplace.json once a plugin with skills is listed. See templates/plugin/.

Add a new plugin

  1. Copy templates/plugin to plugins/<name>.
  2. Put skills in plugins/<name>/skills/<skill>/SKILL.md (start from templates/skill).
  3. Add an entry to .claude-plugin/marketplace.json. The entry name must equal the plugin name.
  4. For Codex, add an entry to .agents/plugins/marketplace.json (source.path starts with ./).
  5. Run claude plugin validate ., then update CHANGELOG.md and tag a version.

License

MIT

Source 2 files
hooks/register.tsx 234 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { QueueItem } from '../types'
5
6const items = atom({ plugin: 'prompt-queue', key: 'items' } as const, [])
7
8const STORE_KEY = 'items'
9const PREVIEW = 36
10
11const preview = (text: string): string => {
12  const flat = text.replace(/\s+/g, ' ').trim()
13
14  return flat.length > PREVIEW ? `${flat.slice(0, PREVIEW - 1)}…` : flat
15}
16
17type Dollar = EngineInterface
18
19async function persist($: Dollar, list: QueueItem[]) {
20  await $.store.set(STORE_KEY, list)
21}
22
23async function add($: Dollar, text: string): Promise<boolean> {
24  const clean = text.trim()
25
26  if (clean === '') {
27    return false
28  }
29
30  const id = `${await $.clock.now()}-${Math.random().toString(36).slice(2, 6)}`
31  const next = await update($, items, (list: QueueItem[]) => [...list, { id, text: clean }])
32  await persist($, next)
33
34  return true
35}
36
37async function remove($: Dollar, id: string) {
38  const next = await update($, items, (list: QueueItem[]) => list.filter(one => one.id !== id))
39  await persist($, next)
40}
41
42async function sendNow($: Dollar, item: QueueItem) {
43  await remove($, item.id)
44  void $.prompt.submit({ text: item.text, asUser: true })
45}
46
47async function setArmed($: Dollar, id: string, armed: boolean) {
48  const next = await update($, items, (list: QueueItem[]) =>
49    list.map(one => (one.id === id ? { ...one, armed } : one)),
50  )
51  await persist($, next)
52}
53
54async function sendNext($: Dollar): Promise<boolean> {
55  const list = (await read($, items)) as QueueItem[]
56  const item = list.find(one => one.armed)
57
58  if (!item) {
59    return false
60  }
61
62  await sendNow($, item)
63
64  return true
65}
66
67async function queueDraft($: Dollar) {
68  const { text } = await $.prompt.read()
69
70  if (!(await add($, text))) {
71    $.ui.toast('Prompt box is empty. Nothing to queue.')
72
73    return
74  }
75
76  await $.prompt.fill({ text: '', mode: 'replace' })
77  $.ui.toast('Queued.')
78}
79
80export const register: Register = on => {
81  let isBusy = false
82
83  on('prompt.submit', async ($, e, next) => {
84    isBusy = isBusy || !e.text.trimStart().startsWith('/')
85
86    return next(e)
87  })
88
89  on('turn.complete', async ($, e, next) => {
90    isBusy = await sendNext($)
91
92    return next(e)
93  })
94
95  on('session.start', async ($, e, next) => {
96    const saved = await $.store.get(STORE_KEY)
97
98    if (Array.isArray(saved)) {
99      await update($, items, () => saved as QueueItem[])
100    }
101
102    await $.command.register({
103      name: 'queue',
104      description: 'Queue a prompt: /queue <text> | send <n> | after <n> | edit <n> | rm <n> | clear',
105    })
106
107    return next(e)
108  })
109
110  on('command.run', { command: 'queue' }, async ($, e) => {
111    const args = e.args.trim()
112    const list = (await read($, items)) as QueueItem[]
113    const [verb, rest] = args.split(/\s+/, 2)
114    const pick = (): QueueItem | undefined => list[Number(rest) - 1]
115
116    if (args === '') {
117      if (list.length === 0) {
118        return { text: 'Queue is empty. Use /queue <text> or the Queue draft button.' }
119      }
120
121      return { text: list.map((one, i) => `${i + 1}. ${one.armed ? '[after] ' : ''}${preview(one.text)}`).join('\n') }
122    }
123
124    if (verb === 'clear') {
125      await update($, items, () => [])
126      await persist($, [])
127
128      return { text: 'Queue cleared.' }
129    }
130
131    if (verb === 'send' || verb === 'after' || verb === 'edit' || verb === 'rm') {
132      const item = pick()
133
134      if (!item) {
135        return { text: `No queue item "${rest ?? ''}". Use /queue to see the list.` }
136      }
137
138      if (verb === 'after') {
139        if (isBusy) {
140          await setArmed($, item.id, true)
141
142          return { text: `Item ${rest} will send when the agent is done.` }
143        }
144
145        isBusy = true
146        $.clock.after(50, () => void sendNow($, item))
147
148        return { text: `Agent is idle. Sent item ${rest}.` }
149      }
150
151      if (verb === 'send') {
152        isBusy = true
153        $.clock.after(50, () => void sendNow($, item))
154
155        return { text: `Sent item ${rest}.` }
156      }
157
158      await remove($, item.id)
159
160      if (verb === 'edit') {
161        await $.prompt.fill({ text: item.text, mode: 'replace' })
162
163        return { text: `Item ${rest} is in the prompt box.` }
164      }
165
166      return { text: `Removed item ${rest}.` }
167    }
168
169    await add($, args)
170
171    return { text: 'Queued.' }
172  })
173
174  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
175    if (e.props.hasSurvey) {
176      return next(e)
177    }
178
179    const list = (await read($, items)) as QueueItem[]
180    const { Box, Button, Text } = $.ui.resolve(e)
181
182    return (
183      <Box flexDirection="column" paddingX={1}>
184        <Box gap={1}>
185          <Button key="queue" label="+ Queue" onPress={() => queueDraft($)} />
186          {list.length > 0 && <Text dimColor>{list.length} waiting</Text>}
187        </Box>
188        {list.map(one => (
189          <Box key={one.id} gap={1} paddingLeft={2}>
190            <Text dimColor>
191              {one.armed ? '⏱ ' : ''}
192              {preview(one.text)}
193            </Text>
194            <Button
195              key={`send-${one.id}`}
196              label="Send"
197              onPress={async () => {
198                isBusy = true
199                await sendNow($, one)
200              }}
201            />
202            {one.armed ? (
203              <Button key={`hold-${one.id}`} label="Hold" onPress={() => setArmed($, one.id, false)} />
204            ) : (
205              <Button
206                key={`after-${one.id}`}
207                label="After"
208                onPress={async () => {
209                  if (isBusy) {
210                    await setArmed($, one.id, true)
211                    $.ui.toast('Will send when the agent is done.')
212                  } else {
213                    isBusy = true
214                    await sendNow($, one)
215                  }
216                }}
217              />
218            )}
219            <Button
220              key={`edit-${one.id}`}
221              label="Edit"
222              onPress={async () => {
223                await remove($, one.id)
224                await $.prompt.fill({ text: one.text, mode: 'replace' })
225              }}
226            />
227            <Button key={`rm-${one.id}`} label="✕" onPress={() => remove($, one.id)} />
228          </Box>
229        ))}
230      </Box>
231    )
232  })
233}
234
types/index.d.ts 8 lines
1export type QueueItem = { id: string; text: string; armed?: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'prompt-queue': { items: QueueItem[] }
6  }
7}
8