SLOPSHOPPER

prompt-queue

Queue prompts with /queue while Claude works, then run them one by one after each task and its background jobs finish.

guardcommandtoaststatusprompttimer
v0.1.1MITupdated 2026-10-21kuzmany/claude-code-prompt-queue
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 <text> park a prompt ⎿ prompt-queue: /queue list show parked prompts ⎿ prompt-queue: /queue run [n] send prompt n now (default 1) ⎿ prompt-queue: /queue drop <n> remove prompt n ⎿ prompt-queue: /queue clear remove all ⎿ prompt-queue: /queue unblock stop waiting on background jobs ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ prompt-queue: auto
README

Prompt Queue for Claude Code

Queue up your next prompts while Claude is still working. Run them later, by hand or automatically one by one.

Claude Code's own queue sends your message into the running task. This mod holds prompts back until you want them.

Install

At the Claude Code prompt:

/plugin install prompt-queue --marketplace kuzmany/claude-code-prompt-queue

Type y to add the marketplace, then pick a scope.

From a shell:

claude plugin install prompt-queue --marketplace kuzmany/claude-code-prompt-queue

In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with kuzmany/claude-code-prompt-queue, then Add plugin.

Use

CommandWhat it does
/queue <text>Park a prompt. Works while Claude is busy.
/queue listShow parked prompts.
/queue run [n]Send prompt n now (default 1).
/queue drop <n>Remove prompt n.
/queue clearRemove all prompts.
/queue-autoToggle auto mode.
/queue unblockStop waiting on background jobs.

Auto mode is off by default. When on, the next prompt starts only after the current task ends and every background job or agent it started has finished. Then the next, until the queue is empty. Auto stays on until you toggle it off. Pressing Esc turns it off.

The status line shows the state, for example 3 queued · auto · waiting on 1 bg.

The queue belongs to the current session.

If auto keeps waiting on a job that already ended (for example one a subagent started), run /queue unblock.

Example

> run the full test suite in the background
/queue fix the failing tests
/queue update the changelog
/queue-auto

Claude finishes the test run, then fixes the tests, then updates the changelog.

License

MIT

Source 2 files
hooks/register.ts 184 lines
1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4const items = atom({ plugin: 'prompt-queue', key: 'items' } as const, [])
5const auto = atom({ plugin: 'prompt-queue', key: 'auto' } as const, false)
6// Ids of background tasks and agents the main session started and still waits on.
7const waiting = atom({ plugin: 'prompt-queue', key: 'waiting' } as const, [])
8// Whether a main-loop turn is running; kept in state so a reload mid-turn remembers it.
9const busy = atom({ plugin: 'prompt-queue', key: 'busy' } as const, false)
10
11const BASH_STARTED = /running in background with ID: (\S+?)\.?\s/
12const AGENT_STARTED = /agentId: ([\w-]+)/
13// A finished job's notice; it stays inside its own <task-notification> block.
14const FINISHED = /<task-id>([^<]+)<\/task-id>(?:(?!<\/task-notification>)[\s\S])*?<status>(?!running)/g
15// A subagent's final report, sent as a message from that agent.
16const HANDED_BACK = /<agent-message from="([^"]+)">\s*\[Subagent hand-back\]/g
17// Job notices arrive on their own or inside another session's message, never in a prompt the user typed.
18const NOTICE_ORIGINS = ['task-notification', 'peer']
19
20const HELP = `/queue <text>     park a prompt
21/queue list       show parked prompts
22/queue run [n]    send prompt n now (default 1)
23/queue drop <n>   remove prompt n
24/queue clear      remove all
25/queue unblock    stop waiting on background jobs
26/queue-auto       toggle: run the queue after each task and its background jobs end`
27
28const showStatus = async ($: Engine) => {
29  const count = (await read($, items)).length
30  const jobs = (await read($, waiting)).length
31  const isAuto = await read($, auto)
32  const text = [
33    count && `${count} queued`,
34    isAuto && 'auto',
35    isAuto && count && jobs && `waiting on ${jobs} bg`,
36  ]
37    .filter(Boolean)
38    .join(' · ')
39  $.ui.status(text || undefined)
40}
41
42const setItems = async ($: Engine, fn: (list: string[]) => string[]) => {
43  await update($, items, fn)
44  await showStatus($)
45}
46
47const setWaiting = async ($: Engine, fn: (ids: string[]) => string[]) => {
48  await update($, waiting, fn)
49  await showStatus($)
50}
51
52// A hook cannot submit while it holds the turn; send once it has ended.
53const send = async ($: Engine, text: string) => {
54  await update($, busy, () => true)
55  $.clock.after(0, async () => {
56    try {
57      await $.prompt.submit({ text, asUser: true })
58    } catch {
59      // A failed send started no turn, so nothing would clear the flag.
60      await update($, busy, () => false)
61    }
62  })
63}
64
65// Auto sends the next prompt only when no turn runs and no background job is left.
66const sendIfIdle = async ($: Engine) => {
67  if ((await read($, busy)) || !(await read($, auto)) || (await read($, waiting)).length) return
68  const [first] = await read($, items)
69  if (first === undefined) return
70  await setItems($, list => list.slice(1))
71  await send($, first)
72}
73
74export const register: Register = on => {
75  on('session.start', async ($, e, next) => {
76    await $.command.register({
77      name: 'queue',
78      description: 'Park a prompt to run later (list, run, drop, clear)',
79      immediate: true,
80    })
81    await $.command.register({
82      name: 'queue-auto',
83      description: 'Toggle running the queue after each task and its background jobs end',
84      immediate: true,
85    })
86    await showStatus($)
87
88    return next(e)
89  })
90
91  on('turn.start', async ($, e, next) => {
92    if (e.agentId === undefined) await update($, busy, () => true)
93
94    return next(e)
95  })
96
97  on('tool.call', async ($, e, next) => {
98    const ran = await next(e)
99    // Only a background Bash call or an Agent call starts a job; other output may quote the words.
100    const pattern =
101      e.tool === 'Bash' && e.run_in_background ? BASH_STARTED : e.tool === 'Agent' ? AGENT_STARTED : undefined
102    const id = pattern && ran.text?.match(pattern)?.[1]
103    if (id) await setWaiting($, ids => [...ids.filter(one => one !== id), id])
104
105    return ran
106  })
107
108  on('prompt.submit', async ($, e, next) => {
109    if (NOTICE_ORIGINS.includes(e.origin.kind)) {
110      const ended = [...e.text.matchAll(FINISHED), ...e.text.matchAll(HANDED_BACK)].map(match => match[1])
111      if (ended.length) await setWaiting($, ids => ids.filter(one => !ended.includes(one)))
112    }
113
114    return next(e)
115  })
116
117  on('turn.complete', async ($, e, next) => {
118    const ended = await next(e)
119    if (e.agentId !== undefined) return ended
120    await update($, busy, () => false)
121
122    // Esc turns auto off instead of starting the next prompt.
123    if (e.isAborted && (await read($, auto))) {
124      await update($, auto, () => false)
125      await showStatus($)
126      $.ui.toast('Queue auto is off. /queue-auto turns it back on.')
127    } else {
128      await sendIfIdle($)
129    }
130
131    return ended
132  })
133
134  on('command.run', { command: 'queue-auto' }, async $ => {
135    const isOn = !(await read($, auto))
136    await update($, auto, () => isOn)
137    await showStatus($)
138    if (!isOn) return { text: 'Queue auto is off.' }
139    await sendIfIdle($)
140
141    return { text: 'Queue auto is on: prompts run after each task and its background jobs end.' }
142  })
143
144  on('command.run', { command: 'queue' }, async ($, e) => {
145    const args = e.args.trim()
146    // A subcommand is matched only in its exact form, so "/queue list the files" is queued.
147    const found = args.match(/^(help|list|clear|unblock)$|^(run|drop)(?:\s+(\d+))?$/)
148    const word = found?.[1] ?? found?.[2]
149    const arg = found?.[3] ?? ''
150    const list = await read($, items)
151    const n = Number(arg || '1')
152    const i = Number.isInteger(n) && n >= 1 && n <= list.length ? n - 1 : -1
153
154    if (args === '' || word === 'help') return { text: HELP }
155
156    if (word === 'list') {
157      return { text: list.map((t, j) => `${j + 1}. ${t}`).join('\n') || 'Queue is empty.' }
158    }
159
160    if (word === 'clear') {
161      await setItems($, () => [])
162      return { text: `Cleared ${list.length} prompt(s).` }
163    }
164
165    if (word === 'unblock') {
166      await setWaiting($, () => [])
167      await sendIfIdle($)
168      return { text: 'Stopped waiting on background jobs.' }
169    }
170
171    if (word === 'drop' || word === 'run') {
172      if (i < 0) return { text: `No prompt ${arg || '1'}. Queue has ${list.length}.` }
173      await setItems($, l => l.filter((_, j) => j !== i))
174      if (word === 'run') await send($, list[i])
175      return { text: `${word === 'run' ? 'Sending' : 'Dropped'}: ${list[i]}` }
176    }
177
178    await setItems($, l => [...l, args])
179    await sendIfIdle($)
180
181    return { text: `Queued #${list.length + 1}.` }
182  })
183}
184
types/index.d.ts 8 lines
1export type QueueItems = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'prompt-queue': { items: QueueItems; auto: boolean; waiting: string[]; busy: boolean }
6  }
7}
8