SLOPSHOPPER

queue

Queues messages sent while Claude is working and sends them one per turn, like Codex

newbandrowsprompttimer
★ 1v0.1.0Unlicenseupdated 2026-09-22FrogAi/Xenopus/claude/mods/queue
A shopper browsing a rack in a slop shop
README

Xenopus

Xenopus, like the lab frog it's named after, is where I experiment with how I work with AI coding agents. It's my personal setup for Claude Code and Codex, with the agents, global instructions and skills I use every day. Everything here is something I actually use, tested and revised as I go.

Why it might be useful to you

Out of the box, coding agents tend to do too much and claim too much. They add safeguards, options and abstractions nobody asked for, report work as tested when it wasn't, and drift from what you asked toward what they think you might want. Most of this setup exists to push back on that.

  • Ask when it matters, decide when it doesn't. Real tradeoffs and hard-to-undo steps go to you as a question; small choices get made and stated.
  • Claude and Codex stay in step. Every skill and agent exists for both, so you get the same behavior whichever one you use.
  • Do exactly what was asked, fully. The smallest change that completely does the job, reusing what's already there, with nothing extra.
  • Evidence over confidence. Check facts that can change (versions, docs, your own code) instead of answering from memory, say where a claim came from, and say plainly what wasn't verified.
  • Fix causes, not symptoms. Understand a system before changing it, fix the problem where it starts, and prove the fix on the real path, not just in a unit test.
  • Get better as you work. When you correct the agent or state a preference, it updates the responsible instruction or skill (with a backup), so the same correction isn't needed twice.

Cost and speed

This setup puts quality first. When quality pulls against speed or token cost, it picks quality. It checks facts instead of answering from memory, proves changes on the real path, gets an independent review before finishing work where a mistake would be costly, and runs most specialist agents on the strongest models at high effort. Expect more tokens and longer turns than a stock setup, especially on larger or riskier tasks.

It doesn't spend for nothing, though. Simple tasks are done directly, and checks that can't change the result are skipped. If you'd rather trade some quality for speed or cost, lower the agents' models and effort levels (see Make it yours) or relax the review rule under "Subagent delegation" in the global instructions.

How it fits together

  • Agents are specialists the main session hands bounded jobs to, such as tracing a bug or reviewing a change, each with its own fresh context. coordinate-specialists decides when delegating is worth it, which is less often than you'd think, since simple work is done directly.
  • Global instructions (CLAUDE.md, AGENTS.md) load into every session. They set the working rules above and point to the skills that own each kind of work.
  • Hook and mods change how Claude Code itself behaves, such as queueing the messages you send while it's working.
  • Skills load only when a task matches their description, so they cost nothing until needed. Each one is a method for one kind of work, such as engineering, prompt writing or translation.

Pieces refer to each other by name. Several agents work by the engineering skill, and some load a skill directly (device-runner loads the device skill, prompt-evaluator the prompt skill). Install the whole set rather than picking pieces out.

It improves itself

The setup keeps itself current as you use it, through improve-personal-customizations.

  • Both runtimes stay in step. A change made in Claude gets the same change in its Codex copy, and the other way round.
  • Changes are safe to make. Every change is backed up first and tested in proportion to its risk. A narrow rule gets a quick check in a fresh session, and broader changes, such as to the global instructions, this skill itself, or a new agent or hook, get a before-and-after comparison on both Claude and Codex plus an independent review. Permissions, credentials and anything that costs money stay with you.
  • It fixes what goes wrong. When it notices a skill or agent is wrong or out of date, or you correct something a rule already covered, it repairs the rule itself, rewriting it when the wording caused the miss rather than piling on another rule.
  • It learns your preferences, not your tasks. When you state how you want something done or correct the agent in a way that applies beyond the task at hand, it writes that preference into the instruction, skill or agent responsible for it. It doesn't save task notes, one-off requests or things it could find in the code, so your instructions don't fill up with clutter.
  • New models get an audit. When you move to a new Claude model, it starts from Claude Code's prompt audit to find instructions written for older models, stale paths and contradictions.

Every change is logged in ~/.agents/CUSTOMIZATIONS.md, along with checks that are still pending and ideas that were tried and rejected, so you can see what changed and why.

Requirements

  • Claude Code 2.1.287 or later, for the mods.
  • Claude Code, Codex CLI, or both. Tested with Claude Code 2.1.283 to 2.1.288 on Claude Opus 5.5, and Codex CLI 0.160 on GPT-6 Astra.
  • Codex CLI signed in with a ChatGPT plan, or an OpenAI API key, for create-images to generate images.
  • Codex CLI, signed in, for the codex-reviewer agent.
  • Python 3 with Pillow, NumPy, OpenCV and requests, for the create-images scripts.
  • Python 3, for the hook.

Layout

The two folders mirror where each runtime looks for its files.

claude/          copy into ~/.claude
  agents/        specialist subagents
  CLAUDE.md      global instructions
  hooks/         queue-until-done.py
  mods/          Claude Code mods: collapse-work, queue
  skills/        skills
codex/           copy into ~/.codex, except skills/
  AGENTS.md      global instructions
  agents/        specialist subagents (.toml)
  skills/        copy into ~/.agents/skills

The Claude and Codex copies of each skill and agent say the same thing and differ only in how each runtime names things (for example the coordinate-specialists skill in Claude and $coordinate-specialists in Codex). Two pieces exist for one runtime only, the codex-reviewer agent (Claude) and the handoff-task skill (Codex).

Install

Each block backs up the files it's about to replace into a dated folder in your home directory, then copies the setup in. Your own skills and agents with other names are left alone. You can install the Claude or Codex half on its own. Start a new session afterwards, since instructions and skills load when a session starts.

Windows (PowerShell)

Claude Code
if (Test-Path "$env:TEMP\Xenopus") {
  Remove-Item "$env:TEMP\Xenopus" -Recurse -Force
}
git clone https://github.com/FrogAi/Xenopus.git "$env:TEMP\Xenopus"
$backup = "$HOME\xenopus-backup-claude-$(Get-Date -Format yyyyMMdd-HHmmss)"
New-Item -ItemType Directory $backup | Out-Null
foreach ($item in "agents", "CLAUDE.md", "hooks", "mods", "skills") {
  if (Test-Path "$HOME\.claude\$item") {
    Copy-Item "$HOME\.claude\$item" $backup -Recurse
  }
}
New-Item -ItemType Directory "$HOME\.claude" -Force | Out-Null
Copy-Item "$env:TEMP\Xenopus\claude\*" "$HOME\.claude" -Recurse -Force
Codex
if (Test-Path "$env:TEMP\Xenopus") {
  Remove-Item "$env:TEMP\Xenopus" -Recurse -Force
}
git clone https://github.com/FrogAi/Xenopus.git "$env:TEMP\Xenopus"
$backup = "$HOME\xenopus-backup-codex-$(Get-Date -Format yyyyMMdd-HHmmss)"
New-Item -ItemType Directory $backup | Out-Null
foreach ($item in ".agents\skills", ".codex\agents", ".codex\AGENTS.md") {
  if (Test-Path "$HOME\$item") {
    Copy-Item "$HOME\$item" (Join-Path $backup ($item -replace "\\", "-")) -Recurse
  }
}
New-Item -ItemType Directory "$HOME\.agents\skills", "$HOME\.codex" -Force | Out-Null
Copy-Item "$env:TEMP\Xenopus\codex\agents", "$env:TEMP\Xenopus\codex\AGENTS.md" "$HOME\.codex" -Recurse -Force
Copy-Item "$env:TEMP\Xenopus\codex\skills\*" "$HOME\.agents\skills" -Recurse -Force

macOS and Linux

Claude Code
rm -rf /tmp/Xenopus
git clone https://github.com/FrogAi/Xenopus.git /tmp/Xenopus
backup=~/xenopus-backup-claude-$(date +%Y%m%d-%H%M%S)
mkdir -p "$backup"
for item in agents CLAUDE.md hooks mods skills; do
  if [ -e ~/.claude/$item ]; then
    cp -R ~/.claude/$item "$backup"/
  fi
done
mkdir -p ~/.claude
cp -R /tmp/Xenopus/claude/. ~/.claude/
Codex
rm -rf /tmp/Xenopus
git clone https://github.com/FrogAi/Xenopus.git /tmp/Xenopus
backup=~/xenopus-backup-codex-$(date +%Y%m%d-%H%M%S)
mkdir -p "$backup"
for item in .agents/skills .codex/agents .codex/AGENTS.md; do
  if [ -e ~/$item ]; then
    cp -R ~/$item "$backup"/$(echo $item | tr / -)
  fi
done
mkdir -p ~/.agents/skills ~/.codex
cp -R /tmp/Xenopus/codex/agents /tmp/Xenopus/codex/AGENTS.md ~/.codex/
cp -R /tmp/Xenopus/codex/skills/. ~/.agents/skills/

The hook and mods need one more step each, covered in Hook and mods.

Make it yours

  • Create the customizations record if you use improve-personal-customizations. It's an empty ~/.agents/CUSTOMIZATIONS.md with four headings, Decided against or reverted, Pending, Recent changes and Twins and intentional differences.
  • Fill in your voice. write-in-my-voice/references/voice-profile.md ships as a blank template with prompts to replace.
  • Name your comma devices if you use develop-on-comma-device. Say in your global instructions which SSH alias is your development device and which is your driving device.
  • Read the global instructions and edit them. They're written in the first person, as my preferences. Change anything that isn't how you want to work.
  • Remove what you don't need. A skill you never use costs only its one-line description per session, but an unused agent or skill is still something to keep current.
  • Set the agents' models. Each agent names a model and effort level in its frontmatter (Claude) or .toml (Codex). Change them to models you have access to.

Skills

SkillWhat it's for
coordinate-specialistsWhen to delegate to subagents and when not to, how to brief them, and how to maintain the agent library.
create-imagesMaking, editing and repairing AI-generated images, from composing several real subjects into one scene to removing artifacts and seams.
develop-on-comma-deviceopenpilot development on a comma three or 3X, covering device roles, safe bench testing, measurements and crash investigation.
engineer-production-changesThe engineering method for all software work. Understand the system first, fix causes where they start, build the smallest complete design, prove it on the real path and report honestly.
engineer-promptsWriting, revising and evaluating prompts, skills, agent definitions and instruction files.
explore-frontend-designsExploring and comparing UI design directions before one is chosen.
handoff-task (Codex)Handing a task to a fresh conversation, or recovering context from an earlier one.
implement-frontend-designsBuilding an approved design in a real project with exact visual and interaction fidelity.
improve-personal-customizationsKeeps your agents, instructions and skills improving as you work. It saves your stated preferences (not task notes), fixes customizations that prove wrong, keeps the Claude and Codex copies in step, and tests changes in proportion to their risk. Keeps a record at ~/.agents/CUSTOMIZATIONS.md (see Make it yours).
translate-contentTranslation and localization, from prose to UI string catalogs.
write-in-my-voiceDrafting messages that sound like you. Ships with a blank voice profile to fill in.
write-release-notesPublic release notes and update posts that are accurate and fun to read.

Agents

Specialists that the main session delegates to through coordinate-specialists. Each has one bounded job and reports back; none decides what to ship.

AgentWhat it does
audience-reviewerReviews prose and visuals for what the intended reader would actually understand.
behavior-tracerTraces an execution or data path through code and configuration.
check-runnerRuns assigned existing checks and reports what actually happened.
cloud-auditorRead-only audit of cloud provider state, usage and cost.
codex-reviewerClaude only. Gets a second opinion from Codex through its CLI. It sends your material to OpenAI, so it runs only when you ask for it.
design-exploration-reviewerReviews frontend design alternatives for fit, quality and fair comparison.
device-runnerRuns checks or retrieves logs on a comma device over SSH, with develop-on-comma-device.
engineering-reviewerReviews a design or implementation for correctness, regressions and unnecessary complexity.
evidence-auditorChecks whether the evidence actually supports a claim of correctness, testing or completion.
frontend-reviewerReviews an implemented interface against its accepted design and user flows.
image-artifact-reviewerInspects a generated or edited image at full zoom for AI artifacts, wrong details and compositing seams, and reports them without editing the image.
log-triagerOrganizes logs or telemetry into event groups, counts and a timeline.
performance-investigatorMeasures latency, throughput and resource use with controlled comparisons.
privacy-reviewerReviews data that leaves a device or system for personal information.
prompt-evaluatorAssesses a prompt, skill or agent definition against its task and target model.
reliability-reviewerReviews rollouts, recovery and operational behavior across versions and dependencies.
repository-locatorFinds files, symbols, references and configuration in a repository.
root-cause-investigatorEstablishes a defect's trigger, mechanism and responsible boundary.
security-reviewerReviews a design or change for reachable security weaknesses.
source-extractorExtracts specified facts or passages from files and web sources, with provenance.
targeted-implementerImplements one small, understood change and verifies it.
translation-reviewerReviews translations for natural language, fidelity and complete coverage.
verification-designerDesigns the checks that would actually prove a change works.

Hook and mods (Claude Code)

These rely on Claude Code internals that can change between releases, so check them after updating.

  • hooks/queue-until-done.py. In the desktop app, it holds a message you send while Claude is working and delivers it when the current task ends. It depends on undocumented behavior of "continue": false; last confirmed on 2.1.284. Register it for both UserPromptSubmit and SessionEnd in ~/.claude/settings.json, using the script's full path.
  "hooks": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "python /path/to/.claude/hooks/queue-until-done.py" }] }],
    "SessionEnd": [{ "hooks": [{ "type": "command", "command": "python /path/to/.claude/hooks/queue-until-done.py" }] }]
  }

Use python3 instead of python where that's your interpreter's name (macOS and most Linux).

  • mods/collapse-work. Folds a finished turn's work under a "Worked for" line.
  • mods/queue. A Codex-style queue for messages sent while Claude works, sent one per turn.

The mods need Claude Code 2.1.287 or later and were tested in the terminal on 2.1.288. Load one with claude --plugin-dir ~/.claude/mods/queue. Use either the hook or mods/queue, not both, since they act on the same message. Each mod has tests in its tests folder, which you can run from the mod's folder with claude plugin test ..

License

Public domain (Unlicense).

Source 2 files
hooks/register.js 973 lines
1const USER_ORIGINS = ['composer', 'sdk', 'bridge']
2
3const KEPT_SESSIONS = 30
4
5// The messages the engine is holding live as long as its process does, so they are kept where a reload of this module keeps them.
6const HELD = { plugin: 'queue', key: 'held' }
7
8const IMAGE_SOURCE = '[Image: source:'
9
10// The engine sends waiting messages back as one prompt with every attachment on it, so which message an image came with is lost.
11const IMAGES_ATTACHED_NOTE =
12  'This message was queued with others while you were working. ' +
13  'The attached images came with that group of queued messages, and the queue cannot tell which message they belong to. ' +
14  'Use them here only if this message refers to an attachment or a screenshot; otherwise leave them for the message they belong to.'
15
16const IMAGES_ELSEWHERE_NOTE =
17  'This message was queued with others while you were working. ' +
18  'These images were attached somewhere in that group of queued messages, and the queue cannot tell which message they belong to. ' +
19  'If this message refers to an attachment or a screenshot, read the files:'
20
21function redraw($) {
22  $.ui.invalidate('ui.render')
23}
24
25async function save($, queue) {
26  const key = 'queue:' + queue.sessionId
27
28  if (queue.items.length === 0) {
29    await $.store.delete(key)
30    return
31  }
32
33  const items = queue.items.map((item) => {
34    const saved = { text: item.text, images: item.images }
35
36    if (item.bubble !== undefined) {
37      saved.row = item.bubble.id ?? ''
38    }
39
40    return saved
41  })
42
43  await $.store.set(key, { items, pausedBecause: queue.pausedBecause })
44}
45
46// Keeps which rows of a session are old and which stay out of the chat, for the sessions used most recently.
47async function saveRows($, queue, bubbles) {
48  await $.store.set('rows:' + queue.sessionId, { known: [...bubbles.known], hidden: [...bubbles.hidden] })
49
50  const stored = (await $.store.get('sessions')) ?? []
51  const sessions = stored.filter((id) => id !== queue.sessionId)
52  sessions.push(queue.sessionId)
53
54  const dropped = sessions.splice(0, sessions.length - KEPT_SESSIONS)
55
56  for (const id of dropped) {
57    await $.store.delete('rows:' + id)
58  }
59
60  await $.store.set('sessions', sessions)
61}
62
63async function persist($, queue, bubbles) {
64  await save($, queue)
65
66  if (bubbles.isTracked) {
67    await saveRows($, queue, bubbles)
68  }
69}
70
71async function load($, queue, bubbles, sessionId) {
72  if (sessionId === queue.sessionId) {
73    return
74  }
75
76  queue.sessionId = sessionId
77
78  const saved = (await $.store.get('queue:' + sessionId)) ?? { items: [] }
79
80  queue.items = saved.items
81
82  queue.pausedBecause = saved.pausedBecause
83
84  // Rows are only kept for a session followed from its first prompt, which is decided when a prompt next enters.
85  const rows = await $.store.get('rows:' + sessionId)
86
87  bubbles.known = new Set(rows?.known)
88
89  bubbles.hidden = new Set(rows?.hidden)
90
91  bubbles.isTracked = undefined
92
93  if (rows !== undefined) {
94    bubbles.isTracked = true
95  }
96
97  redraw($)
98}
99
100// Messages that were still with the engine when the queue was saved, each with its bubble's row: after a reload of this module the
101// engine still has them, so the queue waits for them to come back; in a new process the engine's copies are gone, so the queue
102// sends them itself.
103function restoreWaiting(queue, bubbles, isSameProcess) {
104  queue.items.forEach((item, index) => {
105    if (item.row === undefined) {
106      return
107    }
108
109    const id = item.row || 'saved-' + index
110
111    delete item.row
112
113    if (!isSameProcess) {
114      return
115    }
116
117    item.bubble = {
118      id,
119      text: item.text,
120      entry: item,
121      isQueued: queue.held.includes(item.text),
122      isRemoved: false,
123    }
124
125    bubbles.sentMidTurn.set(id, item.bubble)
126  })
127}
128
129// A newly sent message joins the end of the queue, or takes the place of the message the user took out to edit.
130function enqueue(queue, item) {
131  const index = queue.editedIndex ?? queue.items.length
132
133  queue.editedIndex = undefined
134
135  queue.items.splice(index, 0, item)
136}
137
138// A message whose bubble was not listed when drawn takes the place in the queue that its sending order gives it.
139function insertEntry(queue, bubbles, bubble, item) {
140  const order = [...bubbles.sentMidTurn.values()]
141
142  const position = order.indexOf(bubble)
143
144  const index = queue.items.findIndex((other) => other.bubble !== undefined && order.indexOf(other.bubble) > position)
145
146  if (position === -1 || index === -1) {
147    queue.items.push(item)
148  } else {
149    queue.items.splice(index, 0, item)
150  }
151}
152
153// A message still with the engine cannot be taken back from it, so removing one marks its bubble as not to be queued when it returns.
154async function remove($, queue, item) {
155  if (queue.editedIndex !== undefined && queue.items.indexOf(item) < queue.editedIndex) {
156    queue.editedIndex -= 1
157  }
158
159  queue.items = queue.items.filter((other) => other !== item)
160
161  if (item.bubble !== undefined) {
162    item.bubble.isRemoved = true
163  }
164
165  redraw($)
166
167  await save($, queue)
168}
169
170async function move($, queue, item, offset) {
171  const index = queue.items.indexOf(item)
172
173  const target = index + offset
174
175  if (
176    index === -1 ||
177    target < 0 ||
178    target >= queue.items.length ||
179    queue.delivering !== undefined
180  ) {
181    return
182  }
183
184  const items = queue.items.filter((other) => other !== item)
185  items.splice(target, 0, item)
186
187  queue.items = items
188
189  redraw($)
190
191  await save($, queue)
192
193  deliver($, queue)
194}
195
196async function pause($, queue, because) {
197  queue.pausedBecause = because
198
199  redraw($)
200
201  await save($, queue)
202}
203
204async function hold($, queue, held) {
205  queue.held = held
206
207  await $.state.set(HELD, held)
208}
209
210function startsWithMessage(text, message) {
211  return message !== '' && (text === message || text.startsWith(message + '\n'))
212}
213
214function afterMessage(text, message) {
215  return text.slice(message.length).replace(/^\n+/, '')
216}
217
218// The messages a prompt carries after the ones the engine held: those the engine never offered, told apart by their bubbles.
219// Undefined when the prompt is not messages the engine sent back.
220function lateMessages(text, held, waiting) {
221  let rest = text.replace(/^\n+/, '')
222
223  for (const message of held) {
224    if (!startsWithMessage(rest, message)) {
225      return undefined
226    }
227
228    rest = afterMessage(rest, message)
229  }
230
231  // An old row drawn mid-turn can carry a message's first line or its very text, so the longest and then the newest bubble wins.
232  const late = []
233
234  let candidates = waiting
235
236  while (true) {
237    const matches = candidates.filter((bubble) => startsWithMessage(rest, bubble.text))
238
239    if (matches.length === 0) {
240      break
241    }
242
243    let bubble = matches[0]
244
245    for (const other of matches) {
246      if (other.text.length >= bubble.text.length) {
247        bubble = other
248      }
249    }
250
251    late.push(bubble)
252
253    rest = afterMessage(rest, bubble.text)
254
255    candidates = candidates.filter((other) => other !== bubble)
256  }
257
258  if (held.length === 0 && late.length === 0) {
259    return undefined
260  }
261
262  if (rest !== '') {
263    late.push({ text: rest })
264  }
265
266  return late
267}
268
269// Bubbles of messages that are still with the engine and have not been offered, oldest first.
270function waitingBubbles(bubbles) {
271  const waiting = []
272
273  for (const [id, bubble] of bubbles.sentMidTurn) {
274    if (!bubble.isQueued && !bubbles.promptRows.has(id)) {
275      waiting.push(bubble)
276    }
277  }
278
279  return waiting
280}
281
282// The texts of the user messages the transcript holds, read once per turn.
283function priorTexts($, bubbles) {
284  bubbles.priorTexts ??= $.session.messages().then((messages) => {
285    const texts = new Set()
286
287    for (const message of messages) {
288      if (message.role === 'user') {
289        texts.add(message.text)
290      }
291    }
292
293    return texts
294  })
295
296  return bubbles.priorTexts
297}
298
299function imagesNote(item) {
300  return IMAGES_ELSEWHERE_NOTE + '\n' + item.images.join('\n')
301}
302
303function withImagesNote(context, item) {
304  if (item.images === undefined) {
305    return context
306  }
307
308  return [...(context ?? []), imagesNote(item)]
309}
310
311// Hands a message to the running turn, which reads it at its next step.
312async function interject($, message) {
313  let text = 'The user sent a new message while you were working:\n' + message.text
314  text += '\n\nAddress this message as you continue this turn.'
315
316  if (message.images !== undefined) {
317    text += '\n\n' + imagesNote(message)
318  }
319
320  await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
321
322  $.ui.log('steered into the running turn: ' + message.text)
323}
324
325// A message is done with once its prompt entered the session; one that did not enter stays queued and pauses the queue.
326async function settle($, queue, item, refusal) {
327  queue.delivering = undefined
328
329  if (refusal === undefined) {
330    await remove($, queue, item)
331    return
332  }
333
334  $.ui.log('queued message could not be sent: ' + refusal)
335
336  await pause($, queue, 'a message could not be sent')
337}
338
339// A message still with the engine comes back with the engine's own prompt, so the queue waits for that instead of sending it.
340function deliver($, queue) {
341  if (
342    queue.items.length === 0 ||
343    queue.runningTurn !== undefined ||
344    queue.pausedBecause !== undefined ||
345    queue.delivering !== undefined
346  ) {
347    return
348  }
349
350  if (queue.held.length > 0 || queue.items[0].bubble !== undefined) {
351    return
352  }
353
354  const item = queue.items[0]
355
356  queue.delivering = item
357
358  $.prompt.submit({ text: item.text, asUser: true }).then(
359    (result) => settle($, queue, item, result.drop),
360    (error) => settle($, queue, item, String(error)),
361  )
362}
363
364async function resume($, queue) {
365  queue.pausedBecause = undefined
366
367  redraw($)
368
369  await save($, queue)
370
371  deliver($, queue)
372}
373
374async function steer($, queue, item) {
375  if (!queue.items.includes(item) || queue.delivering !== undefined) {
376    return
377  }
378
379  if (queue.runningTurn === undefined) {
380    const others = queue.items.filter((other) => other !== item)
381    queue.items = [item, ...others]
382
383    await resume($, queue)
384    return
385  }
386
387  await remove($, queue, item)
388
389  queue.steered.push(item)
390
391  await interject($, item)
392}
393
394async function edit($, queue, item) {
395  if (!queue.items.includes(item) || queue.delivering !== undefined) {
396    return
397  }
398
399  const filled = await $.prompt.fill({ text: item.text })
400
401  if (!filled.isFilled) {
402    return
403  }
404
405  const index = queue.items.indexOf(item)
406
407  await remove($, queue, item)
408
409  queue.editedIndex = index
410}
411
412async function discard($, queue, item) {
413  if (queue.delivering !== undefined) {
414    return
415  }
416
417  await remove($, queue, item)
418
419  deliver($, queue)
420}
421
422export function register(on) {
423  // The desktop app shows a dropped prompt as a warning card, so there the engine holds a queued message instead (held) and sends
424  // everything it has waiting back as one prompt when the turn ends. A queued message still with the engine keeps its bubble's
425  // record (item.bubble) until then. A picture sent on its own never reaches the queue: the app does not draw it through a mod, and
426  // it comes back as an attachment of the engine's prompt. imagesFor is the queued messages waiting to learn where a returned
427  // group's images are. editedIndex is the place of the message the user took out to edit, kept for what they send next in the
428  // same turn. steered is the messages steered into the running turn that no later request of that turn has carried yet.
429  const queue = {
430    sessionId: undefined,
431    items: [],
432    pausedBecause: undefined,
433    delivering: undefined,
434    runningTurn: undefined,
435    held: [],
436    isHolding: false,
437    imagesFor: undefined,
438    editedIndex: undefined,
439    steered: [],
440  }
441
442  // The desktop app draws a message as a bubble the moment it is sent. A bubble first drawn while a turn runs is a message waiting
443  // on that turn (sentMidTurn, emptied when the engine hands them back): queued at once and drawn empty, unless it could be an old
444  // row scrolling into view. In a session followed from its first prompt (isTracked) the rows the session has stored are known by
445  // id (known); otherwise a row counts as old when the transcript holds its text. A bubble first drawn while idle is the prompt
446  // being typed
447  // (typed). The engine offers only its oldest waiting message during the turn and sends them all back under the last bubble sent,
448  // which then shows the message that entered the turn (sentBack); the other bubbles stay out of the chat (hidden).
449  const bubbles = {
450    drawn: new Set(),
451    promptRows: new Set(),
452    known: new Set(),
453    isTracked: undefined,
454    sentMidTurn: new Map(),
455    hidden: new Set(),
456    priorTexts: undefined,
457    typed: undefined,
458    sentBack: new Map(),
459  }
460
461  on('session.start', async ($, e, next) => {
462    const held = (await $.state.get(HELD)).value
463
464    queue.held = held ?? []
465
466    await $.state.set(HELD, queue.held)
467
468    await load($, queue, bubbles, await $.session.id())
469
470    restoreWaiting(queue, bubbles, held !== undefined)
471
472    deliver($, queue)
473
474    return next(e)
475  })
476
477  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
478    await load($, queue, bubbles, e.session_id)
479
480    deliver($, queue)
481
482    return next(e)
483  })
484
485  on('prompt.submit', async ($, e, next) => {
486    if (!USER_ORIGINS.includes(e.origin.kind)) {
487      const item = queue.delivering
488
489      if (
490        e.origin.kind !== 'plugin' ||
491        e.origin.name !== 'queue' ||
492        item?.images === undefined
493      ) {
494        return next(e)
495      }
496
497      return next({ ...e, context: withImagesNote(e.context, item) })
498    }
499
500    if (e.turnId === undefined) {
501      let late
502
503      if (e.text !== bubbles.typed) {
504        late = lateMessages(e.text, queue.held, waitingBubbles(bubbles))
505      }
506
507      const returned = queue.held.length + (late?.length ?? 0)
508
509      if (queue.held.length > 0) {
510        await hold($, queue, [])
511      }
512
513      if (late === undefined) {
514        // The engine did not send the messages it held back, so the queue sends them itself.
515        for (const item of queue.items) {
516          if (item.bubble?.isQueued) {
517            delete item.bubble
518          }
519        }
520
521        return next(e)
522      }
523
524      for (const message of late) {
525        message.isQueued = true
526
527        if (message.isRemoved || message.entry !== undefined) {
528          continue
529        }
530
531        message.entry = { text: message.text, bubble: message }
532
533        insertEntry(queue, bubbles, message, message.entry)
534      }
535
536      // The engine hands back everything it has waiting at once, so the queue now sends the whole group itself, and a bubble the
537      // engine did not hand back was an old row.
538      queue.items = queue.items.filter((item) => item.bubble === undefined || item.bubble.isQueued)
539
540      const batch = []
541
542      for (const item of queue.items) {
543        if (item.bubble === undefined) {
544          continue
545        }
546
547        delete item.bubble
548
549        batch.push(item)
550      }
551
552      for (const [id, bubble] of bubbles.sentMidTurn) {
553        if (bubble.isQueued) {
554          bubbles.hidden.add(id)
555        }
556      }
557
558      bubbles.sentMidTurn.clear()
559
560      redraw($)
561
562      if (queue.items.length === 0) {
563        return { drop: 'removed from the queue' }
564      }
565
566      if (queue.pausedBecause !== undefined) {
567        await save($, queue)
568
569        if (e.attachments !== undefined) {
570          return { drop: 'queue paused because ' + queue.pausedBecause + ', and its pictures were not kept, so attach them again' }
571        }
572
573        return { drop: 'queue paused because ' + queue.pausedBecause }
574      }
575
576      const item = queue.items[0]
577
578      const sentBack = { ...e, text: item.text }
579
580      let context = withImagesNote(e.context, item)
581
582      if (e.attachments !== undefined && (returned > 1 || !batch.includes(item))) {
583        queue.imagesFor = batch.filter((member) => member !== item)
584        context = [...(context ?? []), IMAGES_ATTACHED_NOTE]
585      }
586
587      if (context !== undefined) {
588        sentBack.context = context
589      }
590
591      queue.delivering = item
592
593      const result = await next(sentBack)
594
595      await settle($, queue, item, result.drop)
596      return result
597    }
598
599    const isDesktop = (await $.session.surfaces()).includes('desktop')
600
601    // The engine offers its oldest waiting message.
602    const bubble = waitingBubbles(bubbles).find((waiting) => waiting.text === e.text)
603
604    if (bubble !== undefined) {
605      bubble.isQueued = true
606    }
607
608    // A message with attachments goes back to the engine with its attachments only on the desktop, where the engine holds it.
609    if (
610      e.wait ||
611      e.text.startsWith('/') ||
612      (e.attachments !== undefined && !isDesktop)
613    ) {
614      if (bubble?.entry !== undefined) {
615        await remove($, queue, bubble.entry)
616      }
617
618      return next(e)
619    }
620
621    // A picture sent on its own is held with the rest and comes back as an attachment of the engine's prompt.
622    let item = bubble?.entry
623
624    if (item === undefined && !bubble?.isRemoved && e.text !== '') {
625      item = { text: e.text }
626
627      if (bubble === undefined) {
628        enqueue(queue, item)
629      } else {
630        insertEntry(queue, bubbles, bubble, item)
631      }
632    }
633
634    redraw($)
635
636    await save($, queue)
637
638    if (!isDesktop) {
639      return { drop: 'queued, ' + queue.items.length + ' waiting' }
640    }
641
642    if (item !== undefined && item.bubble === undefined) {
643      item.bubble = bubble ?? {
644        id: 'offered-' + bubbles.sentMidTurn.size,
645        text: e.text,
646        isQueued: true,
647        isRemoved: false,
648      }
649
650      item.bubble.entry = item
651
652      bubbles.sentMidTurn.set(item.bubble.id, item.bubble)
653    }
654
655    if (e.text !== '') {
656      await hold($, queue, [...queue.held, e.text])
657    }
658
659    queue.isHolding = true
660
661    try {
662      return await next(e)
663    } finally {
664      queue.isHolding = false
665    }
666  })
667
668  on('classic.UserPromptSubmit', async ($, e, next) => {
669    if (queue.isHolding) {
670      return { preventContinuation: true }
671    }
672
673    return next(e)
674  })
675
676  on('turn.start', async ($, e, next) => {
677    queue.runningTurn = e.turnId
678
679    queue.editedIndex = undefined
680
681    queue.imagesFor = undefined
682
683    bubbles.typed = undefined
684
685    bubbles.priorTexts = undefined
686
687    // A pause set when nothing was queued only waits for messages the engine may still send back.
688    if (queue.items.length === 0) {
689      queue.pausedBecause = undefined
690    }
691
692    return next(e)
693  })
694
695  on('session.append', async ($, e, next) => {
696    // Every row the session stores for the user's side is an old row from then on; tool results are never drawn as messages.
697    const isUserRow =
698      e.agentId === undefined &&
699      e.message.type === 'user' &&
700      e.door !== 'tool-result' &&
701      e.door !== 'tool-message'
702
703    if (isUserRow) {
704      bubbles.known.add(e.uuid)
705    }
706
707    if (isUserRow && e.door === 'prompt') {
708      bubbles.drawn.add(e.uuid)
709
710      bubbles.promptRows.add(e.uuid)
711
712      const wasWaiting = bubbles.hidden.delete(e.uuid) || bubbles.sentMidTurn.has(e.uuid)
713
714      const text = e.message.content.find((block) => block.type === 'text')?.text
715
716      if (wasWaiting && text !== undefined) {
717        bubbles.sentBack.set(e.uuid, text)
718      }
719
720      if (wasWaiting) {
721        redraw($)
722      }
723
724      if (bubbles.isTracked === undefined) {
725        const messages = await $.session.messages()
726        bubbles.isTracked = messages.length === 0
727      }
728
729      if (bubbles.isTracked) {
730        await saveRows($, queue, bubbles)
731      }
732    }
733
734    // The engine records where it saved a prompt's images in a row right after the prompt.
735    if (e.door === 'note' && queue.imagesFor !== undefined) {
736      const images = []
737
738      for (const block of e.message.content) {
739        if (block.type === 'text' && block.text.startsWith(IMAGE_SOURCE)) {
740          images.push(block.text)
741        }
742      }
743
744      if (images.length > 0) {
745        for (const item of queue.imagesFor) {
746          item.images = [...(item.images ?? []), ...images]
747        }
748
749        await save($, queue)
750      }
751    }
752
753    return next(e)
754  })
755
756  on('ui.render', { component: 'UserMessage', surface: 'desktop' }, async ($, e, next) => {
757    if (!bubbles.drawn.has(e.requestId)) {
758      bubbles.drawn.add(e.requestId)
759
760      const isOld =
761        e.props.task !== undefined ||
762        e.props.from !== undefined ||
763        (bubbles.isTracked === true && bubbles.known.has(e.requestId))
764
765      if (queue.runningTurn === undefined) {
766        bubbles.typed = e.props.text
767      } else if (!isOld) {
768        const bubble = {
769          id: e.requestId,
770          text: e.props.text,
771          entry: undefined,
772          isQueued: false,
773          isRemoved: false,
774        }
775
776        bubbles.sentMidTurn.set(e.requestId, bubble)
777
778        let isNew = bubble.text !== ''
779
780        if (isNew && bubbles.isTracked !== true) {
781          const texts = await priorTexts($, bubbles)
782          isNew = !texts.has(bubble.text)
783        }
784
785        bubbles.known.add(e.requestId)
786
787        if (
788          isNew &&
789          bubble.entry === undefined &&
790          !bubble.isQueued &&
791          !bubble.isRemoved
792        ) {
793          bubble.entry = { text: bubble.text, bubble }
794
795          enqueue(queue, bubble.entry)
796
797          // Writes are kept out of a drawing, so the queue is saved just after it.
798          $.clock.after(0, () => persist($, queue, bubbles))
799        }
800
801        redraw($)
802      }
803    }
804
805    if (bubbles.sentBack.has(e.requestId)) {
806      const props = { ...e.props, text: bubbles.sentBack.get(e.requestId) }
807      return next({ ...e, props })
808    }
809
810    const bubble = bubbles.sentMidTurn.get(e.requestId)
811    const isWaiting =
812      bubble !== undefined &&
813      !bubbles.promptRows.has(e.requestId) &&
814      (bubble.entry !== undefined || bubble.isQueued || bubble.isRemoved)
815
816    if (!isWaiting && !bubbles.hidden.has(e.requestId)) {
817      return next(e)
818    }
819
820    const { Box } = $.ui.resolve(e)
821    return Box({ children: [] })
822  })
823
824  on('turn.step', async function* ($, e, next) {
825    if (e.agentId === undefined) {
826      queue.steered = []
827    }
828
829    return yield* next(e)
830  })
831
832  on('turn.complete', async ($, e, next) => {
833    if (e.agentId !== undefined) {
834      return next(e)
835    }
836
837    queue.runningTurn = undefined
838
839    if (queue.steered.length > 0) {
840      queue.items = [...queue.steered, ...queue.items]
841
842      queue.steered = []
843
844      await save($, queue)
845    }
846
847    if (e.isAborted) {
848      await pause($, queue, 'you interrupted')
849    }
850
851    // A turn that died on an API error, such as a usage limit, would take every queued message down the same way.
852    if (e.reason === 'error') {
853      await pause($, queue, 'the last turn failed')
854    }
855
856    const result = await next(e)
857
858    deliver($, queue)
859    return result
860  })
861
862  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
863    if (queue.items.length === 0 || e.props.hasSurvey) {
864      return next(e)
865    }
866
867    const { Box, Text, Button } = $.ui.resolve(e)
868
869    const children = []
870
871    if (queue.pausedBecause !== undefined) {
872      children.push(
873        Box({
874          flexDirection: 'row',
875          columnGap: 2,
876          children: [
877            Text({ children: ['Queue paused because ' + queue.pausedBecause] }),
878            Button({
879              key: 'resume',
880              label: 'Resume',
881              hotkey: 'r',
882              plain: true,
883              onPress: () => resume($, queue),
884            }),
885          ],
886        }),
887      )
888    }
889
890    // Each message takes two lines: its text, and under it the buttons, so more of the text fits. A longer list scrolls.
891    queue.items.forEach((item, index) => {
892      let hotkeys = { steer: {}, edit: {}, delete: {} }
893
894      if (index === 0) {
895        hotkeys = { steer: { hotkey: 's' }, edit: { hotkey: 'e' }, delete: { hotkey: 'd' } }
896      }
897
898      const buttons = []
899
900      if (index > 0) {
901        buttons.push(
902          Button({
903            key: 'up-' + index,
904            label: '↑',
905            plain: true,
906            dimColor: true,
907            onPress: () => move($, queue, item, -1),
908          }),
909        )
910      }
911
912      if (index < queue.items.length - 1) {
913        buttons.push(
914          Button({
915            key: 'down-' + index,
916            label: '↓',
917            plain: true,
918            dimColor: true,
919            onPress: () => move($, queue, item, 1),
920          }),
921        )
922      }
923
924      buttons.push(
925        Button({
926          ...hotkeys.steer,
927          key: 'steer-' + index,
928          label: 'Steer',
929          plain: true,
930          dimColor: true,
931          onPress: () => steer($, queue, item),
932        }),
933        Button({
934          ...hotkeys.edit,
935          key: 'edit-' + index,
936          label: 'Edit',
937          plain: true,
938          dimColor: true,
939          onPress: () => edit($, queue, item),
940        }),
941        Button({
942          ...hotkeys.delete,
943          key: 'delete-' + index,
944          label: 'Delete',
945          plain: true,
946          dimColor: true,
947          onPress: () => discard($, queue, item),
948        }),
949      )
950
951      children.push(
952        Box({
953          key: 'row-' + index,
954          flexDirection: 'column',
955          children: [
956            Box({
957              flexDirection: 'row',
958              columnGap: 1,
959              children: [
960                Text({ dimColor: true, children: [String(index + 1) + '.'] }),
961                Text({ wrap: 'truncate-end', children: [item.text.replace(/\s+/g, ' ')] }),
962              ],
963            }),
964            Box({ flexDirection: 'row', columnGap: 2, paddingLeft: 3, children: buttons }),
965          ],
966        }),
967      )
968    })
969
970    return Box({ flexDirection: 'column', paddingRight: 4, children })
971  })
972}
973
types/index.d.ts 8 lines
1export type QueueHeld = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    queue: { held: QueueHeld }
6  }
7}
8