Queue follow-up prompts that run only after the agent fully finishes its turn

Skills for designers, builders & engineers. Install them into Claude Code as a plugin, or into any agent with the skills CLI.
box-shadow CSS (the shadows.brumm.af / Tobias Ahlin technique) for soft, realistic elevation. Outputs ready-to-paste CSS or Tailwind values.extras/) since Claude Code has native ultracode workflows; still installs everywhere via the skills CLI.yt-dlp, save cached transcript artifacts, and inspect previews/search results/excerpts without loading full transcripts into agent context.yt-dlp catalogs, metadata ranking, transcript hydration, and local transcript ranking.yt-dlp for a single video or a whole channel, save cached transcript artifacts, and fall back to local mlx-whisper transcription (Apple Silicon) when a video has no native captions.yt-dlp, gh, and keyless HTTP. Inspired by mvanhorn/last30days-skill..agents/business-context.md file and a rule base distilled from 63 Wes McDowell videos (2018–2026), with structure ideas adapted from coreyhaines31/marketingskills (MIT). Optional Belgium/EU layer for language, consent, reviews, and legal footer rules.claude-codex/); not part of dedene-skills.ctrl+x enter while the agent works, or type /later <prompt>, and the prompt waits until the turn has fully finished, background agents included. Plain Enter still steers the running turn. Separate plugin (mods/followup/)./plugin marketplace add dedene/skills
/plugin install dedene-skills@dedene
The skills are then available as /dedene-skills:smooth-shadows, /dedene-skills:autopilot-interview, /dedene-skills:architect-loop, /dedene-skills:youtube-transcript, /dedene-skills:youtube-channel-search, /dedene-skills:tiktok-transcript, /dedene-skills:idea-to-visual, /dedene-skills:site-brief, /dedene-skills:conversion-audit, /dedene-skills:page-copy, and /dedene-skills:lead-offer.
The ultracode skill is intentionally not part of dedene-skills — Claude Code ships native ultracode workflows. For other hosts, it is available as a separate plugin:
/plugin install ultracode@dedene
The Codex delegation/review bridge is intentionally Claude/Fable-only for now:
/plugin install codex-bridge@dedene
/plugin install followup@dedene
| Do | Effect |
|---|---|
ctrl+x enter during a turn | Queues what you typed instead of sending it into the running turn |
/later <prompt> | Queues a prompt; sends it right away if the agent is idle |
/later | Lists the queue |
/later run, pause, drop [n], clear | Resume, hold, remove one item (default the last), empty |
Follow-ups go out one per finished turn, in order, as your own message. A band above the prompt shows what's waiting. If you interrupt a turn or it errors, the queue pauses until /later run. The queue holds text only (a prompt with a pasted image goes into the running turn as usual) and lasts for the session.
Mods are an early-access Claude Code API; this one is built and tested on 2.1.294. Run its tests with claude plugin test mods/followup.
Works with Claude Code, Cursor, Codex, and other agents via vercel-labs/skills:
npx skills add dedene/skills
To grab just one skill:
npx skills add dedene/skills --skill smooth-shadows
npx skills add dedene/skills --skill ultracode
npx skills add dedene/skills --skill autopilot-interview
npx skills add dedene/skills --skill architect-loop
npx skills add dedene/skills --skill youtube-transcript
npx skills add dedene/skills --skill youtube-channel-search
npx skills add dedene/skills --skill tiktok-transcript
npx skills add dedene/skills --skill idea-to-visual
npx skills add dedene/skills --skill site-brief
npx skills add dedene/skills --skill conversion-audit
npx skills add dedene/skills --skill page-copy
npx skills add dedene/skills --skill lead-offer
config/skill-sources.json records the canonical source and existing installation targets for each owned skill across this repository and prompt-engineering-club. This repository owns smooth-shadows; its club copy is a sync target. Skills without targets remain uninstalled.
This manifest and config/AGENTS.md are Peter's machine-specific configuration, not team installation defaults. Teammates should supply their own source/target manifest with --manifest <file>. Shared MCP skills accept MCPORTER_BIN as an executable path; configure any host-required credential wrapper there, otherwise they use the installed mcporter command.
Run from this repository:
python3 scripts/sync-skills.py --check
python3 scripts/sync-skills.py --plan /tmp/skill-sync-plan.json
# Review the plan before applying it.
python3 scripts/sync-skills.py --apply /tmp/skill-sync-plan.json
Apply verifies source and destination hashes before writing, backs up replaced files, and preserves destination-only files. Reconcile unexpected drift in the canonical source before creating a new plan.
config/AGENTS.md is Peter's canonical personal instruction base; the Compound plugin appends its generated tool map when installed. config/screenshots.md holds the conditional screenshot recipe. The club maintains its separate team instruction base.
config/disabled-upstream-skills.json records excluded duplicate Superpowers entries and broad bootstrap skills. Their matching [[skills.config]] entries in ~/.codex/config.toml use enabled = false; upstream bodies remain untouched. Recheck versioned plugin paths after upgrades and restart Codex after configuration changes. Re-enable a disabled entry deliberately when needed. Owned architect-loop, autopilot-interview, and ultracode instead use allow_implicit_invocation: false, retaining explicit invocation.
MIT — see LICENSE.
hooks/register.tsx 219 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FollowupQueue } from '../types'
5
6const queue = atom({ plugin: 'followup', key: 'queue' } as const, [] as FollowupQueue)
7const isPaused = atom({ plugin: 'followup', key: 'isPaused' } as const, false)
8const isBusy = atom({ plugin: 'followup', key: 'isBusy' } as const, false)
9
10// Agent loops that are still working; a follow-up waits for them too.
11const LIVE_AGENT = new Set(['pending', 'running', 'waiting'])
12const BAND_ROWS = 3
13const DISPATCH_DELAY_MS = 50
14
15const preview = (text: string, max = 80) => {
16 const line = (text.split('\n')[0] ?? '').trim()
17 return line.length > max ? `${line.slice(0, max - 1)}…` : line
18}
19
20const plural = (n: number) => `${n} follow-up${n === 1 ? '' : 's'}`
21
22const hasLiveAgents = async ($: EngineInterface) => {
23 try {
24 const agents = await $.agent.list()
25 return agents.some(agent => LIVE_AGENT.has(agent.status))
26 } catch {
27 return false
28 }
29}
30
31// Sends the head of the queue as a turn of its own, once nothing is running.
32const dispatchNext = async ($: EngineInterface) => {
33 if ((await read($, isPaused)) || (await read($, isBusy))) return
34 const list = await read($, queue)
35 if (list.length === 0) return
36 if (await hasLiveAgents($)) {
37 $.ui.status(`${plural(list.length)} waiting on background agents`)
38 return
39 }
40 $.ui.status(undefined)
41
42 // Pop inside update so an item queued meanwhile is never overwritten.
43 let text: string | undefined
44 await update($, queue, l => {
45 text = l[0]
46 return l.slice(1)
47 })
48 if (text === undefined) return
49 // Claim the session now so a second turn.complete cannot send twice.
50 await update($, isBusy, () => true)
51
52 // Never awaited: inside a hook the session is not idle yet, and the
53 // submission resolves only once its own turn starts.
54 const sent = text
55 void $.prompt.submit({ text: sent, asUser: true }).then(
56 async result => {
57 if (result.drop !== undefined) await update($, isBusy, () => false)
58 },
59 async () => {
60 await update($, queue, l => [sent, ...l])
61 await update($, isBusy, () => false)
62 $.ui.toast('Follow-up could not be sent; it is back at the head of the queue')
63 },
64 ).catch(() => undefined) // the session (or a test) may be gone by now
65}
66
67// The engine refuses $.prompt.submit inside a command or press hook (it would
68// wait on the hook holding the session), so those paths go through a timer.
69const dispatchSoon = ($: EngineInterface) => {
70 $.clock.after(DISPATCH_DELAY_MS, () => void dispatchNext($).catch(() => undefined))
71}
72
73const enqueue = async ($: EngineInterface, text: string) => {
74 const list = await update($, queue, l => [...l, text])
75 return list.length
76}
77
78const listing = (list: FollowupQueue, paused: boolean) => {
79 if (list.length === 0) return 'No follow-ups queued.'
80 const head = `${plural(list.length)} queued${paused ? ' (paused)' : ''}:`
81 return [head, ...list.map((t, i) => ` ${i + 1}. ${preview(t, 120)}`)].join('\n')
82}
83
84const HELP = [
85 '/later <prompt> queue a prompt to run after the agent fully finishes',
86 '/later list the queue',
87 '/later run resume and send the next one when idle',
88 '/later pause hold the queue',
89 '/later drop [n] remove item n (default: the last)',
90 '/later clear empty the queue',
91 'ctrl+x enter during a turn queues what you typed as a follow-up.',
92].join('\n')
93
94export const register: Register = on => {
95 on('session.start', async ($, e, next) => {
96 await $.command.register({
97 name: 'later',
98 description: 'Queue a follow-up prompt for when the agent is fully done',
99 argumentHint: '[prompt | run | pause | drop [n] | clear | help]',
100 immediate: true,
101 })
102 return next(e)
103 })
104
105 // ctrl+x enter (chat:queueSubmit) during a turn: hold it as a follow-up
106 // instead of letting the engine fold it into the running turn.
107 on('prompt.submit', async ($, e, next) => {
108 const isQueueKey = e.wait && e.turnId !== undefined && e.origin.kind === 'composer'
109 const text = e.text.trim()
110 if (!isQueueKey || text === '' || (e.attachments?.length ?? 0) > 0) return next(e)
111
112 const n = await enqueue($, text)
113 $.ui.toast(`Queued as follow-up #${n}`)
114 return { drop: `Queued as follow-up #${n}; it runs when the agent is done.` }
115 })
116
117 on('turn.start', async ($, e, next) => {
118 await update($, isBusy, () => true)
119 return next(e)
120 })
121
122 on('turn.complete', async ($, e, next) => {
123 const result = await next(e)
124 if (e.agentId !== undefined) return result
125
126 await update($, isBusy, () => false)
127 const waiting = (await read($, queue)).length
128 if (waiting === 0) return result
129
130 if (e.reason !== 'answer') {
131 await update($, isPaused, () => true)
132 $.ui.toast(`Turn ended (${e.reason}); ${plural(waiting)} paused. /later run to resume.`)
133 return result
134 }
135
136 await dispatchNext($)
137 return result
138 })
139
140 on('command.run', { command: 'later' }, async ($, e) => {
141 const args = e.args.trim()
142 const [verb = '', ...rest] = args.split(/\s+/)
143
144 if (args === '') {
145 return { text: listing(await read($, queue), await read($, isPaused)) }
146 }
147 if (args === 'help') return { text: HELP }
148 if (args === 'clear') {
149 await update($, queue, () => [])
150 await update($, isPaused, () => false)
151 $.ui.status(undefined)
152 return { text: 'Follow-up queue cleared.' }
153 }
154 if (args === 'pause') {
155 await update($, isPaused, () => true)
156 return { text: 'Follow-ups paused. /later run to resume.' }
157 }
158 if (args === 'run' || args === 'resume') {
159 await update($, isPaused, () => false)
160 const n = (await read($, queue)).length
161 dispatchSoon($)
162 return { text: n === 0 ? 'Nothing queued.' : `Resumed; ${plural(n)} queued.` }
163 }
164 if (verb.toLowerCase() === 'drop' && rest.length <= 1 && rest.every(n => /^\d+$/.test(n))) {
165 const list = await read($, queue)
166 if (list.length === 0) return { text: 'Nothing queued.' }
167 const index = rest[0] === undefined ? list.length - 1 : Number(rest[0]) - 1
168 if (index < 0 || index >= list.length) return { text: `No follow-up #${index + 1}.` }
169 await update($, queue, l => l.filter((_, i) => i !== index))
170 return { text: `Dropped follow-up #${index + 1}: ${preview(list[index] ?? '')}` }
171 }
172
173 const n = await enqueue($, args)
174 const isIdle = !(await read($, isBusy)) && !(await read($, isPaused))
175 if (isIdle) dispatchSoon($)
176 return { text: isIdle ? 'Agent idle; sending it now.' : `Queued as follow-up #${n}.` }
177 })
178
179 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180 const list = await read($, queue)
181 if (e.props.hasSurvey || list.length === 0) return next(e)
182
183 const paused = await read($, isPaused)
184 const { Box, Button, Text } = $.ui.resolve(e)
185 const width = Math.max(20, e.props.bodyColumns - 6)
186 const state = paused ? 'paused' : e.props.isWorking ? 'after this turn' : 'next up'
187
188 return (
189 <Box flexDirection="column">
190 <Box flexDirection="row" gap={1}>
191 <Text color={paused ? 'warning' : 'suggestion'}>
192 ⏳ {plural(list.length)} · {state}
193 </Text>
194 {paused ? (
195 <Button key="resume" label="Resume" onPress={async () => {
196 await update($, isPaused, () => false)
197 dispatchSoon($)
198 }} />
199 ) : (
200 <Button key="pause" label="Pause" onPress={() => update($, isPaused, () => true)} />
201 )}
202 <Button key="clear" label="Clear" onPress={async () => {
203 await update($, queue, () => [])
204 await update($, isPaused, () => false)
205 }} />
206 </Box>
207 {list.slice(0, BAND_ROWS).map((t, i) => (
208 <Text key={`item-${i}`} dimColor wrap="truncate-end">
209 {' '}{i + 1}. {preview(t, width)}
210 </Text>
211 ))}
212 {list.length > BAND_ROWS && (
213 <Text dimColor> … {list.length - BAND_ROWS} more (/later to list)</Text>
214 )}
215 </Box>
216 )
217 })
218}
219types/index.d.ts 8 lines1export type FollowupQueue = string[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 followup: { queue: FollowupQueue; isPaused: boolean; isBusy: boolean }
6 }
7}
8