SLOPSHOPPER

review-watch

A live line above the prompt for each running code review: Codex or a review subagent, its model, what it reviews, time, and Codex's latest output. A toast…

newbandguardtoastprocesstimer
v0.1.1no licenseupdated 2026-10-14aidan-nunn-sys/claude-config/mods/review-watch
A shopper browsing a rack in a slop shop
README

claude-config

My Claude Code setup: mods, plugins, skills and settings, so a new machine is one clone away.

New machine

git clone git@github-personal:aidan-nunn-sys/claude-config.git ~/Work/personal/claude-config
~/Work/personal/claude-config/install.sh

Then:

  • Fill in any <SET-ME> values install.sh reports in ~/.claude.json (MCP secrets are never committed).
  • optimaizr CLI (audited 0.11.0; re-audit before bumping), in ~/.config/mise/config.toml: "npm:optimaizr" = { version = "0.11.0", allow_low_downloads = true }
  • Restart Claude Code.

After changing settings

Mods and skills are symlinked from this repo, so edits land here directly. Settings changed through Claude Code live in ~/.claude; run ./capture.sh and review git diff to bring them back.

Layout

PathWhat
mods/Mods loaded through CLAUDE_CODE_PLUGIN_DIRS (built by install.sh from this folder)
plugins/plugins.jsonMarketplaces and which marketplace plugins are enabled
skills/Personal skills, symlinked into ~/.claude/skills
settings/settings.json~/.claude/settings.json minus plugin dirs, plugins and hooks; ${HOME} for paths
settings/mcp.jsonUser MCP servers, secrets blanked
settings/rulebook.jsonGeneric example rules (copied only when the machine has none; not captured)
docs/specs/Design specs

Mods

ModSourceLocal changes
hudownprompt hint, context weather bar, cache countdown, kaomoji mascot
rulebookownhard rules in ~/.claude/rulebook.json, /rules, mcp__rulebook__add_rule
xagentowndispatch/route tools for Codex, Copilot, Antigravity
handoffown/handoff [next] writes a handoff doc to ~/.claude/handoffs/
agent-radarhamzafer/claude-code-mods @ 87f108479bbf/radar runs mid-turn (immediate) and toggles closed; shows xagent's external agents
mission-controlhamzafer/claude-code-mods @ 87f108479bbfshows xagent's external agents; code-map view (Haiku diff summaries, headless Chrome) stripped
blast-radiushamzafer/claude-code-mods @ 87f108479bbf60 s auto-cancel
review-watchhamzafer/claude-code-mods @ 87f108479bbfnone
where-am-ihamzafer/claude-code-mods @ 87f108479bbfshows only "next"
secret-boxray-amjad/claude-code-mods @ 467b20300d8enone
task-boardMarcoCarnevali/claude-code-mods @ a3e528dd00ce/board (not /tasks), no auto-open, mid-turn + toggle

Upstream commits are the repos' HEAD on 2026-10-14, when this repo was made; installs were from around then. Read upstream changes before pulling them in.

Not in this repo

  • ~/.claude/.credentials.json and MCP secrets.
  • Managed by other tools: omarchy skills (diagnose-crash, omarchy, symlinks into /usr/share/omarchy), themes/omarchy.json, the herdr SessionStart hook.
  • Per-machine state: memory, history, sessions, plans.
  • .claude-plugin/types/ in each mod: SDK types Claude Code writes itself.
  • Hiding Claude Code's native subagent area: the mod API exposes no component for it (checked 2026-10-14; Spinner props are word, message, suffix, mode).
Source 2 files
hooks/register.tsx 291 lines
1// Review Watch: is my code review still running?
2//   The band above the prompt has one live line per running review: a `codex review`
3//   shell command or a subagent whose description says "review". It shows the model, what
4//   is under review, the time, and Codex's latest output line. When a review ends a toast
5//   says so, with the findings Codex reported, and its line shows a check for 30 s.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, Timer } from 'claude-code'
8
9import type { Review } from '../types'
10
11const SHOW_DONE_MS = 30_000 // a finished review stays in the band this long
12const BAND_ROWS = 4
13const POLL_MS = 3_000
14const UNSEEN_POLLS = 3 // a review whose process never shows up in ps is over after this many polls
15
16// Held by the host, so the reviews survive a hot reload of this file.
17const reviews = atom({ plugin: 'review-watch', key: 'reviews' } as const, [] as Review[])
18const now = atom({ plugin: 'review-watch', key: 'now' } as const, 0)
19
20let isPolling = false // a slow ps must not let two polls overlap
21
22export const register: Register = on => {
23  let cwd = ''
24  let timers: Timer[] = []
25
26  on('session.start', async ($, e, next) => {
27    const r = await next(e)
28    cwd = e.cwd
29    // A review agent that is no longer running (a resumed or new session) has no line to keep.
30    const live = new Set((await $.agent.list().catch(() => [])).filter(a => a.status === 'running').map(a => a.id))
31    await update($, reviews, list => list.filter(v => v.kind === 'codex' || v.status !== 'running' || live.has(v.id)))
32    for (const t of timers) t.cancel()
33    timers = []
34    // A clock for the elapsed times, ticking only while something shows.
35    timers.push($.clock.every(1000, () => {
36      void (async () => {
37        if ((await read($, reviews)).some(v => isShown(v, Date.now() - 3_000))) await update($, now, () => Date.now())
38      })().catch(() => {})
39    }))
40    timers.push($.clock.every(POLL_MS, () => void poll($).catch(() => {})))
41    return r
42  })
43
44  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
45    if (!isCodexReview(e.command)) return next(e)
46    const id = `codex-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
47    const one: Review = {
48      id,
49      kind: 'codex',
50      model: modelOf(e.command) ?? (await configModel($)),
51      label: labelOf(e.command),
52      status: 'running',
53      startedAt: Date.now(),
54      key: keyOf(e.command),
55      out: outputOf(e.command, cwd, (await $.env.get('HOME').catch(() => undefined)) ?? ''),
56      polls: 0,
57      last: 'starting',
58    }
59    await update($, reviews, list => [one, ...list].slice(0, 20))
60    const r = await next(e)
61    if (r.deny || r.isError || !e.run_in_background) {
62      await finish($, id, Boolean(r.deny || r.isError)) // it ran in the foreground: it is over
63    } else if (!one.out) {
64      const path = /written to: (\S+\.output)/.exec(r.text ?? '')?.[1] // where Claude Code keeps a background shell's output
65      if (path) await update($, reviews, list => list.map(v => (v.id === id ? { ...v, out: path } : v)))
66    }
67    return r
68  })
69
70  on('agent.spawn', async ($, e, next) => {
71    const r = await next(e)
72    if (r.agentId && isReviewAgent(e.description ?? '')) {
73      const one: Review = { id: r.agentId, kind: 'agent', model: e.model || r.model || 'inherit', label: e.description, status: 'running', startedAt: Date.now(), last: e.subagentType }
74      await update($, reviews, list => [one, ...list.filter(v => v.id !== one.id)].slice(0, 20))
75    }
76    return r
77  })
78
79  on('turn.complete', async ($, e, next) => {
80    const r = await next(e)
81    if (e.agentId) await finish($, e.agentId, e.reason === 'error' || e.reason === 'aborted')
82    return r
83  })
84
85  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
86    const rest = await next(e) // what other mods and Claude Code draw here stays
87    await read($, now) // subscribes the band to the clock
88    const shown = (await read($, reviews)).filter(v => isShown(v))
89    if (e.props.hasSurvey || shown.length === 0) return rest
90    const { Box, Text } = $.ui.resolve(e)
91    return (
92      <Box flexDirection="column">
93        {shown.slice(0, BAND_ROWS).map(v => (
94          <Box flexDirection="column">
95            <Text wrap="truncate-end">
96              <Text color={color(v)} bold>{` ${icon(v)} ${v.kind === 'codex' ? 'codex' : 'agent'}`}</Text>
97              <Text>{` · ${v.model} · ${v.label}`}</Text>
98              {v.status !== 'running' && v.findings && <Text bold>{` · ${findingsText(v.findings)}`}</Text>}
99              <Text dimColor>{`  ${elapsed(v)}`}</Text>
100            </Text>
101            {v.status === 'running' && v.kind === 'codex' && v.last !== '' && (
102              <Text dimColor wrap="truncate-end">{`    └ ${v.last}`}</Text>
103            )}
104          </Box>
105        ))}
106        {shown.length > BAND_ROWS && <Text dimColor>{`   +${shown.length - BAND_ROWS} more`}</Text>}
107        {rest}
108      </Box>
109    )
110  })
111}
112
113// Checks each running Codex review: is its process still there, and what did it print last.
114async function poll($: EngineInterface) {
115  if (isPolling) return
116  isPolling = true
117  try {
118    await pollOnce($)
119  } finally {
120    isPolling = false
121  }
122}
123
124async function pollOnce($: EngineInterface) {
125  const running = (await read($, reviews)).filter(v => v.kind === 'codex' && v.status === 'running')
126  if (running.length === 0) return
127  const ps = await $.process.run(['ps', '-axww', '-o', 'command='], { timeoutMs: 5_000 })
128  if (ps.exitCode !== 0) return
129  const lines = ps.stdout.split('\n').filter(l => isCodexReview(l)).map(plain)
130  for (const v of running) {
131    const isAlive = lines.some(l => `${l} `.includes(`${v.key ?? 'codex review'} `)) // whole arguments: `--base main` is not `--base main2`
132    const polls = (v.polls ?? 0) + 1
133    if (!isAlive && (v.seen || polls >= UNSEEN_POLLS)) {
134      await finish($, v.id, false)
135      continue
136    }
137    const last = v.out ? await lastLine($, v.out) : v.last
138    await update($, reviews, list => list.map(x => (x.id === v.id ? { ...x, polls, seen: x.seen || isAlive, last } : x)))
139  }
140}
141
142async function finish($: EngineInterface, id: string, failed: boolean) {
143  // Claimed in one update, so two callers (a poll and the tool call) never both toast.
144  let v: Review | undefined
145  await update($, reviews, list =>
146    list.map(x => (x.id === id && x.status === 'running' ? (v = { ...x, status: failed ? 'failed' : 'done', endedAt: Date.now() }) : x)),
147  )
148  if (!v) return
149  const done = v
150  const findings = done.kind === 'codex' && done.out && !failed ? findingsOf(await tail($, ['-c', '40000', '--', done.out])) : undefined
151  if (findings) await update($, reviews, list => list.map(x => (x.id === id ? { ...x, findings } : x)))
152  const what = done.kind === 'codex' ? `codex review (${done.model})` : `${done.label} (${done.model})`
153  const extra = findings ? ` · ${findingsText(findings)}` : ''
154  $.ui.toast(`${failed ? '✗' : '✓'} ${what} ${failed ? 'stopped' : 'done'} · ${elapsed(done)}${extra}`)
155}
156
157async function tail($: EngineInterface, args: string[]) {
158  const r = await $.process.run(['tail', ...args], { timeoutMs: 5_000 }).catch(() => null)
159  return r && r.exitCode === 0 ? r.stdout : ''
160}
161
162async function lastLine($: EngineInterface, file: string) {
163  const lines = clean(await tail($, ['-n', '30', '--', file])).split('\n').map(l => l.trim()).filter(Boolean)
164  return lines.at(-1)?.slice(0, 200) ?? ''
165}
166
167// The model from ~/.codex/config.toml, for a review run without one on its command line.
168async function configModel($: EngineInterface) {
169  const r = await $.process
170    .run(['sh', '-c', 'grep -E "^model *=" "$HOME/.codex/config.toml" | head -n 1'], { timeoutMs: 3_000 })
171    .catch(() => null)
172  return (r && /=\s*["']?([\w.:-]+)/.exec(r.stdout)?.[1]) || 'default'
173}
174
175// `codex review` where a command starts (after `;`, `&&`, `|`, `timeout 900` and the like), flags before `review` allowed.
176// Also the Codex plugin's `/codex:review` and `/codex:adversarial-review`: `node .../codex-companion.mjs review`.
177const CODEX_REVIEW =
178  /(?:^|[;&|(])\s*(?:(?:timeout|nohup|nice|env|time|command)(?:\s+[-\w.=:]+)*\s+)?(?:(?:\S*\/)?codex(?:\s+-{1,2}[\w-]+(?:[\s=]+(?!-)\S+)?)*\s+review|(?:\S*\/)?node\s+(?:"[^"]*|'[^']*|\S*)\/codex-companion\.mjs["']?\s+(?:adversarial-)?review)(?=\s|$)/
179
180export function isCodexReview(command: string) {
181  return CODEX_REVIEW.test(command)
182}
183
184// A subagent whose description says review (not "preview" or "interview").
185export function isReviewAgent(description: string) {
186  return /\breview(?:s|er|ers|ing)?\b/i.test(description)
187}
188
189const TITLE = /--title(?:\s+|=)(?:(["'])(.*?)\1|([^\s"';&|<>]+))/
190
191// The `codex ... review ...` part of a command, up to where the next command starts.
192function codexPart(command: string) {
193  const m = CODEX_REVIEW.exec(command)
194  if (!m) return ''
195  const rest = command.slice(m.index).replace(TITLE, '--title _')
196  return rest.split(/\s*(?:&&|\|\||;|\|(?!&))\s*/)[0]!
197}
198
199export function modelOf(command: string) {
200  const part = codexPart(command)
201  return /\s-c\s+["']?model\s*=\s*\\?["']?([\w.:-]+)/.exec(part)?.[1] ?? /[\s"'](?:--model|-m)(?:\s+|=)["']?([\w.:-]+)/.exec(part)?.[1]
202}
203
204export function labelOf(command: string) {
205  const title = titleOf(command)
206  if (title) return title
207  const part = codexPart(command)
208  const commit = /--commit(?:\s+|=)["']?(\w+)/.exec(part)?.[1]
209  if (commit) return `commit ${commit.slice(0, 7)}`
210  const base = /--base(?:\s+|=)["']?([^\s"']+)/.exec(part)?.[1]
211  return base ? `changes vs ${base}` : 'uncommitted changes'
212}
213
214function titleOf(command: string) {
215  const m = TITLE.exec(command.slice(Math.max(0, command.search(CODEX_REVIEW))))
216  return m ? (m[2] ?? m[3] ?? '') : ''
217}
218
219// What finds this review's process in ps: its arguments as ps prints them (quotes gone).
220export function keyOf(command: string) {
221  const m = CODEX_REVIEW.exec(command)
222  if (!m) return 'codex review'
223  const rest = command.slice(m.index + m[0].search(/codex(?:-companion\.mjs["']?)?\s/))
224  // Up to the first redirect or the next command, past a quoted title.
225  const masked = rest.replace(TITLE, x => x.replace(/[;&|<>]/g, '_'))
226  const cut = masked.search(/\s(?:\d?>|&>)|\s*(?:&&|\|\||;|\|)/)
227  return plain(cut >= 0 ? rest.slice(0, cut) : rest)
228}
229
230// The file the review's stdout goes to (`> file`): `~` from HOME, a relative one from a `cd` before it or the session's folder.
231export function outputOf(command: string, cwd: string, home = '') {
232  const at = Math.max(0, command.search(CODEX_REVIEW))
233  const re = /(\d|&)?>>?\s*(["']?)([^\s"'&|;<>]+)\2/g
234  const rest = command.slice(at).replace(TITLE, '--title _')
235  for (const m of rest.matchAll(re)) {
236    if (m[1] === '2' || m[3]!.startsWith('&')) continue
237    let path = m[3]!
238    if (path === '/dev/null' || path.includes('$')) return undefined
239    if (path.startsWith('~/')) {
240      if (!home) return undefined
241      path = home + path.slice(1)
242    }
243    if (path.startsWith('/')) return path
244    const cds = [...command.slice(0, at).matchAll(/(?:^|[;&|])\s*cd\s+(["']?)([^\s"';&|]+)\1/g)]
245    const dir = cds.at(-1)?.[2]
246    const base = dir ? (dir.startsWith('/') ? dir : dir.startsWith('~/') && home ? home + dir.slice(1) : `${cwd}/${dir}`) : cwd
247    return base ? `${base}/${path}` : undefined
248  }
249  return undefined
250}
251
252// Codex ends with a "Full review comments:" block of `- [P2] title — file:line` items.
253// Undefined when the output says neither (an error, a killed run, an unreadable file).
254export function findingsOf(text: string): string[] | undefined {
255  const at = text.lastIndexOf('Full review comments:')
256  if (at >= 0) return [...text.slice(at).matchAll(/^\s*-\s*\[(P\d)\]/gm)].map(m => m[1]!)
257  if (/\b(?:no (?:issues|findings|problems|bugs)|did not (?:find|identify)|didn't (?:find|identify)|looks good)\b/i.test(text.slice(-3000))) return []
258  return undefined
259}
260
261export function findingsText(findings: string[]) {
262  if (findings.length === 0) return 'no findings'
263  return `${findings.length} finding${findings.length === 1 ? '' : 's'} (${findings.join(', ')})`
264}
265
266export function isShown(v: Pick<Review, 'status' | 'endedAt'>, at = Date.now()) {
267  return v.status === 'running' || at - (v.endedAt ?? 0) < SHOW_DONE_MS
268}
269
270export function elapsed(v: Pick<Review, 'startedAt' | 'endedAt'>) {
271  const s = Math.max(0, Math.round(((v.endedAt ?? Date.now()) - v.startedAt) / 1000))
272  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
273}
274
275// A command line as ps prints it: no quotes, single spaces.
276function plain(text: string) {
277  return text.replace(/["']/g, '').replace(/\s+/g, ' ').trim()
278}
279
280function clean(text: string) {
281  return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
282}
283
284function icon(v: Review) {
285  return v.status === 'running' ? '⏳' : v.status === 'done' ? '✅' : '✗'
286}
287
288function color(v: Review) {
289  return v.status === 'running' ? 'cyan' : v.status === 'done' ? 'green' : 'red'
290}
291
types/index.d.ts 22 lines
1export type Review = {
2  id: string
3  kind: 'codex' | 'agent'
4  model: string
5  label: string
6  status: 'running' | 'done' | 'failed'
7  startedAt: number
8  endedAt?: number
9  key?: string // a Codex review: a string of its command line, to find its process
10  out?: string // a Codex review: the file its output goes to
11  seen?: boolean // a Codex review: its process has shown up in ps
12  polls?: number
13  last: string
14  findings?: string[] // a Codex review: P1, P2, ... per finding
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'review-watch': { reviews: Review[]; now: number }
20  }
21}
22