Type >> on and messages you send while Claude works wait until the current task is finished, in a queue above the prompt you can reorder, cancel or clear; >>…

>> onand what you send while Claude works waits until the task is finished, in a queue above the prompt you can reorder, cancel or clear./now <message>stops the task and sends the message at once.>> offand everything is back to normal. The>>lines never reach Claude's history.
In Claude Code, a message you send while Claude is working reaches it as soon as it has a moment: it steers the task in progress. That is often what you want, but not always:
prompt-queue adds a queue for the first case and an instant interrupt for the second. Nothing changes until you ask: every session starts with Claude Code's normal behaviour.
/plugin marketplace add GiuseppeBellamacina/cosmic-plugins
/plugin install prompt-queue@cosmic-plugins
Where /plugin isn't available (e.g. the VS Code extension), run these in a terminal, then /reload-plugins in the session:
claude plugin marketplace add GiuseppeBellamacina/cosmic-plugins
claude plugin install prompt-queue@cosmic-plugins
Requires a Claude Code build with plugin function hooks (mods). Nothing else to install.
Type these as a normal message. A line starting with >> is taken by the plugin and thrown away before it reaches Claude, so it leaves nothing in Claude's history (a slash command would always leave a row there).
| You type | What it does |
|---|---|
>> on | Turns the queue on: from now on, what you send while Claude works waits in the queue. Claude knows nothing about it until it has finished everything it was doing; then the first queued message is sent automatically, then the next, one per finished task. |
>> <message> | Turns the queue on (if it was off) and adds the message to it, while Claude works or while it is idle. |
>> off | Back to Claude Code's normal behaviour (messages sent while Claude works steer the running task). Messages already queued still go out one per finished task, or Clear them. |
/now <message> | Stops the running task at once and sends the message. Whatever was queued waits until this new task is finished. While Claude is idle there is nothing to stop, so it puts the text back in the prompt box and one Enter sends it. |
The chat confirms each >> line (Prompt dropped by a hook: Queue on, Queued as #2, ...). Every new session starts with the queue off.
While the queue is on, or something is still queued, a band sits above the prompt:
── prompt-queue ─────────────────────────────────────────────────────────────
Queue on · 2 queued Clear
1. also update the changelog when you're done ↑ ↓ ✕
2. then open a PR ↑ ↓ ✕
>> line too). Your message shows in the transcript at once, with a Prompt dropped by a hook: Queued as #n line once it is queued; Claude never sees it before. While Claude is idle a >> line acts at once, and /now always does.@file mentions in a queued message reach Claude as text: the file is not attached when the message is sent later./reload-plugins, not a restart.One hooks module (hooks/register.tsx): a prompt.submit hook takes the >> lines, and with the queue on keeps the messages typed during a turn ({ drop }); /now stops the turn with $.turn.abort; a turn.complete hook sends the next message with $.prompt.submit; a ui.render hook on AbovePrompt draws the band. Tests are in tests/ (claude plugin test .).
hooks/register.tsx 165 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Queued } from '../types'
5
6const queue = atom({ plugin: 'prompt-queue', key: 'queue' } as const, [])
7const isOn = atom({ plugin: 'prompt-queue', key: 'isOn' } as const, false)
8const running = atom({ plugin: 'prompt-queue', key: 'running' } as const, null)
9const held = atom({ plugin: 'prompt-queue', key: 'held' } as const, false)
10const now = atom({ plugin: 'prompt-queue', key: 'now' } as const, null)
11
12const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
13
14function move(list: readonly Queued[], id: string, by: number): Queued[] {
15 const i = list.findIndex(q => q.id === id)
16 const j = i + by
17 if (i < 0 || j < 0 || j >= list.length) return [...list]
18 const copy = [...list]
19 ;[copy[i], copy[j]] = [copy[j]!, copy[i]!]
20 return copy
21}
22
23// asUser: the model reads it as the person's own words, not as a plugin's message.
24const send = ($: EngineInterface, text: string) => void $.prompt.submit({ text, asUser: true })
25
26async function enqueue($: EngineInterface, text: string): Promise<number> {
27 const id = `${await $.clock.now()}-${Math.random().toString(36).slice(2, 8)}`
28 return (await update($, queue, list => [...list, { id, text }])).length
29}
30
31async function sendNext($: EngineInterface) {
32 let next: Queued | undefined
33 await update($, queue, ([first, ...rest]) => ((next = first), rest))
34 await update($, held, () => false)
35 if (next) send($, next.text)
36}
37
38// Stops the running turn and keeps `text` for its turn.complete, the first point a prompt can be submitted;
39// false when there was no turn to stop (it had just ended).
40async function interrupt($: EngineInterface, turnId: string | null, text: string): Promise<boolean> {
41 if (turnId === null) return false
42 await update($, now, () => text)
43 const stopped = await $.turn.abort({ turnId }).then(() => true, () => false)
44 if (!stopped) await update($, now, () => null)
45 return stopped
46}
47
48// A command hook cannot submit a prompt itself, so while Claude is idle the text goes back to the box for one Enter.
49async function backToBox($: EngineInterface, text: string) {
50 const { isFilled } = await $.prompt.fill({ text })
51 $.ui.toast(isFilled ? 'Claude is idle: press Enter to send it.' : 'Claude is idle: send the message normally.')
52}
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 await $.command.register({
57 name: 'now',
58 description: 'Interrupt Claude and send this message right away',
59 argumentHint: '<message>',
60 immediate: true,
61 })
62 return next(e)
63 })
64
65 on('turn.start', async ($, e, next) => {
66 if (e.agentId === undefined) await update($, running, () => e.turnId)
67 return next(e)
68 })
69
70 on('turn.complete', async ($, e, next) => {
71 if (e.agentId !== undefined) return next(e)
72 await update($, running, () => null)
73 const interrupting = await read($, now)
74 if (interrupting !== null) {
75 // The queue waits for the interrupting prompt's own turn to finish.
76 await update($, now, () => null)
77 send($, interrupting)
78 } else if (e.reason === 'answer') await sendNext($)
79 else if ((await read($, queue)).length > 0) await update($, held, () => true)
80 return next(e)
81 })
82
83 // `>> on`, `>> off` and `>> <message>` drive the queue; dropped here, they never reach the model's history.
84 // With the queue on, other prompts typed while a turn runs wait too. Slash commands and machine-made prompts pass.
85 on('prompt.submit', async ($, e, next) => {
86 const typed = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
87 if (!typed || e.command !== undefined || e.isMeta) return next(e)
88 const control = /^>>\s*([\s\S]*)$/.exec(e.text.trim())
89 if (control === null && (e.turnId === undefined || !(await read($, isOn)))) return next(e)
90 if (e.attachments?.length) {
91 // shortcut: a plugin cannot re-send images or files, so these stay with Claude Code's own handling.
92 $.ui.toast('Message with attachments: left to Claude Code (prompt-queue cannot hold attachments).')
93 return next(e)
94 }
95
96 const arg = control === null ? e.text : control[1]!.trim()
97 if (control !== null && arg.toLowerCase() === 'off') {
98 await update($, isOn, () => false)
99 const left = (await read($, queue)).length
100 return { drop: left > 0 ? `Queue off. ${left} queued message(s) still go out one per finished task, or Clear them.` : 'Queue off.' }
101 }
102 await update($, isOn, () => true)
103 if (control !== null && (arg === '' || arg.toLowerCase() === 'on')) {
104 return { drop: 'Queue on: what you send while Claude works waits until the current task is finished.' }
105 }
106 const n = await enqueue($, arg)
107 return { drop: `Queued as #${n}: Claude gets it when the current task is finished.` }
108 })
109
110 on('command.run', { command: 'now' }, async ($, e) => {
111 const text = e.args.trim()
112 if (text === '') $.ui.toast('Usage: /now <message>')
113 else if (!(await interrupt($, await read($, running), text))) await backToBox($, text)
114 return {}
115 })
116
117 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
118 if (e.props.hasSurvey) return next(e)
119 const list = await read($, queue)
120 const active = await read($, isOn)
121 if (!active && list.length === 0) return next(e)
122 // Other mods share this band: theirs is drawn below ours.
123 const below = await next(e).catch(() => undefined)
124 const isHeld = await read($, held)
125 const { Box, Text, Button } = $.ui.resolve(e)
126 const edit = (fn: (l: Queued[]) => Queued[]) => update($, queue, l => fn([...l]))
127
128 const status = active
129 ?`Queue on · ${list.length} queued${isHeld ? ', paused after an interruption' : ''}`
130 : `Queue off · ${list.length} left, sent one per finished task`
131
132 const mine = (
133 <Box flexDirection="column" width={e.props.bodyColumns}>
134 {/* Sets the band apart from other plugins' bands drawn next to it. */}
135 <Text dimColor wrap="truncate-end">{`── prompt-queue ${'─'.repeat(e.props.bodyColumns)}`}</Text>
136 <Box flexDirection="row" alignItems="center" gap={1}>
137 <Text color={isHeld ? 'warning' : undefined}>{status}</Text>
138 <Box flexGrow={1} />
139 {list.length > 0 && !e.props.isWorking && <Button key="send-next" plain label="Send next" onPress={() => sendNext($)} />}
140 {list.length > 0 && <Button key="clear" plain label="Clear" onPress={() => edit(() => [])} />}
141 </Box>
142 {list.map((q, i) => (
143 <Box key={`row-${q.id}`} flexDirection="row" gap={1}>
144 <Text dimColor>{i + 1}.</Text>
145 <Box flexGrow={1}>
146 <Text wrap="truncate-end">{oneLine(q.text)}</Text>
147 </Box>
148 <Button key={`up-${q.id}`} plain label="↑" onPress={() => edit(l => move(l, q.id, -1))} />
149 <Button key={`down-${q.id}`} plain label="↓" onPress={() => edit(l => move(l, q.id, 1))} />
150 <Button key={`drop-${q.id}`} plain label="✕" onPress={() => edit(l => l.filter(x => x.id !== q.id))} />
151 </Box>
152 ))}
153 </Box>
154 )
155 return below ? (
156 <Box flexDirection="column">
157 {mine}
158 {below}
159 </Box>
160 ) : (
161 mine
162 )
163 })
164}
165types/index.d.ts 19 lines1/** A prompt held back until the running turn ends. */
2export type Queued = { id: string; text: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'prompt-queue': {
7 queue: Queued[]
8 /** `>> on`: prompts typed while Claude works are queued instead of steering it. */
9 isOn: boolean
10 /** The main loop's running turn, null while idle. */
11 running: string | null
12 /** True after an interrupted or failed turn: the queue waits for a turn that ends normally, or "Send next". */
13 held: boolean
14 /** The prompt that interrupted the running turn, sent once that turn has ended. */
15 now: string | null
16 }
17 }
18}
19