SLOPSHOPPER

autodream-band

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…

newpanebandcommandtoastprocess
v0.1.0MITupdated 2026-10-05STRML/autodream/mods/autodream-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autodream-band
│ ┃ dream ✕ › fix the failing auth test and add an audit log call │ ┃ No report loaded. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /dream │ ⎿ autodream-band: No autodream report found in /Users/dev/.claude/ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dream
No report loaded.
README

autodream-band

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]
  • Triage starts 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.
  • View opens the report's top patterns and open questions in a pane.
  • Dismiss hides the band for that report, across sessions. The next night's report brings it back.

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.

Install

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

CommandWhat it does
/dreamOpen the newest report's key sections in a pane.
/dream triageRun review.sh in a cmux split beside this session.
/dream hereTriage in this session: the same walk-through, one question at a time, under this session's permission mode.
/dream cmuxRun 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.

Where it looks

The mod reads the variables bin/review.sh reads, with the same defaults:

VariableDefault
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.

What it depends on in the report

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.

  • The title # 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.
  • A ## Triage decisions heading once a triage has run.

Tests

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.

Source 3 files
hooks/register.tsx 290 lines
1import { 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}
290
hooks/lib.ts 152 lines
1import 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}
152
types/index.d.ts 28 lines
1export 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