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

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.
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.
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.
coordinate-specialists decides when delegating is worth it, which is less often than you'd think, since simple work is done directly.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.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.
The setup keeps itself current as you use it, through improve-personal-customizations.
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.
create-images to generate images.codex-reviewer agent.create-images scripts.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).
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.
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
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
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/
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.
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.write-in-my-voice/references/voice-profile.md ships as a blank template with prompts to replace.develop-on-comma-device. Say in your global instructions which SSH alias is your development device and which is your driving device..toml (Codex). Change them to models you have access to.| Skill | What it's for |
|---|---|
coordinate-specialists | When to delegate to subagents and when not to, how to brief them, and how to maintain the agent library. |
create-images | Making, editing and repairing AI-generated images, from composing several real subjects into one scene to removing artifacts and seams. |
develop-on-comma-device | openpilot development on a comma three or 3X, covering device roles, safe bench testing, measurements and crash investigation. |
engineer-production-changes | The 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-prompts | Writing, revising and evaluating prompts, skills, agent definitions and instruction files. |
explore-frontend-designs | Exploring 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-designs | Building an approved design in a real project with exact visual and interaction fidelity. |
improve-personal-customizations | Keeps 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-content | Translation and localization, from prose to UI string catalogs. |
write-in-my-voice | Drafting messages that sound like you. Ships with a blank voice profile to fill in. |
write-release-notes | Public release notes and update posts that are accurate and fun to read. |
Specialists that the main session delegates to through coordinate-specialists. Each has one bounded job and reports back; none decides what to ship.
| Agent | What it does |
|---|---|
audience-reviewer | Reviews prose and visuals for what the intended reader would actually understand. |
behavior-tracer | Traces an execution or data path through code and configuration. |
check-runner | Runs assigned existing checks and reports what actually happened. |
cloud-auditor | Read-only audit of cloud provider state, usage and cost. |
codex-reviewer | Claude 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-reviewer | Reviews frontend design alternatives for fit, quality and fair comparison. |
device-runner | Runs checks or retrieves logs on a comma device over SSH, with develop-on-comma-device. |
engineering-reviewer | Reviews a design or implementation for correctness, regressions and unnecessary complexity. |
evidence-auditor | Checks whether the evidence actually supports a claim of correctness, testing or completion. |
frontend-reviewer | Reviews an implemented interface against its accepted design and user flows. |
image-artifact-reviewer | Inspects a generated or edited image at full zoom for AI artifacts, wrong details and compositing seams, and reports them without editing the image. |
log-triager | Organizes logs or telemetry into event groups, counts and a timeline. |
performance-investigator | Measures latency, throughput and resource use with controlled comparisons. |
privacy-reviewer | Reviews data that leaves a device or system for personal information. |
prompt-evaluator | Assesses a prompt, skill or agent definition against its task and target model. |
reliability-reviewer | Reviews rollouts, recovery and operational behavior across versions and dependencies. |
repository-locator | Finds files, symbols, references and configuration in a repository. |
root-cause-investigator | Establishes a defect's trigger, mechanism and responsible boundary. |
security-reviewer | Reviews a design or change for reachable security weaknesses. |
source-extractor | Extracts specified facts or passages from files and web sources, with provenance. |
targeted-implementer | Implements one small, understood change and verifies it. |
translation-reviewer | Reviews translations for natural language, fidelity and complete coverage. |
verification-designer | Designs the checks that would actually prove a change works. |
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 ..
Public domain (Unlicense).
hooks/register.js 973 lines1const 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}
973types/index.d.ts 8 lines1export type QueueHeld = string[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 queue: { held: QueueHeld }
6 }
7}
8