Keeps the prompts you send while Claude works in a queue, and sends them one by one once Claude has finished. Edit, reorder or remove any of them.

Keep typing while Claude works. Your prompts wait in a queue and go one at a time, each once Claude has finished the turn before it.

Claude Code already holds a prompt you send during a turn, but it hands that prompt to the running turn as soon as a tool call returns. Claude then reads it in the middle of its task. We asked Claude Haiku 4.5 on Claude Code 2.1.292 to run a build and then say "done", and sent "Say only: hello" during the build, three times. Twice, Claude answered "hello" and never said "done". Once, it said "Done." and never answered the message.

prompt-queue keeps the prompt until the turn has ended, then sends it as a turn of its own.
/queue opens the queue. Rewrite a prompt in place, move it up with ↑, remove it with ✕, or add one at the bottom. Nothing is sent while the pane is open./queue resume sends again.


| Command | Effect |
|---|---|
/queue | Open the queue |
/queue <prompt> | Add a prompt, sent at once if Claude is idle |
/queue pause | Hold the queue |
/queue resume | Send again |
/queue clear | Empty the queue |
In the pane, ↑ ↓ or Tab move between fields and Enter saves a prompt. Saving an empty prompt removes it. Outside a field, p pauses or resumes and c clears. Esc closes the pane.
@file mentions as plain text. In our test, Claude read the file with a tool call once the prompt was sent.The mod is one TypeScript module, hooks/register.ts. Claude Code calls its register(on) once, and each on(event, handler) hooks an engine event.
next: it passes the event on or answers it. prompt.submit answers { drop } to hold a prompt typed during a turn.turn.complete sends the next prompt with $.prompt.submit({ text, asUser: true }). A command or a button defers that call with $.clock.after(0), because submitting waits for the turn slot the command itself holds.ui.render hook returns Box, Text, Button and Input elements. Each prompt's field is keyed by its id, so an edit survives a redraw, and $.ui.invalidate asks for a redraw whenever the queue changes.claude plugin test runs 8 tests against the engine, with stubs and a mock clock. One covers a race found while filming the demo: a turn that ends while you edit the queue.claude plugin validate --strict plugins/prompt-queue
claude plugin test plugins/prompt-queue
claude --plugin-dir plugins/prompt-queue
Tested with Claude Code 2.1.292 on Linux.
hooks/register.ts 324 lines1// prompt-queue: holds the prompts you send while Claude works, then sends
2// them one at a time, each after Claude has finished the turn before it.
3//
4// On its own, Claude Code hands a prompt typed mid-turn to the running turn:
5// Claude reads it between two tool calls and may leave its task unfinished.
6
7import type { Engine, On } from 'claude-code'
8
9type Item = { id: number; text: string }
10
11const PANE = 'prompt-queue'
12// Wide enough to read a prompt in the pane docked beside the transcript
13const PANE_COLUMNS = 60
14// The band lists this many prompts, then sums up the rest
15const BAND_ROWS = 3
16// How a line break shows in a one-line field, and turns back on save
17const BREAK = ' ⏎ '
18
19let queue: Item[] = []
20let nextId = 1
21// True from a main-loop turn.start to its turn.complete
22let isWorking = false
23// Why the queue holds its prompts back, or null while it sends them
24let pauseReason: string | null = null
25// True while the /queue pane is open: a prompt sent then would leave the
26// list under the edit in progress, so nothing goes until the pane closes
27let isEditing = false
28
29function redraw($: Engine) {
30 $.ui.invalidate('ui.render')
31}
32
33function add($: Engine, text: string) {
34 queue = [...queue, { id: nextId++, text }]
35 redraw($)
36}
37
38// Submits the first prompt as if typed, unless a turn runs or the queue is paused
39function sendNext($: Engine) {
40 if (isWorking || isEditing || pauseReason || queue.length === 0) return
41 const [first, ...rest] = queue
42 queue = rest
43 // turn.start sets this too; setting it now keeps a second call from
44 // sending another prompt before that turn has started
45 isWorking = true
46 redraw($)
47 // Resolves only once the turn starts, so no hook waits on it
48 $.prompt.submit({ text: first.text, asUser: true }).catch((error: unknown) => {
49 queue = [first, ...queue]
50 isWorking = false
51 pauseReason = 'the last prompt could not be sent'
52 $.ui.log('prompt-queue could not send a prompt: ' + String(error))
53 redraw($)
54 })
55}
56
57// A command, a press or a field holds the turn slot that submitting waits on,
58// so they send from a timer instead, once their own event has finished
59function sendSoon($: Engine) {
60 $.clock.after(0, () => sendNext($))
61}
62
63const oneLine = (text: string) => text.replace(/\r?\n/g, BREAK)
64
65function title() {
66 if (queue.length === 0) return pauseReason ? 'Queue paused: ' + pauseReason : 'No prompts waiting'
67 const count = queue.length === 1 ? '1 prompt waiting' : queue.length + ' prompts waiting'
68 return pauseReason ? 'Queue paused: ' + pauseReason + ' · ' + count : 'Queue · ' + count
69}
70
71function hint() {
72 if (pauseReason) return '/queue resume sends again'
73 return isEditing ? 'held while the /queue pane is open' : 'sent one at a time when Claude finishes · /queue to edit'
74}
75
76function paneHint() {
77 return pauseReason ? 'Held until you press p or run /queue resume' : 'Nothing is sent while this pane is open'
78}
79
80const accent = () => (pauseReason ? 'warning' : 'suggestion')
81
82export function register(on: On) {
83 on('session.start', async ($, e, next) => {
84 try {
85 await $.command.register({
86 name: 'queue',
87 description: 'Show and edit the prompts waiting for Claude to finish',
88 argumentHint: '[prompt | pause | resume | clear]',
89 immediate: true,
90 })
91 } catch (error) {
92 $.ui.log('prompt-queue could not add /queue: ' + String(error))
93 }
94 return next(e)
95 })
96
97 on('turn.start', async ($, e, next) => {
98 isWorking = true
99 redraw($)
100 return next(e)
101 })
102
103 on('turn.complete', async ($, e, next) => {
104 // A subagent's turn ends inside the main one, which keeps running
105 if (e.agentId) return next(e)
106 isWorking = false
107 // After an interruption or an error, sending on would surprise you
108 if (e.reason !== 'answer' && queue.length > 0) {
109 pauseReason = e.isAborted ? 'you interrupted Claude' : 'the last turn ended with ' + (e.reason === 'refusal' ? 'a refusal' : 'an error')
110 }
111 sendNext($)
112 redraw($)
113 return next(e)
114 })
115
116 on('prompt.submit', async ($, e, next) => {
117 // Only what you send during a turn waits, from the prompt box or from your
118 // phone through Remote Control; notifications and other senders go through
119 const isYours = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
120 if (!e.turnId || !isYours) return next(e)
121 const text = e.text.trim()
122 // A command acts at once, and pasted images would be lost on the way
123 if (!text || text.startsWith('/') || (e.attachments?.length ?? 0) > 0) return next(e)
124 add($, e.text)
125 return { drop: 'queued as #' + queue.length + ', sent when Claude finishes' }
126 }).catch(async ($, e, next) => {
127 // If the queue fails, the prompt reaches Claude the usual way rather than vanish
128 return next(e)
129 })
130
131 on('command.run', { command: 'queue' }, async ($, e) => {
132 const args = e.args.trim()
133 const word = args.toLowerCase()
134 if (word === '') {
135 await $.ui.open({
136 id: PANE,
137 title: 'Prompt queue',
138 focus: true,
139 closeOnEscape: true,
140 rows: Math.min(9 + Math.max(queue.length, 1), 20),
141 columns: PANE_COLUMNS,
142 })
143 isEditing = true
144 redraw($)
145 } else if (word === 'pause') {
146 pauseReason = 'you paused it'
147 redraw($)
148 } else if (word === 'resume') {
149 pauseReason = null
150 sendSoon($)
151 redraw($)
152 } else if (word === 'clear') {
153 queue = []
154 pauseReason = null
155 redraw($)
156 } else {
157 // /queue followed by a prompt adds it, and sends it now if Claude is idle
158 add($, args)
159 sendSoon($)
160 }
161 return {}
162 })
163
164 on('ui.close', async ($, e, next) => {
165 if (e.id === PANE) {
166 isEditing = false
167 sendSoon($)
168 redraw($)
169 }
170 return next(e)
171 })
172
173 // The band above the prompt: what waits, while something waits
174 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
175 if (queue.length === 0) return next(e)
176 const { Box, Text } = $.ui.resolve(e)
177 const rows = queue.slice(0, BAND_ROWS).map((item, i) =>
178 Box({
179 key: 'band-' + item.id,
180 flexDirection: 'row',
181 columnGap: 1,
182 children: [
183 Text({ bold: true, color: accent(), children: [String(i + 1)] }),
184 Text({ wrap: 'truncate-end', children: [oneLine(item.text)] }),
185 ],
186 }),
187 )
188 const more = queue.length - BAND_ROWS
189 const ours = Box({
190 borderStyle: 'round',
191 borderColor: accent(),
192 paddingX: 1,
193 flexDirection: 'column',
194 children: [
195 // One line, cut at the edge rather than wrapped in a narrow window
196 Box({
197 flexDirection: 'row',
198 children: [
199 Text({ bold: true, color: accent(), wrap: 'truncate-end', children: [title()] }),
200 Text({ color: 'subtle', wrap: 'truncate-end', children: [' · ' + hint()] }),
201 ],
202 }),
203 ...rows,
204 ...(more > 0 ? [Text({ color: 'subtle', children: ['+' + more + ' more in /queue'] })] : []),
205 ],
206 })
207 // Keep what the mods after this one draw in the band
208 const theirs = await next(e)
209 return theirs ? Box({ flexDirection: 'column', children: [ours, theirs] }) : ours
210 })
211
212 // The pane /queue opens: every waiting prompt, editable in place
213 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
214 if (e.requestId !== PANE) return next(e)
215 const { Box, Text, Button, Input } = $.ui.resolve(e)
216 const controls = Box({
217 flexDirection: 'row',
218 columnGap: 2,
219 children: [
220 Button({
221 key: 'toggle',
222 label: pauseReason ? 'Resume' : 'Pause',
223 hotkey: 'p',
224 plain: true,
225 onPress: async () => {
226 pauseReason = pauseReason ? null : 'you paused it'
227 sendSoon($)
228 redraw($)
229 },
230 }),
231 Button({
232 key: 'clear',
233 label: 'Clear',
234 hotkey: 'c',
235 plain: true,
236 onPress: async () => {
237 queue = []
238 redraw($)
239 },
240 }),
241 ],
242 })
243 const rows = queue.map((item, i) =>
244 Box({
245 key: 'row-' + item.id,
246 flexDirection: 'row',
247 columnGap: 1,
248 children: [
249 Text({ bold: true, color: accent(), children: [String(i + 1)] }),
250 Box({
251 flexGrow: 1,
252 children: [
253 Input({
254 key: 'edit-' + item.id,
255 value: oneLine(item.text),
256 // Only the return symbol, so a long prompt keeps its width
257 submitLabel: '',
258 // An emptied field removes the prompt
259 onSubmit: async (value: string) => {
260 const text = value.split(BREAK).join('\n').trim()
261 queue = text ? queue.map((it) => (it.id === item.id ? { ...it, text } : it)) : queue.filter((it) => it.id !== item.id)
262 redraw($)
263 },
264 }),
265 ],
266 }),
267 // The first prompt cannot move up: a blank keeps the columns aligned
268 i === 0
269 ? Text({ children: [' '] })
270 : Button({
271 key: 'up-' + item.id,
272 label: '↑',
273 plain: true,
274 onPress: async () => {
275 const at = queue.findIndex((it) => it.id === item.id)
276 if (at > 0) {
277 const moved = [...queue]
278 ;[moved[at - 1], moved[at]] = [moved[at], moved[at - 1]]
279 queue = moved
280 }
281 redraw($)
282 },
283 }),
284 Button({
285 key: 'remove-' + item.id,
286 label: '✕',
287 plain: true,
288 onPress: async () => {
289 queue = queue.filter((it) => it.id !== item.id)
290 redraw($)
291 },
292 }),
293 ],
294 }),
295 )
296 return Box({
297 flexDirection: 'column',
298 rowGap: 1,
299 children: [
300 Box({
301 flexDirection: 'column',
302 children: [Text({ bold: true, color: accent(), children: [title()] }), Text({ color: 'subtle', children: [paneHint()] })],
303 }),
304 controls,
305 ...(rows.length > 0 ? [Box({ flexDirection: 'column', children: rows })] : [Text({ color: 'subtle', children: ['Nothing waiting. What you send while Claude works will wait here.'] })]),
306 Input({
307 key: 'add',
308 label: 'Add',
309 placeholder: 'Type a prompt and press Enter',
310 value: '',
311 submitLabel: 'add',
312 autoFocus: true,
313 onSubmit: async (value: string) => {
314 if (!value.trim()) return
315 add($, value.split(BREAK).join('\n').trim())
316 sendSoon($)
317 },
318 }),
319 Text({ color: 'subtle', children: ['↑ ↓ or Tab to move · Enter saves · Esc closes'] }),
320 ],
321 })
322 })
323}
324