SLOPSHOPPER

aq

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-13daviddecorso/claude-mods/aq
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aq
│ ┃ aq ✕ › fix the failing auth test and add an audit log call │ ┃ a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 · 0 │ ┃ queued ⏺ Read(src/auth.ts) │ ┃ Queue empty. /aq add <text>, or d to queue ⎿ Read 6 lines │ ┃ the prompt box. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Fill prompt (0) ] [ Add draft ] [ Refres ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /aq │ ⎿ aq: aq picker opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · aq
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 · 0 queued Queue empty. /aq add <text>, or d to queue the prompt box. [ Fill prompt (0) ] [ Add draft ] [ Refresh ]
README

claude-mods

Function-hook plugins for Claude Code. Each one installs on its own.

Install

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.

shell-edit-guard

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

dev-servers

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

handoff

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

aq

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

Developing

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.

License

MIT

Source 3 files
hooks/register.tsx 316 lines
1import { 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 &lt;text&gt;, 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}
316
hooks/queue.ts 74 lines
1import 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}
74
types/index.d.ts 23 lines
1export 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