SLOPSHOPPER

wait-queue

/q <prompt> holds prompts until the running turn ends and sends them one per turn; a band above the prompt lists, edits, reorders and pauses them

newbandrowscommandtoaststatus
v0.5.0no licenseupdated 2026-10-04gergesh/claude-queue-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wait-queue
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ wait-queue │ ⏺ Read(src/auth.ts) │ Usage: /q <prompt>. The queue panel above │ ⎿ Read 6 lines │ the prompt manages what is queued. │ ⏺ 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 › /q ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-queue-mod

A Claude Code mod that queues prompts until Claude's running turn ends.

When you send a message while Claude is working, Claude Code delivers it into the running turn, between tool calls. Sometimes you want the opposite: a follow-up that waits until Claude has finished, then goes out as its own turn.

/q now write tests for what you just changed

What it does

  • /q <prompt>, or Tab once bound (below), queues a prompt. While a turn is running it confirms in a toast (Queued: 2 waiting.); while idle it sends straight away. /q leaves nothing in the transcript.
  • Queued prompts go out one per turn, in order, each as your own message. Only one goes at a time, and one Claude Code refuses goes back to the front of the queue with a toast.
  • A band above the prompt lists the queue while it has anything in it, with Pause / Resume and Clear.
  • Steer: Enter on a queued prompt sends it into the turn Claude is running now, read at its next step. A "↪ steering" line turns into "✓ read" once the model has it; if the turn ends first, a short "continue" prompt follows so the steer is not lost. While idle, Enter sends the prompt as a turn of its own.
  • Editing a prompt pauses sending, so a half-edited prompt never goes out.
  • Interrupting a turn (Esc) pauses the queue rather than sending into the interruption, with a toast saying so. Resume from the band.

Keys

Mods cannot see keys typed in the prompt box, so the two shortcuts come from Claude Code's own actions. Add them to ~/.claude/keybindings.json:

{
  "bindings": [
    {
      "context": "Chat",
      "bindings": {
        "tab": "chat:queueSubmit",
        "alt+up": "abovePrompt:focus"
      }
    },
    {
      "context": "Autocomplete",
      "bindings": {
        "tab": "autocomplete:accept"
      }
    }
  ]
}
  • Tab submits the prompt with "wait for this turn", which the mod holds. The Autocomplete entry keeps Tab accepting file, command and skill suggestions while their menu is open: without it, a Chat binding for Tab wins over the built-in autocomplete one and submits the prompt. The dim suggestion in an empty prompt box is taken with → instead: Claude Code drops an empty submit before any mod sees it, so Tab cannot do both.
  • alt+↑ (or the default ctrl+x tab) moves into the band, with the focus on the newest queued prompt. alt+↑ is also /diff's file-list key, which this binding takes over while the prompt box has focus.

In the band:

KeyDoes
↑ / ↓move to the prompt before / after
Entersteer the focused prompt into the running turn (send it, while idle)
eedit the focused prompt (Enter saves; saving it empty removes it)
k / jmove the focused prompt up / down the queue
xremove the focused prompt
ppause / resume sending
Escback to the prompt box

Install

From inside Claude Code:

/plugin marketplace add gergesh/claude-queue-mod
/plugin install wait-queue@claude-queue-mod

Or run it from a checkout without installing:

git clone https://github.com/gergesh/claude-queue-mod
claude --plugin-dir ./claude-queue-mod

Mods (function-hook plugins) are an early-access Claude Code feature; the API may change between releases.

Develop

claude plugin validate .   # what the engine would load or refuse
claude plugin test .       # hooks/register.test.ts against the engine
tsc -p .                   # after Claude Code has loaded the mod once

Claude Code writes the API's type declarations into .claude-plugin/types/ each time it loads the mod, and tsconfig.json extends the config it writes there, so type-checking works once the mod has been loaded (with --plugin-dir, or as an installed plugin). That folder is not committed.

  • hooks/register.tsx: the mod
  • hooks/register.test.ts: its tests, on the terminal and desktop surfaces
  • types/index.d.ts: the session state the mod keeps ($.state)
Source 2 files
hooks/register.tsx 359 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { QueueItem } from '../types'
5
6// The engine delivers a prompt typed mid-turn into that turn even when the
7// person asked it to wait (`e.wait`, chat:queueSubmit). This mod holds such
8// prompts, and those given to `/q <prompt>`, sends them one per turn once the
9// session is idle, and lists them in a band above the prompt to steer into the
10// running turn, edit, reorder, remove or pause.
11
12const items = atom({ plugin: 'wait-queue', key: 'items' } as const, [])
13const editingId = atom({ plugin: 'wait-queue', key: 'editingId' } as const, null)
14const isPaused = atom({ plugin: 'wait-queue', key: 'isPaused' } as const, false)
15const steered = atom({ plugin: 'wait-queue', key: 'steered' } as const, [])
16const runningTurnId = atom({ plugin: 'wait-queue', key: 'runningTurnId' } as const, null)
17const isReleasing = atom({ plugin: 'wait-queue', key: 'isReleasing' } as const, false)
18
19const ROW = 'row-'
20const EDIT = 'edit-'
21const STEER_FRAME = '[The user sent this while you were working. Apply it to the current task now.]'
22const STEER_NUDGE = 'Continue the task, applying the instruction I just sent.'
23
24function isQueueCommand(text: string): boolean {
25  return /^\/q(\s|$)/.test(text.trim())
26}
27
28function truncate(text: string, width: number): string {
29  const line = text.replace(/\s+/g, ' ')
30  return line.length <= width ? line : `${line.slice(0, width - 1)}…`
31}
32
33function moveItem(list: QueueItem[], id: string, offset: -1 | 1): QueueItem[] {
34  const from = list.findIndex(item => item.id === id)
35  const item = list[from]
36  const to = from + offset
37  if (item === undefined || to < 0 || to >= list.length) return list
38  const rest = list.filter(other => other.id !== id)
39  return [...rest.slice(0, to), item, ...rest.slice(to)]
40}
41
42function showCount($: EngineInterface, count: number) {
43  $.ui.status(count === 0 ? undefined : `${count} queued`)
44}
45
46async function enqueue($: EngineInterface, text: string): Promise<number> {
47  const item: QueueItem = { id: crypto.randomUUID(), text }
48  const next = await update($, items, list => [...list, item])
49  showCount($, next.length)
50  return next.length
51}
52
53async function take($: EngineInterface, id: string): Promise<QueueItem | undefined> {
54  const item = (await read($, items)).find(one => one.id === id)
55  if (item === undefined) return undefined
56  const rest = await update($, items, list => list.filter(one => one.id !== id))
57  showCount($, rest.length)
58  return item
59}
60
61// Puts a prompt that did not get through back at the front of the queue.
62async function restore($: EngineInterface, item: QueueItem, reason: string) {
63  await update($, isReleasing, () => false)
64  const list = await update($, items, current => [item, ...current.filter(one => one.id !== item.id)])
65  showCount($, list.length)
66  $.ui.toast(`${reason}; it is back at the front of the queue.`)
67}
68
69// Sends a prompt as the person's own; one the engine refuses goes back in the queue.
70function submit($: EngineInterface, item: QueueItem) {
71  void $.prompt
72    .submit({ text: item.text, asUser: true })
73    .then(result => (result.drop === undefined ? undefined : restore($, item, `Not sent (${result.drop})`)))
74    .catch(() => restore($, item, 'Could not send'))
75}
76
77// Sends the head of the queue, unless the queue is paused, a prompt in it is
78// being edited, or one already went and its turn has not started.
79async function sendNext($: EngineInterface) {
80  if ((await read($, isPaused)) || (await read($, editingId)) !== null || (await read($, isReleasing))) return
81  const [first] = await read($, items)
82  if (first === undefined) return
83
84  await update($, isReleasing, () => true)
85  const item = await take($, first.id)
86  if (item !== undefined) submit($, item)
87}
88
89// Sends one queued prompt into the running turn, read at the model's next step;
90// while idle it goes as a turn of its own.
91async function steer($: EngineInterface, id: string) {
92  const isWorking = (await read($, runningTurnId)) !== null
93  const item = await take($, id)
94  if (item === undefined) return
95  if (!isWorking) {
96    submit($, item)
97    return
98  }
99
100  const appended = await $.session
101    .append({ message: { type: 'user', content: [{ type: 'text', text: `${STEER_FRAME}\n\n${item.text}` }] } })
102    .catch((error: unknown) => ({ deny: error instanceof Error ? error.message : String(error) }))
103  if (appended.deny !== undefined) {
104    await restore($, item, `Not steered (${appended.deny})`)
105    return
106  }
107
108  await update($, steered, list => [...list, { id: item.id, text: item.text, status: 'pending' as const }])
109  $.ui.toast('Steered into the current turn.')
110}
111
112export const register: Register = on => {
113  on('session.start', async ($, e, next) => {
114    // A reload or a new process starts with nothing in flight.
115    await update($, isReleasing, () => false)
116    await $.command.register({
117      name: 'q',
118      description: 'Queue a prompt to send after the current turn ends',
119      argumentHint: '<prompt>',
120      immediate: true,
121    })
122    return next(e)
123  })
124
125  on('command.run', { command: 'q' }, async ($, e) => {
126    // Answers in a toast, not text: /q leaves nothing in the transcript (see the
127    // CommandOutput and UserMessage hooks below).
128    const text = e.args.trim()
129    if (text === '') {
130      $.ui.toast('Usage: /q <prompt>. The queue panel above the prompt manages what is queued.')
131      return {}
132    }
133
134    const count = await enqueue($, text)
135    if ((await read($, runningTurnId)) === null) {
136      // A submit can't start inside this hook (it would wait on the command's own
137      // dispatch), so it goes out on the clock's next tick, once the command is done.
138      $.clock.after(0, () => void sendNext($))
139      $.ui.toast('Sending now.')
140      return {}
141    }
142    $.ui.toast(`Queued: ${count} waiting.`)
143    return {}
144  })
145
146  on('prompt.submit', async ($, e, next) => {
147    const isWaitingRequest =
148      e.wait && e.turnId !== undefined && e.origin.kind === 'composer' && e.attachments === undefined
149    if (!isWaitingRequest) return next(e)
150
151    const count = await enqueue($, e.text)
152    return { drop: `Queued (${count} waiting).` }
153  })
154
155  on('turn.start', async ($, e, next) => {
156    const result = await next(e)
157    await update($, runningTurnId, () => result.turnId)
158    await update($, isReleasing, () => false)
159    return result
160  })
161
162  on('turn.step', async function* ($, e, next) {
163    // The step after a steer carries it to the model.
164    if (e.turnId === (await read($, runningTurnId)) && (await read($, steered)).some(one => one.status === 'pending')) {
165      await update($, steered, list => list.map(one => ({ ...one, status: 'read' as const })))
166    }
167    return yield* next(e)
168  })
169
170  on('turn.complete', async ($, e, next) => {
171    const result = await next(e)
172    if (e.agentId !== undefined) return result
173    if (e.turnId === (await read($, runningTurnId))) await update($, runningTurnId, () => null)
174
175    const hasUnreadSteer = (await read($, steered)).some(one => one.status === 'pending')
176    await update($, steered, () => [])
177
178    if (e.isAborted) {
179      // An interrupted turn pauses the queue; the person resumes or edits it in the band.
180      const waiting = (await read($, items)).length
181      if (waiting > 0) {
182        await update($, isPaused, () => true)
183        $.ui.toast(`Queue paused: turn interrupted (${waiting} waiting). Resume it from the panel.`)
184      }
185      return result
186    }
187
188    if (hasUnreadSteer) {
189      // The turn ended before the model read the steer: ask it to pick it up
190      // first; the queue goes on after that turn.
191      void $.prompt.submit({ text: STEER_NUDGE, asUser: true }).catch(() => $.ui.toast('Could not ask Claude to continue.'))
192      return result
193    }
194
195    await sendNext($)
196    return result
197  })
198
199  // /q runs are kept out of the transcript's drawing (the model still reads them).
200  on('ui.render', { component: 'CommandOutput' }, ($, e, next) =>
201    e.props.command === 'q' ? $.ui.resolve(e).Box({}) : next(e),
202  )
203
204  on('ui.render', { component: 'UserMessage' }, ($, e, next) =>
205    isQueueCommand(e.props.text) ? $.ui.resolve(e).Box({}) : next(e),
206  )
207
208  // The row the band's focus ring is on, so e / k / j / x act on it.
209  let focusedId: string | undefined
210
211  on('ui.focus', { component: 'AbovePrompt' }, ($, e, next) => {
212    if (e.plugin === $.plugin.name && e.element?.startsWith(ROW) === true) focusedId = e.element.slice(ROW.length)
213    return next(e)
214  })
215
216  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
217    const list = await read($, items)
218    const steering = await read($, steered)
219    if (e.props.hasSurvey || (list.length === 0 && steering.length === 0)) return next(e)
220
221    const paused = await read($, isPaused)
222    const editing = await read($, editingId)
223    const elements = $.ui.resolve(e)
224    const { Box, Button, Text } = elements
225    // A surface without text fields lists, steers, reorders and removes, but does not edit.
226    const Input = 'Input' in elements ? elements.Input : undefined
227
228    // Sending resumes on its own at the next turn's end; while idle it is nudged here.
229    const resumeIfIdle = async () => {
230      if ((await read($, runningTurnId)) === null) await sendNext($)
231    }
232    // Read at press time: moving the focus ring does not redraw the band.
233    const focusedRow = async () => {
234      const current = await read($, items)
235      return (current.find(item => item.id === focusedId) ?? current[current.length - 1])?.id
236    }
237    const moveFocused = async (offset: -1 | 1) => {
238      const id = await focusedRow()
239      if (id !== undefined) await update($, items, current => moveItem(current, id, offset))
240    }
241    const state = paused ? 'paused' : editing !== null ? 'paused while editing' : 'sends after this turn'
242    const width = Math.max(20, e.props.bodyColumns - 8)
243
244    return (
245      <Box flexDirection="column">
246        {steering.length > 0 && (
247          <Box key="steering" flexDirection="column">
248            {steering.map(one => (
249              <Text key={`steered-${one.id}`} color="cyan" wrap="truncate-end">
250                {one.status === 'pending' ? '↪ steering' : '✓ read'} · {truncate(one.text, width - 12)}
251              </Text>
252            ))}
253          </Box>
254        )}
255        {list.length > 0 && (
256          <Box flexDirection="row" gap={1}>
257            <Text bold>Queue</Text>
258            <Text dimColor>
259              {list.length} waiting · {state}
260            </Text>
261            <Button
262              key="pause"
263              label={paused ? 'Resume' : 'Pause'}
264              hotkey="p"
265              plain
266              dimColor
267              onPress={async () => {
268                await update($, isPaused, value => !value)
269                if (paused) await resumeIfIdle()
270              }}
271            />
272            <Button
273              key="clear"
274              label="Clear"
275              dimColor
276              onPress={async () => {
277                await update($, editingId, () => null)
278                await update($, items, () => [])
279                showCount($, 0)
280              }}
281            />
282          </Box>
283        )}
284        {list.map((item, index) =>
285          item.id === editing && Input !== undefined ? (
286            <Box key={`editing-${item.id}`} flexDirection="row" gap={1}>
287              <Input
288                key={`${EDIT}${item.id}`}
289                label={`${index + 1}.`}
290                value={item.text}
291                submitLabel="save"
292                autoFocus
293                onSubmit={async (value: string) => {
294                  const text = value.trim()
295                  await update($, items, current =>
296                    text === ''
297                      ? current.filter(one => one.id !== item.id)
298                      : current.map(one => (one.id === item.id ? { ...one, text } : one)),
299                  )
300                  await update($, editingId, () => null)
301                  await resumeIfIdle()
302                }}
303              />
304              <Button
305                key={`cancel-${item.id}`}
306                label="Cancel"
307                dimColor
308                onPress={async () => {
309                  await update($, editingId, () => null)
310                  await resumeIfIdle()
311                }}
312              />
313            </Box>
314          ) : (
315            <Button
316              key={`${ROW}${item.id}`}
317              label={truncate(`${index + 1}. ${item.text}`, width)}
318              plain
319              autoFocus={editing === null && index === list.length - 1 ? true : undefined}
320              onPress={() => steer($, item.id)}
321            />
322          ),
323        )}
324        {list.length > 0 && editing === null && (
325          <Box flexDirection="row" gap={2}>
326            <Text dimColor>enter: {e.props.isWorking ? 'steer now' : 'send now'}</Text>
327            {Input !== undefined && (
328              <Button
329                key="edit"
330                label="edit"
331                hotkey="e"
332                plain
333                dimColor
334                onPress={async () => {
335                  const id = await focusedRow()
336                  if (id !== undefined) await update($, editingId, () => id)
337                }}
338              />
339            )}
340            <Button key="move-up" label="move up" hotkey="k" plain dimColor onPress={() => moveFocused(-1)} />
341            <Button key="move-down" label="move down" hotkey="j" plain dimColor onPress={() => moveFocused(1)} />
342            <Button
343              key="remove"
344              label="remove"
345              hotkey="x"
346              plain
347              dimColor
348              onPress={async () => {
349                const id = await focusedRow()
350                if (id !== undefined) await take($, id)
351              }}
352            />
353          </Box>
354        )}
355      </Box>
356    )
357  })
358}
359
types/index.d.ts 20 lines
1export type QueueItem = { id: string; text: string }
2
3/** A queued prompt steered into the running turn: `pending` until the model's next step reads it. */
4export type SteeredItem = { id: string; text: string; status: 'pending' | 'read' }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'wait-queue': {
9      items: QueueItem[]
10      editingId: string | null
11      isPaused: boolean
12      steered: SteeredItem[]
13      /** The main loop's running turn, or null while idle. */
14      runningTurnId: string | null
15      /** True from releasing a queued prompt until its turn starts, so only one goes at a time. */
16      isReleasing: boolean
17    }
18  }
19}
20