A morning band for the newest autodream report: shown only when it has open questions or a medium/high pattern, dismissed once per report, with /dream to read…

A Claude Code mod that puts the newest autodream report in front of you, and lets you triage its open questions without leaving Claude Code. It is optional: the nightly run and review.sh work the same with or without it.
When the newest report is a day or two old, is not triaged yet, and has open questions or a medium/high pattern, a band appears above the prompt:
🌙 Autodream 2026-10-01: 2 open questions · top pattern: Editing files before reading them [Triage] [View] [Dismiss]
review.sh in a fresh Claude session in a cmux split to the right of the current one, so the conversation you were in stays about what it was about.The band also goes away on its own once the report has a ## Triage decisions section, which is how a finished triage ends, and it is re-checked at session start, every 30 minutes and after every turn.
claude --plugin-dir /path/to/cc-autodream/mods/autodream-band
Or add the folder to CLAUDE_CODE_PLUGIN_DIRS to load it in every session.
/dream| Command | What it does |
|---|---|
/dream | Open the newest report's key sections in a pane. |
/dream triage | Run review.sh in a cmux split beside this session. |
/dream here | Triage in this session: the same walk-through, one question at a time, under this session's permission mode. |
/dream cmux | Run review.sh so it opens its own cmux workspace. |
Add force to triage a report that has no open questions or is already triaged, as review.sh --force does.
/dream triage degrades rather than fails. If the split's address cannot be read from what cmux new-split prints, or the command cannot be sent into it, review.sh opens a cmux workspace instead. Outside cmux there is nothing to split, so it opens the workspace directly. With no cmux or no review.sh it triages in the current session.
The mod reads the variables bin/review.sh reads, with the same defaults:
| Variable | Default |
|---|---|
DREAMS_DIR | ~/.claude/dreams |
AUTODREAM_DIR | ~/.claude/autodream (where review.sh is) |
CMUX_BIN | /Applications/cmux.app/Contents/Resources/bin/cmux |
A value that lives only in $AUTODREAM_DIR/config is invisible to the mod, because the mod cannot source a shell file. Export it in the environment Claude Code starts from if you moved something there.
The mod parses the report prompts/PROMPT.md tells Layer 2 to write, so these are a contract. If one changes, change hooks/lib.ts and its tests in the same commit.
# Autodream — YYYY-MM-DD.## Top patterns, each pattern a ### <title> with a - Severity: high|medium|low line.## Open questions, ended by <!-- autodream:open-questions=N -->. A report with no marker counts as zero questions.## Triage decisions heading once a triage has run.claude plugin validate mods/autodream-band
claude plugin test mods/autodream-band
For tsc -p mods/autodream-band Claude Code has to have loaded the mod once: it writes the type definitions into .claude-plugin/types/, which is git-ignored. CI does not run these, because they need Claude Code itself.
hooks/register.tsx 290 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DreamReport } from '../types'
5import {
6 cmuxTriage,
7 isFresh,
8 keySections,
9 latestReport,
10 locations,
11 parseReport,
12 shouldShow,
13 surfaceRef,
14 triageCommandLine,
15 triagePrompt,
16} from './lib'
17
18const PANE = 'dream'
19
20const report = atom({ plugin: 'autodream-band', key: 'report' } as const, null)
21const viewing = atom({ plugin: 'autodream-band', key: 'viewing' } as const, null)
22const isHidden = atom({ plugin: 'autodream-band', key: 'isHidden' } as const, false)
23
24/** Where the reports, review.sh and cmux are: review.sh's own environment knobs, then its defaults. */
25const where = async ($: EngineInterface) => {
26 const [HOME, AUTODREAM_DIR, DREAMS_DIR, CMUX_BIN] = await Promise.all([
27 $.env.get('HOME'),
28 $.env.get('AUTODREAM_DIR'),
29 $.env.get('DREAMS_DIR'),
30 $.env.get('CMUX_BIN'),
31 ])
32
33 return locations({ HOME, AUTODREAM_DIR, DREAMS_DIR, CMUX_BIN })
34}
35
36/** The newest report on disk, whether or not the band would show it. */
37const load = async ($: EngineInterface): Promise<DreamReport | null> => {
38 try {
39 const { dreams: dir } = await where($)
40 const name = latestReport((await $.fs.list(dir)).map(entry => entry.name))
41
42 if (name === null) return null
43
44 const path = `${dir}/${name}`
45 const text = await $.fs.read(path)
46 const parsed = typeof text === 'string' ? parseReport(text) : null
47
48 return parsed === null || typeof text !== 'string' ? null : { ...parsed, path, sections: keySections(text, 9_500) }
49 } catch {
50 return null
51 }
52}
53
54/** Why there is nothing to show: no report at all, or one the band cannot read (a title it does not recognise). */
55const explainNoReport = async ($: EngineInterface): Promise<string> => {
56 const { dreams } = await where($)
57 const name = await $.fs.list(dreams).then(
58 entries => latestReport(entries.map(entry => entry.name)),
59 () => null,
60 )
61
62 return name === null
63 ? `No autodream report found in ${dreams}.`
64 : `Found ${dreams}/${name}, but it is not in the format the band reads: it needs a "# Autodream — YYYY-MM-DD" title.`
65}
66
67const dismissedDate = async ($: EngineInterface): Promise<string | null> => {
68 const date = await $.store.get('dismissed')
69
70 return typeof date === 'string' ? date : null
71}
72
73const refresh = async ($: EngineInterface) => {
74 const [latest, now, dismissed] = await Promise.all([load($), $.clock.now(), dismissedDate($)])
75
76 await update($, report, () => (latest !== null && isFresh(latest.date, now, 2) && shouldShow(latest, dismissed) ? latest : null))
77}
78
79const dismiss = async ($: EngineInterface) => {
80 const shown = await read($, report)
81
82 if (shown === null) return
83
84 await $.store.set('dismissed', shown.date)
85 await update($, report, () => null)
86}
87
88const open = async ($: EngineInterface, latest: DreamReport) => {
89 await update($, viewing, () => latest)
90 await $.ui.open({ id: PANE, title: 'Autodream', focus: true, closeOnEscape: true })
91}
92
93const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
94
95/** Whether there is anything for a triage to do: review.sh's own rule, which `force` overrides. */
96const nothingToTriage = (latest: DreamReport, isForced: boolean): string | null => {
97 if (isForced) return null
98
99 if (latest.isTriaged) return `${latest.date} is already triaged. /dream triage force opens it again.`
100
101 return latest.openQuestions === 0 ? `${latest.date} has no open questions, so there is nothing to triage. /dream triage force starts anyway.` : null
102}
103
104/**
105 * Starts review.sh's walk-through in this session. The prompt goes from a timer: the call resolves when the new turn
106 * starts, and a call begun in a dispatch (a press, a command) is dropped when that dispatch ends.
107 */
108const triageHere = ($: EngineInterface, latest: DreamReport, isForced: boolean): string => {
109 const blocked = nothingToTriage(latest, isForced)
110
111 if (blocked !== null) return blocked
112
113 $.clock.after(100, () => {
114 $.prompt.submit({ text: triagePrompt(latest.date, latest.path) }).catch(() => {})
115 })
116
117 return `Starting triage of the ${latest.date} report here: one open question at a time.`
118}
119
120/** Opens review.sh's own triage session in a cmux workspace and reports what the script said. */
121const triageInCmux = async ($: EngineInterface, latest: DreamReport, isForced: boolean): Promise<string> => {
122 const { script } = await where($)
123 const { argv, env } = cmuxTriage(script, latest.date, isForced)
124
125 if (!(await $.fs.exists(script))) {
126 return `autodream's review.sh was not found at ${script}. Install cc-autodream, or use /dream triage to do it in this session.`
127 }
128
129 try {
130 const { exitCode, stdout, stderr } = await $.process.run(argv, { env, timeoutMs: 20_000 })
131 const said = (exitCode === 0 ? stdout : stderr || stdout).trim().slice(0, 400)
132
133 return exitCode === 0 ? said || 'review.sh ran.' : `review.sh exited ${exitCode}: ${said}`
134 } catch (cause) {
135 return `review.sh could not run: ${cause instanceof Error ? cause.message : String(cause)}`
136 }
137}
138
139/**
140 * The default way to triage: review.sh's own session in a split beside this one, so this conversation stays about
141 * what it was about. cmux's `new-split` and `send` default to the surface this session runs in. Every way it can
142 * go wrong has a fallback: no readable split address or a failed send opens a cmux workspace instead; outside cmux
143 * there is nothing to split, so review.sh opens the workspace itself; no cmux or no review.sh means this session.
144 */
145const triageBeside = async ($: EngineInterface, latest: DreamReport, isForced: boolean): Promise<string> => {
146 const blocked = nothingToTriage(latest, isForced)
147
148 if (blocked !== null) return blocked
149
150 const { script, cmux } = await where($)
151
152 if (!(await $.fs.exists(cmux))) {
153 return `cmux was not found, so triage runs in this session. ${triageHere($, latest, isForced)}`
154 }
155
156 if (!(await $.fs.exists(script))) {
157 return `autodream's review.sh was not found at ${script}, so triage runs in this session. ${triageHere($, latest, isForced)}`
158 }
159
160 const surface = await $.env.get('CMUX_SURFACE_ID')
161
162 if (surface === undefined || surface === '') return triageInCmux($, latest, isForced)
163
164 try {
165 const made = await $.process.run([cmux, 'new-split', 'right', '--focus', 'true'], { timeoutMs: 15_000 })
166 const ref = made.exitCode === 0 ? surfaceRef(made.stdout) : null
167
168 if (ref !== null) {
169 // cmux reads a backslash-n in the text as Enter.
170 const typed = await $.process.run([cmux, 'send', '--surface', ref, '--', `${triageCommandLine(script, latest.date, isForced)}\\n`], {
171 timeoutMs: 15_000,
172 })
173
174 if (typed.exitCode === 0) return `Opened the ${latest.date} triage in a split to the right: a fresh session with the report preloaded.`
175 }
176 } catch {
177 // fall through to the workspace
178 }
179
180 const said = await triageInCmux($, latest, isForced)
181
182 return `Could not open a split beside this session, so ${said.charAt(0).toLowerCase()}${said.slice(1)}`
183}
184
185export const register: Register = on => {
186 on('session.start', async ($, e, next) => {
187 await $.command.register({
188 name: 'dream',
189 description: 'Read the newest autodream report; /dream triage opens its open questions in a split beside this session',
190 argumentHint: '[triage|here|cmux] [force]',
191 })
192 await refresh($)
193 // A session can outlive the night; check again for a fresh report.
194 $.clock.every(30 * 60_000, () => refresh($))
195
196 return next(e)
197 })
198
199 // A triage ends by logging its decisions into the report; the turn that ends it clears the band.
200 on('turn.complete', async ($, e, next) => {
201 await refresh($)
202
203 return next(e)
204 })
205
206 on('command.run', { command: 'dream' }, async ($, e) => {
207 const [action = '', ...flags] = e.args.trim().split(/\s+/)
208 const latest = await load($)
209
210 if (latest === null) return { text: await explainNoReport($) }
211
212 const isForced = flags.includes('force')
213
214 if (action === 'triage') return { text: await triageBeside($, latest, isForced) }
215
216 if (action === 'here') return { text: triageHere($, latest, isForced) }
217
218 if (action === 'cmux') return { text: await triageInCmux($, latest, isForced) }
219
220 await open($, latest)
221
222 return {
223 text: `Autodream ${latest.date}: ${plural(latest.openQuestions, 'open question')}, ${plural(latest.patterns.length, 'pattern')}${latest.isTriaged ? ', triaged' : ''}. Opened in the pane.`,
224 }
225 })
226
227 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
228 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
229 const shown = await read($, viewing)
230
231 if (shown === null) return <Text dimColor>No report loaded.</Text>
232
233 const canTriage = !shown.isTriaged && shown.openQuestions > 0
234
235 return (
236 <Box flexDirection="column">
237 <Markdown text={shown.sections === '' ? `No patterns or questions in the ${shown.date} report.` : shown.sections} />
238 {shown.isTriaged && <Text dimColor>Triaged: decisions are logged at the bottom of the report.</Text>}
239 <Box gap={2}>
240 {canTriage && (
241 <Button
242 key="triage"
243 label="Triage in a side window"
244 variant="primary"
245 hotkey="t"
246 onPress={async () => $.ui.toast(await triageBeside($, shown, false))}
247 />
248 )}
249 {canTriage && <Button key="here" label="Triage here" hotkey="h" onPress={() => $.ui.toast(triageHere($, shown, false))} />}
250 <Button key="close" label="Close" role="dismiss" hotkey="c" onPress={() => $.ui.close({ id: PANE })} />
251 </Box>
252 </Box>
253 )
254 })
255
256 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
257 const shown = await read($, report)
258
259 if (e.props.hasSurvey || shown === null || (await read($, isHidden))) return next(e)
260
261 const { Box, Button, Text } = $.ui.resolve(e)
262 const top = shown.patterns[0]
263 const parts = [
264 ...(shown.openQuestions > 0 ? [plural(shown.openQuestions, 'open question')] : []),
265 ...(top === undefined ? [] : [`top pattern: ${top.title}`]),
266 ]
267 // Other plugins and the engine draw in this slot too: stack under them rather than replace them.
268 const beneath = await next(e)
269
270 return (
271 <Box flexDirection="column">
272 {beneath}
273 {/* One row: the summary takes what room the buttons leave and ends in an ellipsis rather than wrapping around them. */}
274 <Box key="band" gap={1}>
275 <Box flexGrow={1} flexShrink={1}>
276 <Text dimColor wrap="truncate-end">{`🌙 Autodream ${shown.date}: ${parts.join(' · ')}`}</Text>
277 </Box>
278 <Box flexShrink={0} gap={1}>
279 {shown.openQuestions > 0 && (
280 <Button key="triage" label="Triage" variant="primary" onPress={async () => $.ui.toast(await triageBeside($, shown, false))} />
281 )}
282 <Button key="view" label="View" onPress={() => open($, shown)} />
283 <Button key="dismiss" label="Dismiss" dimColor onPress={() => dismiss($)} />
284 </Box>
285 </Box>
286 </Box>
287 )
288 })
289}
290hooks/lib.ts 152 lines1import type { DreamPattern, DreamReport } from '../types'
2
3type Parsed = Omit<DreamReport, 'sections' | 'path'>
4
5const DATED = /^(\d{4}-\d{2}-\d{2})\.md$/
6
7/** A report carries `## Triage decisions` once review.sh or a triage session has worked through its questions. */
8export const isTriaged = (text: string): boolean => /^## Triage decisions/m.test(text)
9
10/** The newest YYYY-MM-DD.md among a directory's names; logs and backups are not reports. */
11export const latestReport = (names: readonly string[]): string | null =>
12 names
13 .filter(name => DATED.test(name))
14 .sort()
15 .pop() ?? null
16
17/** One section's body: the lines after its `## ` heading up to the next one. */
18const section = (text: string, heading: RegExp): string | null => {
19 const lines = text.split('\n')
20 const start = lines.findIndex(line => heading.test(line))
21
22 if (start < 0) return null
23
24 const end = lines.findIndex((line, at) => at > start && /^## /.test(line))
25
26 return lines.slice(start + 1, end < 0 ? undefined : end).join('\n')
27}
28
29export const parseReport = (text: string): Parsed | null => {
30 const date = /^# Autodream\s*[—-]\s*(\d{4}-\d{2}-\d{2})/m.exec(text)?.[1]
31
32 if (date === undefined) return null
33
34 const marker = /<!--\s*autodream:open-questions=(\d+)\s*-->/.exec(text)?.[1]
35 const patterns: DreamPattern[] = []
36
37 for (const chunk of (section(text, /^## Top patterns/) ?? '').split(/^### /m).slice(1)) {
38 // Backticks are Markdown in the report and literal noise in a one-line band.
39 const title = (chunk.split('\n')[0]?.trim() ?? '').replace(/`/g, '')
40 const severity = /\*\*Severity\*\*:\s*(\w+)/i.exec(chunk)?.[1]?.toLowerCase() ?? 'unknown'
41
42 if (title !== '') patterns.push({ title, severity })
43 }
44
45 return { date, openQuestions: marker === undefined ? 0 : Number(marker), isTriaged: isTriaged(text), patterns }
46}
47
48/** Reports are nightly: a few days old is news, a week old is not. */
49export const isFresh = (date: string, nowMs: number, maxDays: number): boolean => {
50 const [year = 0, month = 1, day = 1] = date.split('-').map(Number)
51 const today = new Date(nowMs)
52 const dayMs = 86_400_000
53 const age = Math.round(
54 (new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() - new Date(year, month - 1, day).getTime()) / dayMs,
55 )
56
57 return age <= maxDays
58}
59
60/** PROMPT.md asks for high|medium|low; the shipped example report also uses critical, which is worse than high. */
61const WORTH_STOPPING_FOR = ['critical', 'high', 'medium']
62
63/**
64 * The band is for reports worth stopping for: open questions, or a pattern of medium or higher severity. A report
65 * that has been triaged is done, and one that was dismissed stays dismissed.
66 */
67export const shouldShow = (report: Parsed, dismissed: string | null): boolean =>
68 !report.isTriaged &&
69 report.date !== dismissed &&
70 (report.openQuestions > 0 || report.patterns.some(pattern => WORTH_STOPPING_FOR.includes(pattern.severity)))
71
72/**
73 * The walk-through `review.sh` opens a session for, as a prompt for the session you are in. It mirrors
74 * review.sh's system prompt (bin/review.sh) with two differences: the report is read rather than
75 * inlined, and this session's own permission mode applies, not bypassPermissions.
76 */
77export const triagePrompt = (date: string, path: string): string =>
78 [
79 `You are the morning autodream review partner. Triage the open questions from the ${date} autodream report with me, in this session.`,
80 '',
81 `Read the report first: ${path}. Keep it in context; do not dump it back to me.`,
82 '',
83 'Workflow:',
84 '1. Restate ONE open question (in order, from the report\'s "Open questions for the user" section), in full, before asking me for any decision. I have not read the report this morning, so a bare label ("now question 2") is never enough. Carry (a) the question in plain words, (b) every motivation behind it: findings, session counts, evidence quotes and report section numbers, and (c) every potential fix, each with its trade-off. Ask for the decision only after (a), (b) and (c) are on screen.',
85 '2. Say what you checked or could not check since the report was written, if anything.',
86 '3. Recommend one concrete action from (c). Be opinionated; I trust your judgment.',
87 '4. Wait for: approve / modify / skip / discuss.',
88 `5. If approved: execute it. If modified: incorporate the change, confirm, then execute. If skipped or discussed: log the decision as one line under a "## Triage decisions" section at the bottom of ${path} (create it if absent).`,
89 '6. Move to the next question. Do not batch questions.',
90 '',
91 `When every open question is resolved, write a brief summary under "## Triage decisions" in ${path} and stop.`,
92 '',
93 'Rules:',
94 '- Edits to ~/.claude/CLAUDE.md, ~/.claude/rules/*, ~/.claude/docs/guardrails/* and any other global file need my explicit per-edit approval.',
95 '- Be terse everywhere except the question restatement in step 1, which is always complete. One question, one decision, one action, then the next.',
96 ].join('\n')
97
98/** One shell word, whatever is in it. */
99export const shellQuote = (text: string): string => `'${text.replace(/'/g, "'\\''")}'`
100
101/**
102 * What gets typed into the new split: review.sh run inline there, so its triage session fills that split rather than
103 * handing off to yet another cmux workspace.
104 */
105export const triageCommandLine = (script: string, date: string, isForced: boolean): string =>
106 `env AUTODREAM_TRIAGE_SURFACE=inline bash ${shellQuote(script)} ${isForced ? '--force ' : ''}${date}`
107
108/** The address of the split `cmux new-split` made, from what it printed: a short ref (surface:7) or a UUID. */
109export const surfaceRef = (stdout: string): string | null =>
110 /\bsurface:\d+\b/.exec(stdout)?.[0] ?? /\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b/i.exec(stdout)?.[0] ?? null
111
112/** The environment variables review.sh reads for its own locations; the mod reads the same ones. */
113export type AutodreamEnv = Partial<Record<'HOME' | 'AUTODREAM_DIR' | 'DREAMS_DIR' | 'CMUX_BIN', string | undefined>>
114
115/**
116 * Where autodream's pieces are, resolved the way bin/review.sh resolves them: an exported variable wins, an empty one
117 * counts as unset, and the defaults are the ones install.sh and review.sh use. A value set only in
118 * `$AUTODREAM_DIR/config` is invisible here, so export it (or set it in Claude Code's environment) to move the mod too.
119 */
120export const locations = (env: AutodreamEnv) => {
121 const home = env.HOME ?? ''
122
123 return {
124 dreams: env.DREAMS_DIR || `${home}/.claude/dreams`,
125 script: `${env.AUTODREAM_DIR || `${home}/.claude/autodream`}/review.sh`,
126 cmux: env.CMUX_BIN || '/Applications/cmux.app/Contents/Resources/bin/cmux',
127 }
128}
129
130/**
131 * The command that opens review.sh's own triage session in a cmux workspace: the surface and focus come from the
132 * script's documented environment knobs, so nothing in the user's autodream config has to change.
133 */
134export const cmuxTriage = (script: string, date: string, isForced: boolean) => ({
135 argv: ['bash', script, ...(isForced ? ['--force'] : []), date],
136 env: { AUTODREAM_TRIAGE_SURFACE: 'cmux', AUTODREAM_TRIAGE_FOCUS: 'true' },
137 script,
138})
139
140const WANTED = ['Top patterns', 'Open questions']
141
142/** What the pane draws: the sections that ask something of you, within a Markdown element's limit. */
143export const keySections = (text: string, limit: number): string => {
144 const kept = text
145 .split(/^(?=## )/m)
146 .filter(block => WANTED.some(heading => block.startsWith(`## ${heading}`)))
147 .join('\n')
148 .trim()
149
150 return kept.length <= limit ? kept : `${kept.slice(0, limit - 1)}…`
151}
152types/index.d.ts 28 lines1export type DreamPattern = { title: string; severity: string }
2
3/** The newest autodream report, reduced to what the band and pane show. */
4export type DreamReport = {
5 /** YYYY-MM-DD, the night the report covers */
6 date: string
7 /** where the report is on disk, for the triage prompt to point at */
8 path: string
9 openQuestions: number
10 /** true once a triage has logged its decisions into the report */
11 isTriaged: boolean
12 patterns: DreamPattern[]
13 /** the report's key sections as markdown, capped to what a Markdown element draws */
14 sections: string
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'autodream-band': {
20 /** null when there is no report, or it is too old, already triaged or dismissed */
21 report: DreamReport | null
22 /** the report /dream is showing in its pane */
23 viewing: DreamReport | null
24 isHidden: boolean
25 }
26 }
27}
28