Pane for the per-repo aq prompt queue: pick items, batch them into the prompt box, queue drafts, and let Claude queue follow-ups

Function-hook plugins for Claude Code. Each one installs on its own.
At a Claude Code prompt, install any of the mods below:
/plugin install <mod> --marketplace daviddecorso/claude-mods
Answer y to add the marketplace, then pick a scope.
Blocks Bash commands that write files (inline Python/Node scripts, sed -i, perl -pi, redirects, tee) and points the model at Edit/Write instead, so every file change shows up as a reviewable diff. Writes to temp and scratchpad directories are allowed.
Option extraAllowed: a regex; Bash commands matching it are never blocked (e.g. ^node scripts/gen-).
/plugin install shell-edit-guard --marketplace daviddecorso/claude-mods
Finds each repo's dev servers (dev* package scripts and wrangler dev, for Vite, Astro, Next, Wrangler and Expo), gives them fixed ports, and runs them as named services. Adds a /dev pane and dev_list / dev_start / dev_stop / dev_restart / dev_logs tools, so the model reads filtered logs instead of backgrounding servers in Bash.
/plugin install dev-servers --marketplace daviddecorso/claude-mods
Hands a plans/<slug>.md plan to agy, the Antigravity CLI (headless with live progress, or copied for agy -i), or to Google's Jules (cloud PR), then starts a plan-vs-diff review when the work lands. When an agy run's review reports findings, they can go back to agy to fix (written to plans/fixes/<slug>.md), and that run gets reviewed too. Ships two skills: plan-handoff writes the plan with a recommended implementer, and plan-diff-review checks the result against it.
Needs agy on PATH for agy handoffs, and a Jules API key for Jules handoffs, stored in the macOS Keychain:
security add-generic-password -s jules-api -a "$USER" -w
(JULES_API_KEY in the environment also works.)
/plugin install handoff --marketplace daviddecorso/claude-mods
A pane for a per-repo prompt queue kept in <repo>/.queue/ (shared across worktrees): pick queued prompts, batch them into the prompt box, queue drafts, and archive items with the x hotkey or /aq done <seq…>. Also gives the model an aq_add tool to queue follow-ups. Pairs with the aq CLI from zsh-utils, which reads and writes the same queue.
/plugin install aq --marketplace daviddecorso/claude-mods
Run a mod from a working copy with claude --plugin-dir ./<mod>. Check it with claude plugin validate ./<mod> and claude plugin test ./<mod>.
Each mod's tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code generates the first time it loads the mod. On a fresh clone, run claude --plugin-dir ./<mod> once before type-checking.
MIT
hooks/register.tsx 316 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PendingItem, QueueItem } from '../types'
5import { ITEM_FILE, anchorOf, joinBatch, keptItems, nextSeq, seqsToFiles, slugify, toItem, truncate } from './queue'
6
7type $ = EngineInterface
8
9const PANE = 'aq'
10
11const items = atom({ plugin: 'aq', key: 'items' } as const, [] as QueueItem[])
12const selected = atom({ plugin: 'aq', key: 'selected' } as const, [] as string[])
13const pending = atom({ plugin: 'aq', key: 'pending' } as const, [] as PendingItem[])
14const repo = atom({ plugin: 'aq', key: 'repo' } as const, '')
15
16let queueDir = ''
17let lastNudged = 0
18
19async function logEvent($: $, event: Record<string, unknown>) {
20 try {
21 const home = await $.env.get('HOME')
22 const line = JSON.stringify({ at: new Date().toISOString(), session: await $.session.id(), queue: queueDir, ...event })
23 await $.process.run(
24 ['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', `${home}/.claude/mod-logs/aq.jsonl`],
25 { stdin: `${line}\n` },
26 )
27 } catch {
28 // Logging is best effort.
29 }
30}
31
32function text(body: string, isError = false) {
33 return { result: { content: [{ type: 'text' as const, text: body }], isError } }
34}
35
36// Same rule as the zsh `aq`: the queue sits beside the git common dir, so worktrees share it.
37async function resolveQueue($: $) {
38 const common = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
39 cwd: await $.session.cwd(),
40 })
41 if (common.exitCode !== 0) {
42 queueDir = ''
43 await update($, repo, () => '')
44 return
45 }
46 const root = common.stdout.trim().replace(/\/+$/, '').replace(/\/[^/]+$/, '')
47 queueDir = `${root}/.queue`
48 await update($, repo, () => root.split('/').pop() ?? '')
49}
50
51async function queuedFiles($: $): Promise<string[]> {
52 if (!queueDir || !(await $.fs.exists(queueDir))) return []
53 const entries = await $.fs.list(queueDir)
54 return entries
55 .filter(entry => entry.kind === 'file' && ITEM_FILE.test(entry.name))
56 .map(entry => entry.name)
57 .sort()
58}
59
60async function refresh($: $) {
61 await resolveQueue($)
62 const files = await queuedFiles($)
63 const loaded: QueueItem[] = []
64 for (const file of files) {
65 try {
66 loaded.push(toItem(file, await $.fs.read(`${queueDir}/${file}`)))
67 } catch {
68 // Gone between list and read: `aq next` ran in another shell.
69 }
70 }
71 await update($, items, () => loaded)
72 await update($, selected, list => list.filter(file => files.includes(file)))
73 return loaded
74}
75
76async function add($: $, body: string, source: string): Promise<string> {
77 await resolveQueue($)
78 if (!queueDir) throw new Error('not inside a git repo')
79 const trimmed = body.trim()
80 if (!trimmed) throw new Error('nothing to queue')
81
82 const existing = (await $.fs.exists(queueDir)) ? (await $.fs.list(queueDir)).map(entry => entry.name) : []
83 const file = `${nextSeq(existing)}-${slugify(trimmed)}.md`
84 await $.fs.write(`${queueDir}/${file}`, `${trimmed}\n`)
85 void logEvent($, { event: 'add', source, file })
86 await refresh($)
87 return file
88}
89
90async function archive($: $, files: readonly string[]) {
91 if (!queueDir || files.length === 0) return
92 await $.process.run(['mkdir', '-p', `${queueDir}/done`])
93 await $.process.run(['mv', ...files.map(file => `${queueDir}/${file}`), `${queueDir}/done/`])
94 void logEvent($, { event: 'archive', files })
95 await refresh($)
96}
97
98async function fillSelected($: $) {
99 const picked = await read($, selected)
100 const queued = (await read($, items)).filter(item => picked.includes(item.file))
101 if (queued.length === 0) {
102 $.ui.toast('Nothing selected')
103 return
104 }
105
106 const bodies: string[] = []
107 const nextPending: PendingItem[] = []
108 for (const item of queued) {
109 const body = await $.fs.read(`${queueDir}/${item.file}`)
110 bodies.push(body)
111 nextPending.push({ file: item.file, anchor: anchorOf(body) })
112 }
113
114 const filled = await $.prompt.fill({ text: joinBatch(bodies), mode: 'replace' })
115 if (!filled.isFilled) {
116 $.ui.toast('Could not fill the prompt box')
117 return
118 }
119 await update($, pending, () => nextPending)
120 await update($, selected, () => [])
121 void logEvent($, { event: 'fill', files: nextPending.map(item => item.file) })
122 await $.ui.close({ id: PANE })
123}
124
125async function queueDraft($: $): Promise<string> {
126 const { text: draft } = await $.prompt.read()
127 if (!draft.trim()) return 'Prompt box is empty.'
128 const file = await add($, draft, 'draft')
129 await $.prompt.fill({ text: '', mode: 'replace' })
130 return `Queued ${file}`
131}
132
133async function listing($: $): Promise<string> {
134 const queued = await refresh($)
135 if (!queueDir) return 'Not inside a git repo.'
136 if (queued.length === 0) return 'Queue empty.'
137 return queued.map(item => `${item.seq} ${item.title}`).join('\n')
138}
139
140async function openPane($: $) {
141 await refresh($)
142 await $.ui.open({ id: PANE, title: 'aq', focus: true })
143}
144
145export const register: Register = on => {
146 on('session.start', async ($, e, next) => {
147 const started = await next(e)
148 await $.command.register({
149 name: 'aq',
150 description: 'Prompt queue: open the picker, list, or add <text> (no text queues the prompt box draft)',
151 argumentHint: '[ls | add [text] | done <seq…>]',
152 })
153 await $.tool.register({
154 name: 'aq_add',
155 description:
156 "Queues a prompt in this repo's aq queue (<repo>/.queue) for a later session; returns the file name. Use when the user asks to queue, park or defer something for later, or to record an out-of-scope follow-up you'd otherwise suggest at the end. Never use it to put off work the user asked for now. Write the prompt self-contained: a later session reads it with no memory of this one.",
157 inputSchema: {
158 type: 'object',
159 properties: { text: { type: 'string', description: 'The prompt. First line is its title in the queue.' } },
160 required: ['text'],
161 },
162 })
163 await refresh($)
164 return started
165 })
166
167 on('turn.complete', async ($, e, next) => {
168 const done = await next(e)
169 const count = (await refresh($)).length
170 if (count > 0 && count !== lastNudged) $.ui.toast(`${count} queued: /aq to pick`)
171 lastNudged = count
172 return done
173 })
174
175 on('prompt.submit', async ($, e, next) => {
176 const waiting = await read($, pending)
177 const origin = e.origin?.kind
178 if (waiting.length === 0 || (origin !== 'composer' && origin !== 'bridge')) return next(e)
179
180 const kept = keptItems(e.text, waiting)
181 const entered = await next(e)
182 const dropped = typeof entered.drop === 'string'
183 await update($, pending, () => [])
184 void logEvent($, { event: 'submit', origin, dropped, waiting: waiting.map(item => item.file), kept })
185 if (!dropped) await archive($, kept)
186 return entered
187 }).catch(($, e, next) => {
188 void logEvent($, { event: 'submit-error', origin: e.origin?.kind })
189 return next(e)
190 })
191
192 on('tool.call', { tool: 'mcp__aq__aq_add' }, async ($, e) => {
193 try {
194 const file = await add($, String(e.text ?? ''), 'model')
195 return text(`Queued ${file}`)
196 } catch (error) {
197 return text(`aq_add failed: ${(error as Error).message}`, true)
198 }
199 }).catch(() => text('aq_add failed', true))
200
201 on('command.run', { command: 'aq' }, async ($, e) => {
202 const args = e.args.trim()
203 const [verb = ''] = args.split(/\s+/, 1)
204
205 if (verb === 'ls' || verb === 'l') return { text: await listing($) }
206 if (verb === 'add' || verb === 'a') {
207 const body = args.slice(verb.length).trim()
208 try {
209 return { text: body ? `Queued ${await add($, body, 'command')}` : await queueDraft($) }
210 } catch (error) {
211 return { text: `aq: ${(error as Error).message}` }
212 }
213 }
214 if (verb === 'done' || verb === 'rm') {
215 const rest = args.slice(verb.length).trim()
216 if (!rest) return { text: 'Usage: /aq done <seq…>' }
217 const queued = await refresh($)
218 if (!queueDir) return { text: 'Not inside a git repo.' }
219 const files = seqsToFiles(rest, queued)
220 if (files.length === 0) return { text: 'No matching items.' }
221 await archive($, files)
222 return { text: `Archived ${files.join(', ')}` }
223 }
224 if (verb) return { text: 'Usage: /aq [ls | add [text] | done <seq…>]' }
225
226 await openPane($)
227 return { text: queueDir ? 'aq picker opened.' : 'Not inside a git repo.' }
228 })
229
230 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231 const queued = await read($, items)
232 const name = await read($, repo)
233 if (!name || queued.length === 0 || e.props.hasSurvey) return next(e)
234
235 const { Box, Text, Button } = $.ui.resolve(e)
236 return (
237 <Box flexDirection="row" gap={1}>
238 <Text color="suggestion" bold>
239 ⏵ {queued.length} queued
240 </Text>
241 <Button key="open" label="Open" plain dimColor onPress={() => void openPane($)} />
242 </Box>
243 )
244 })
245
246 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
247 const { Box, Text, Button } = $.ui.resolve(e)
248 const queued = await read($, items)
249 const picked = await read($, selected)
250 const name = await read($, repo)
251 const width = Math.max(20, (e.viewport?.columns ?? 80) - 8)
252
253 const toggle = (file: string) =>
254 void update($, selected, list => (list.includes(file) ? list.filter(f => f !== file) : [...list, file]))
255 const toggleAll = () =>
256 void update($, selected, list => (list.length === queued.length ? [] : queued.map(item => item.file)))
257 const archiveSelected = async () => {
258 const count = picked.length
259 await archive($, picked)
260 await update($, selected, () => [])
261 $.ui.toast(`Archived ${count}`)
262 }
263 const addDraft = async () => $.ui.toast(await queueDraft($).catch(error => `aq: ${(error as Error).message}`))
264
265 if (!name) return <Text dimColor>Not inside a git repo.</Text>
266
267 return (
268 <Box flexDirection="column">
269 <Text dimColor>
270 {name} · {queued.length} queued{picked.length > 0 ? ` · ${picked.length} selected` : ''}
271 </Text>
272 {queued.length === 0 && <Text dimColor>Queue empty. /aq add <text>, or d to queue the prompt box.</Text>}
273 {queued.map((item, index) => (
274 <Box flexDirection="column">
275 <Button
276 key={`toggle-${item.file}`}
277 label={truncate(`${picked.includes(item.file) ? '[x]' : '[ ]'} ${item.seq} ${item.title}`, width)}
278 hotkey={index < 9 ? String(index + 1) : undefined}
279 plain
280 onPress={() => toggle(item.file)}
281 />
282 {item.preview && <Text dimColor>{truncate(` ${item.preview}`, width)}</Text>}
283 </Box>
284 ))}
285 <Box flexDirection="row" gap={2} marginTop={1}>
286 <Button
287 key="fill"
288 label={`Fill prompt (${picked.length})`}
289 hotkey="f"
290 variant="primary"
291 onPress={() => void fillSelected($)}
292 />
293 {picked.length > 0 && (
294 <Button
295 key="done"
296 label={`Done (${picked.length})`}
297 hotkey="x"
298 onPress={() => void archiveSelected()}
299 />
300 )}
301 {queued.length > 0 && (
302 <Button
303 key="all"
304 label={picked.length === queued.length ? 'None' : 'All'}
305 hotkey="a"
306 onPress={toggleAll}
307 />
308 )}
309 <Button key="draft" label="Add draft" hotkey="d" onPress={() => void addDraft()} />
310 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
311 </Box>
312 </Box>
313 )
314 })
315}
316hooks/queue.ts 74 lines1import type { PendingItem, QueueItem } from '../types'
2
3export const ITEM_FILE = /^(\d{3})-.*\.md$/
4
5// The separator `aq next` puts between batched prompts.
6export const BATCH_SEPARATOR = '\n\n---\n\n'
7
8// Mirrors the zsh slug: first line, lowercased, non-alnum runs to `-`, trimmed, cut to 40.
9export function slugify(body: string): string {
10 const firstLine = body.split('\n', 1)[0] ?? ''
11 const slug = firstLine
12 .toLowerCase()
13 .replace(/[^a-z0-9]+/g, '-')
14 .replace(/^-/, '')
15 .replace(/-$/, '')
16 .slice(0, 40)
17 return slug || 'task'
18}
19
20export function nextSeq(names: readonly string[]): string {
21 let max = 0
22 for (const name of names) {
23 const match = ITEM_FILE.exec(name)
24 if (match) max = Math.max(max, Number(match[1]))
25 }
26 return String(max + 1).padStart(3, '0')
27}
28
29export function firstLineOf(body: string): string {
30 return (body.split('\n', 1)[0] ?? '').trim()
31}
32
33export function anchorOf(body: string): string {
34 return firstLineOf(body).toLowerCase().replace(/\s+/g, ' ').slice(0, 30)
35}
36
37export function toItem(file: string, body: string): QueueItem {
38 const [, rest = ''] = /^[^\n]*\n?([\s\S]*)$/.exec(body) ?? []
39 return {
40 file,
41 seq: file.slice(0, 3),
42 title: firstLineOf(body).replace(/^#*\s*/, ''),
43 preview: rest.split('\n').map(line => line.trim()).filter(Boolean).join(' '),
44 }
45}
46
47export function joinBatch(bodies: readonly string[]): string {
48 return bodies.map(body => body.trimEnd()).join(BATCH_SEPARATOR)
49}
50
51// Items whose anchor survived the person's edits, so deleting one from the draft keeps it queued.
52export function keptItems(submitted: string, pending: readonly PendingItem[]): string[] {
53 const normalized = submitted.toLowerCase().replace(/\s+/g, ' ')
54 return pending.filter(item => item.anchor && normalized.includes(item.anchor)).map(item => item.file)
55}
56
57export function seqsToFiles(args: readonly string[] | string, items: readonly QueueItem[]): string[] {
58 const tokens = typeof args === 'string' ? args.trim().split(/\s+/).filter(Boolean) : args
59 const files: string[] = []
60 for (const token of tokens) {
61 if (!token) continue
62 const seq = token.padStart(3, '0')
63 const match = items.find(item => item.seq === seq)
64 if (match && !files.includes(match.file)) {
65 files.push(match.file)
66 }
67 }
68 return files
69}
70
71export function truncate(text: string, width: number): string {
72 return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
73}
74types/index.d.ts 23 lines1export type QueueItem = {
2 file: string
3 seq: string
4 title: string
5 preview: string
6}
7
8export type PendingItem = {
9 file: string
10 anchor: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 aq: {
16 items: QueueItem[]
17 selected: string[]
18 pending: PendingItem[]
19 repo: string
20 }
21 }
22}
23