SLOPSHOPPER

autodream-focus

A + autodream focus button at the top right of every prompt and reply: press it to tag that turn, and the next nightly autodream run takes a close look at it.

newrowstoast
v0.1.0MITupdated 2026-10-04STRML/autodream/mods/autodream-focus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autodream-focus
⟨Claude Code's own drawing⟩ ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ⟨Claude Code's own drawing⟩ ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
⟨Claude Code's own drawing⟩
Claude's reply
⟨Claude Code's own drawing⟩
README

autodream-focus

A Claude Code mod that puts a + autodream focus button at the top right of every prompt and reply. Press it to tag that turn, and the next nightly autodream run takes a close look at it. It is optional and separate from autodream-band: nothing installs or loads it unless you ask.

> why is the build red?                          + autodream focus
  The lockfile is stale. Run npm ci, then ...
  • The button appears while the pointer is over a row. Press it and it becomes ✓ autodream focus and stays, so a glance down the transcript finds what you marked. Press it again to take the tag back.
  • It sits on the right, drawn over the row rather than beside it, so nothing reflows and a narrow split pane loses no width.
  • Only what you typed gets one (at the terminal or from the phone), plus the assistant's text. A task notification or another session's message is not yours to tag.
  • Clicks reach the transcript in fullscreen mode ("tui": "fullscreen"). On the main screen the rows are already in scrollback and cannot be pressed.

Install

Opt in per session:

claude --plugin-dir /path/to/cc-autodream/mods/autodream-focus

Or add the folder to CLAUDE_CODE_PLUGIN_DIRS (colon-separated, next to autodream-band if you use it) to load it in every session. install.sh does not touch this mod.

What a tag does

Pressing the button adds one line to $AUTODREAM_DIR/tags.jsonl (default ~/.claude/autodream/tags.jsonl):

{"id":"<session>:<row>","session":"…","uuid":"<row>","role":"user|assistant","text":"…","cwd":"…","taggedAt":"2026-10-04T22:15:00.000Z"}

text is the turn, capped at 2,000 characters. taggedAt is UTC and the only clock in the line: the mod has no timezone to speak of, so bin/vault-notes.sh decides which local day a tag belongs to. An evening tag is in that day's report, not the next one's.

bin/vault-notes.sh reads that file as a third capture surface, next to notes.md and the vault inbox. Each tag that was made on or before the date being reported, and that no earlier report has read, becomes a ## note: focus-… block in findings/<date>/operator-notes.md: the quoted turn, the speaker, the project, the session id and the transcript's path, with an instruction to read the turn in context and report on it. L2 already reads that file and has the Read tool, so the prompt needed no change.

A tag is consumed only after a complete report, under the same gates as an inbox note (archive, not collect): a failed run, or a rebuild of an old date, leaves it pending. Consumed tags (id, report date and taggedAt) go in tags-consumed.txt, a ledger this script writes and the mod never does, so a session open at 03:15 cannot race the run. vault-notes.sh status shows both counts.

vault-notes.sh needs jq for this. Without it the tags are reported as UNREADABLE in the operator notes and nothing is consumed.

Where it looks

VariableDefault
AUTODREAM_DIR~/.claude/autodream (where tags.jsonl is)
AUTODREAM_TAGS_FILE$AUTODREAM_DIR/tags.jsonl (moves the file itself, as it does for bin/vault-notes.sh)

A value set 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 the install.

Limits

  • Claude Code transcripts only. OMP sessions have no focus button.
  • tags.jsonl only grows, at most about 2 KB a tag, and Claude Code will not read a file over 4 MiB for a mod. Past that the button says it cannot read the file and changes nothing, rather than rewriting it from empty. Prune it by hand (and tags-consumed.txt with it) long before then.
  • Every press rewrites the file from what is on disk, so two open sessions mostly keep each other's tags, but the write is not an atomic rename and the gap between its read and its write is not closed.
  • Whether a row's id is the transcript's own uuid is not guaranteed by the mod API, so a note points at the session and quotes the turn instead of relying on the id to find it.

Tests

claude plugin validate mods/autodream-focus
claude plugin test mods/autodream-focus

The vault-notes.sh side is covered by the test_focus_tag_* cases in tests/run-all.sh. For tsc -p mods/autodream-focus 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 the mod's tests, because they need Claude Code itself.

Source 3 files
hooks/register.tsx 151 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { FocusTag } from '../types'
5import type { TagFile } from './focus-lib'
6import { clip, parseFile, serializeFile, tagId, tagsFile, TEXT_CAP, toggleTag } from './focus-lib'
7
8/**
9 * Plain text, not an icon: a glyph's width is a guess the engine and the terminal make separately (the emoji eye with
10 * its selector drew as an ellipsis, and a bare one was small), and words cannot be measured wrong. The check mark is
11 * one cell in every terminal.
12 */
13const LABEL = 'autodream focus'
14const OFF = `+ ${LABEL}`
15const ON = `✓ ${LABEL}`
16/** The label plus the padding a plain Button gives it, with room to spare: squeezed below that, it truncates to an ellipsis. */
17const WIDTH = OFF.length + 5
18
19const tagged = atom({ plugin: 'autodream-focus', key: 'tagged' } as const, [])
20
21type Ui = ReturnType<EngineInterface['ui']['resolve']>
22type Turn = { uuid: string; role: FocusTag['role']; text: string }
23
24const where = async ($: EngineInterface): Promise<string> => {
25  const [HOME, AUTODREAM_DIR, AUTODREAM_TAGS_FILE] = await Promise.all([
26    $.env.get('HOME'),
27    $.env.get('AUTODREAM_DIR'),
28    $.env.get('AUTODREAM_TAGS_FILE'),
29  ])
30
31  return tagsFile({ HOME, AUTODREAM_DIR, AUTODREAM_TAGS_FILE })
32}
33
34const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause))
35
36/**
37 * What is on disk now. Another session may have tagged since this one last looked, so every write starts here.
38 *
39 * Only a file that is not there is empty. One that is there and will not read (over the 4 MiB a read allows, no
40 * permission, an I/O error) throws, because the caller rewrites the whole file: treating it as empty would replace
41 * every tag with the one being added.
42 */
43const load = async ($: EngineInterface): Promise<TagFile> => {
44  const file = await where($)
45
46  if (!(await $.fs.exists(file))) return { tags: [], others: [] }
47
48  const text = await $.fs.read(file)
49
50  if (typeof text !== 'string') throw new Error(`${file} did not read as text`)
51
52  return parseFile(text)
53}
54
55const refresh = async ($: EngineInterface) => {
56  try {
57    const { tags } = await load($)
58
59    await update($, tagged, () => tags.map(tag => tag.id))
60  } catch (cause) {
61    $.ui.toast(`Could not read the tags file, so no turn shows as tagged: ${reason(cause)}`)
62  }
63}
64
65const press = async ($: EngineInterface, turn: Turn) => {
66  let current: TagFile
67
68  try {
69    current = await load($)
70  } catch (cause) {
71    $.ui.toast(`Could not read the tags file, so nothing was changed: ${reason(cause)}`)
72
73    return
74  }
75
76  try {
77    const [session, cwd, now] = await Promise.all([$.session.id(), $.session.cwd(), $.clock.now()])
78    const tag: FocusTag = {
79      id: tagId(session, turn.uuid),
80      session,
81      uuid: turn.uuid,
82      role: turn.role,
83      text: clip(turn.text, TEXT_CAP),
84      cwd,
85      taggedAt: new Date(now).toISOString(),
86    }
87    const { file, isTagged } = toggleTag(current, tag)
88
89    await $.fs.write(await where($), serializeFile(file))
90    await update($, tagged, () => file.tags.map(each => each.id))
91    $.ui.toast(isTagged ? "Tagged: tonight's autodream takes a close look at this." : 'Tag removed.')
92  } catch (cause) {
93    $.ui.toast(`Could not save the tag: ${reason(cause)}`)
94  }
95}
96
97/**
98 * The row as the engine draws it, with the focus button over its top-right corner. The button is absolutely positioned
99 * so showing it moves nothing, and it sits on the right because the left edge of a split pane has no cells to spare.
100 * Untagged it appears while the pointer is over the row; tagged it stays, so a glance down the transcript finds what
101 * was marked.
102 */
103const withFocus = async ($: EngineInterface, ui: Ui, turn: Turn, requestId: string, beneath: RenderElement) => {
104  if (typeof turn.text !== 'string' || turn.text.trim() === '') return beneath
105
106  const [session, ids] = await Promise.all([$.session.id(), read($, tagged)])
107  const isTagged = ids.includes(tagId(session, turn.uuid))
108  const { Box, Button } = ui
109
110  return (
111    <Box key={`focus:${requestId}`} flexDirection="column">
112      {beneath}
113      <Box
114        position="absolute"
115        top={0}
116        right={0}
117        width={WIDTH}
118        justifyContent="flex-end"
119        display={isTagged ? 'flex' : 'none'}
120        hover={isTagged ? undefined : { display: 'flex' }}
121      >
122        <Button key={`focus-press:${requestId}`} plain dimColor={!isTagged} label={isTagged ? ON : OFF} onPress={() => press($, turn)} />
123      </Box>
124    </Box>
125  )
126}
127
128export const register: Register = on => {
129  on('session.start', async ($, e, next) => {
130    await refresh($)
131
132    return next(e)
133  })
134
135  // Only what the person typed (here or from the phone): a task notification or a peer's message is not theirs to tag.
136  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
137    const beneath = await next(e)
138    const kind = e.props.origin?.kind
139
140    if (kind !== 'composer' && kind !== 'bridge') return beneath
141
142    return withFocus($, $.ui.resolve(e), { uuid: e.requestId, role: 'user', text: e.props.text }, e.requestId, beneath)
143  })
144
145  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
146    const beneath = await next(e)
147
148    return withFocus($, $.ui.resolve(e), { uuid: e.requestId, role: 'assistant', text: e.props.text }, e.requestId, beneath)
149  })
150}
151
hooks/focus-lib.ts 80 lines
1import type { FocusTag } from '../types'
2
3/** A tagged turn is a pointer plus enough of its text for the nightly note to stand alone. */
4export const TEXT_CAP = 2_000
5
6const ROLES: readonly string[] = ['user', 'assistant']
7
8/**
9 * Where the tags are, resolved the way bin/vault-notes.sh resolves them: an exported AUTODREAM_TAGS_FILE wins, else AUTODREAM_DIR (an empty
10 * one counts as unset) plus tags.jsonl. A value set only in `$AUTODREAM_DIR/config` is invisible here; export it to move the mod too.
11 */
12export const tagsFile = (env: {
13  HOME?: string | undefined
14  AUTODREAM_DIR?: string | undefined
15  AUTODREAM_TAGS_FILE?: string | undefined
16}): string => env.AUTODREAM_TAGS_FILE || `${env.AUTODREAM_DIR || `${env.HOME ?? ''}/.claude/autodream`}/tags.jsonl`
17
18/** One turn, across sessions: the id Claude Code gives the row, under the session that holds it. */
19export const tagId = (session: string, uuid: string): string => `${session}:${uuid}`
20
21export const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max)}…`)
22
23const isString = (value: unknown): value is string => typeof value === 'string'
24
25/** One line of tags.jsonl as a tag, or null when it is not one this mod can read. */
26const parseTag = (line: string): FocusTag | null => {
27  let raw: unknown
28
29  try {
30    raw = JSON.parse(line)
31  } catch {
32    return null
33  }
34
35  if (typeof raw !== 'object' || raw === null) return null
36
37  const { id, session, uuid, role, text, cwd, taggedAt } = raw as Record<string, unknown>
38
39  if (!isString(id) || !isString(session) || !isString(uuid) || !isString(role) || !ROLES.includes(role)) return null
40  if (!isString(text) || !isString(taggedAt)) return null
41
42  // Fields this version does not know ride along, so a rewrite does not strip what a later version added.
43  return { ...raw, id, session, uuid, role: role as FocusTag['role'], text, cwd: isString(cwd) ? cwd : '', taggedAt }
44}
45
46/**
47 * What tags.jsonl holds: the tags this mod reads, and every other non-blank line as it stood. A rewrite has to hand
48 * those back, because they are somebody's (a hand edit, a field a later version added, a line torn by a crash), and
49 * bin/vault-notes.sh skips what it cannot read anyway.
50 */
51export type TagFile = { tags: FocusTag[]; others: string[] }
52
53export const parseFile = (text: string): TagFile => {
54  const file: TagFile = { tags: [], others: [] }
55
56  for (const line of text.split('\n')) {
57    if (line.trim() === '') continue
58
59    const tag = parseTag(line)
60
61    if (tag === null) file.others.push(line)
62    else file.tags.push(tag)
63  }
64
65  return file
66}
67
68/** JSON.stringify keeps each tag on one line, newlines in the text included. */
69export const serializeFile = ({ tags, others }: TagFile): string => {
70  const lines = [...others, ...tags.map(tag => JSON.stringify(tag))]
71
72  return lines.length === 0 ? '' : `${lines.join('\n')}\n`
73}
74
75/** The button is a toggle: a second press on a tagged turn takes the tag back. */
76export const toggleTag = (file: TagFile, tag: FocusTag): { file: TagFile; isTagged: boolean } =>
77  file.tags.some(existing => existing.id === tag.id)
78    ? { file: { ...file, tags: file.tags.filter(existing => existing.id !== tag.id) }, isTagged: false }
79    : { file: { ...file, tags: [...file.tags, tag] }, isTagged: true }
80
types/index.d.ts 27 lines
1/** A turn the person marked with the focus button, as tags.jsonl holds it; bin/vault-notes.sh reads the same shape. */
2export type FocusTag = {
3  /** `<session id>:<row id>`, unique across sessions */
4  id: string
5  session: string
6  /** the id Claude Code gave the row when it drew it */
7  uuid: string
8  role: 'user' | 'assistant'
9  /** the turn's text, capped, so the nightly note stands alone */
10  text: string
11  cwd: string
12  /**
13   * UTC, ISO 8601. The only clock the file carries: the mod's environment has no timezone, so the report day a tag
14   * belongs to is decided by bin/vault-notes.sh, which does.
15   */
16  taggedAt: string
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'autodream-focus': {
22      /** ids of the turns tagged so far, read from tags.jsonl at session start and after every press */
23      tagged: string[]
24    }
25  }
26}
27