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
A shopper browsing a rack in a slop shop
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in mods/

Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.

Source 3 files
hooks/register.tsx 305 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'
10import { bandsFile, parseOrder, stackBands } from './bands'
11
12const SHOW_DONE_MS = 30_000 // a finished review stays in the band this long
13const BAND_ROWS = 4
14const POLL_MS = 3_000
15const UNSEEN_POLLS = 3 // a review whose process never shows up in ps is over after this many polls
16
17// Held by the host, so the reviews survive a hot reload of this file.
18const reviews = atom({ plugin: 'review-watch', key: 'reviews' } as const, [] as Review[])
19const now = atom({ plugin: 'review-watch', key: 'now' } as const, 0)
20
21let isPolling = false // a slow ps must not let two polls overlap
22
23let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
24
25export const register: Register = on => {
26  let cwd = ''
27  let timers: Timer[] = []
28
29  on('session.start', async ($, e, next) => {
30    const r = await next(e)
31    cwd = e.cwd
32    // A review agent that is no longer running (a resumed or new session) has no line to keep.
33    const live = new Set((await $.agent.list().catch(() => [])).filter(a => a.status === 'running').map(a => a.id))
34    await update($, reviews, list => list.filter(v => v.kind === 'codex' || v.status !== 'running' || live.has(v.id)))
35    for (const t of timers) t.cancel()
36    timers = []
37    // A clock for the elapsed times, ticking only while something shows.
38    timers.push($.clock.every(1000, () => {
39      void (async () => {
40        if ((await read($, reviews)).some(v => isShown(v, Date.now() - 3_000))) await update($, now, () => Date.now())
41      })().catch(() => {})
42    }))
43    timers.push($.clock.every(POLL_MS, () => void poll($).catch(() => {})))
44    return r
45  })
46
47  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
48    if (!isCodexReview(e.command)) return next(e)
49    const id = `codex-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
50    const one: Review = {
51      id,
52      kind: 'codex',
53      model: modelOf(e.command) ?? (await configModel($)),
54      label: labelOf(e.command),
55      status: 'running',
56      startedAt: Date.now(),
57      key: keyOf(e.command),
58      out: outputOf(e.command, cwd, (await $.env.get('HOME').catch(() => undefined)) ?? ''),
59      polls: 0,
60      last: 'starting',
61    }
62    await update($, reviews, list => [one, ...list].slice(0, 20))
63    const r = await next(e)
64    if (r.deny || r.isError || !e.run_in_background) {
65      await finish($, id, Boolean(r.deny || r.isError)) // it ran in the foreground: it is over
66    } else if (!one.out) {
67      const path = /written to: (\S+\.output)/.exec(r.text ?? '')?.[1] // where Claude Code keeps a background shell's output
68      if (path) await update($, reviews, list => list.map(v => (v.id === id ? { ...v, out: path } : v)))
69    }
70    return r
71  })
72
73  on('agent.spawn', async ($, e, next) => {
74    const r = await next(e)
75    if (r.agentId && isReviewAgent(e.description ?? '')) {
76      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 }
77      await update($, reviews, list => [one, ...list.filter(v => v.id !== one.id)].slice(0, 20))
78    }
79    return r
80  })
81
82  on('turn.complete', async ($, e, next) => {
83    const r = await next(e)
84    if (e.agentId) await finish($, e.agentId, e.reason === 'error' || e.reason === 'aborted')
85    return r
86  })
87
88  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89    const rest = await next(e) // what other mods and Claude Code draw here stays
90    await read($, now) // subscribes the band to the clock
91    const shown = (await read($, reviews)).filter(v => isShown(v))
92    if (e.props.hasSurvey || shown.length === 0) return rest
93    const { Box, Text } = $.ui.resolve(e)
94    const mine = (
95      <Box flexDirection="column">
96        {shown.slice(0, BAND_ROWS).map(v => (
97          <Box flexDirection="column">
98            <Text wrap="truncate-end">
99              <Text color={color(v)} bold>{` ${icon(v)} ${v.kind === 'codex' ? 'codex' : 'agent'}`}</Text>
100              <Text>{` · ${v.model} · ${v.label}`}</Text>
101              {v.status !== 'running' && v.findings && <Text bold>{` · ${findingsText(v.findings)}`}</Text>}
102              <Text dimColor>{`  ${elapsed(v)}`}</Text>
103            </Text>
104            {v.status === 'running' && v.kind === 'codex' && v.last !== '' && (
105              <Text dimColor wrap="truncate-end">{`    └ ${v.last}`}</Text>
106            )}
107          </Box>
108        ))}
109        {shown.length > BAND_ROWS && <Text dimColor>{`   +${shown.length - BAND_ROWS} more`}</Text>}
110      </Box>
111    )
112    return stackBands(rest, 'review-watch', mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => []))) // above what other mods draw, unless the band order says otherwise
113  })
114}
115
116// The band order file, as the claude-code-mods checklist saves it; [] on any error.
117async function readBandOrder($: EngineInterface): Promise<string[]> {
118  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
119  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
120}
121
122// Forgets the cached band order, for tests.
123export function resetBandOrder() {
124  bandOrder = undefined
125}
126
127// Checks each running Codex review: is its process still there, and what did it print last.
128async function poll($: EngineInterface) {
129  if (isPolling) return
130  isPolling = true
131  try {
132    await pollOnce($)
133  } finally {
134    isPolling = false
135  }
136}
137
138async function pollOnce($: EngineInterface) {
139  const running = (await read($, reviews)).filter(v => v.kind === 'codex' && v.status === 'running')
140  if (running.length === 0) return
141  const ps = await $.process.run(['ps', '-axww', '-o', 'command='], { timeoutMs: 5_000 })
142  if (ps.exitCode !== 0) return
143  const lines = ps.stdout.split('\n').filter(l => isCodexReview(l)).map(plain)
144  for (const v of running) {
145    const isAlive = lines.some(l => `${l} `.includes(`${v.key ?? 'codex review'} `)) // whole arguments: `--base main` is not `--base main2`
146    const polls = (v.polls ?? 0) + 1
147    if (!isAlive && (v.seen || polls >= UNSEEN_POLLS)) {
148      await finish($, v.id, false)
149      continue
150    }
151    const last = v.out ? await lastLine($, v.out) : v.last
152    await update($, reviews, list => list.map(x => (x.id === v.id ? { ...x, polls, seen: x.seen || isAlive, last } : x)))
153  }
154}
155
156async function finish($: EngineInterface, id: string, failed: boolean) {
157  // Claimed in one update, so two callers (a poll and the tool call) never both toast.
158  let v: Review | undefined
159  await update($, reviews, list =>
160    list.map(x => (x.id === id && x.status === 'running' ? (v = { ...x, status: failed ? 'failed' : 'done', endedAt: Date.now() }) : x)),
161  )
162  if (!v) return
163  const done = v
164  const findings = done.kind === 'codex' && done.out && !failed ? findingsOf(await tail($, ['-c', '40000', '--', done.out])) : undefined
165  if (findings) await update($, reviews, list => list.map(x => (x.id === id ? { ...x, findings } : x)))
166  const what = done.kind === 'codex' ? `codex review (${done.model})` : `${done.label} (${done.model})`
167  const extra = findings ? ` · ${findingsText(findings)}` : ''
168  $.ui.toast(`${failed ? '✗' : '✓'} ${what} ${failed ? 'stopped' : 'done'} · ${elapsed(done)}${extra}`)
169}
170
171async function tail($: EngineInterface, args: string[]) {
172  const r = await $.process.run(['tail', ...args], { timeoutMs: 5_000 }).catch(() => null)
173  return r && r.exitCode === 0 ? r.stdout : ''
174}
175
176async function lastLine($: EngineInterface, file: string) {
177  const lines = clean(await tail($, ['-n', '30', '--', file])).split('\n').map(l => l.trim()).filter(Boolean)
178  return lines.at(-1)?.slice(0, 200) ?? ''
179}
180
181// The model from ~/.codex/config.toml, for a review run without one on its command line.
182async function configModel($: EngineInterface) {
183  const r = await $.process
184    .run(['sh', '-c', 'grep -E "^model *=" "$HOME/.codex/config.toml" | head -n 1'], { timeoutMs: 3_000 })
185    .catch(() => null)
186  return (r && /=\s*["']?([\w.:-]+)/.exec(r.stdout)?.[1]) || 'default'
187}
188
189// `codex review` where a command starts (after `;`, `&&`, `|`, `timeout 900` and the like), flags before `review` allowed.
190// Also the Codex plugin's `/codex:review` and `/codex:adversarial-review`: `node .../codex-companion.mjs review`.
191const CODEX_REVIEW =
192  /(?:^|[;&|(])\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|$)/
193
194export function isCodexReview(command: string) {
195  return CODEX_REVIEW.test(command)
196}
197
198// A subagent whose description says review (not "preview" or "interview").
199export function isReviewAgent(description: string) {
200  return /\breview(?:s|er|ers|ing)?\b/i.test(description)
201}
202
203const TITLE = /--title(?:\s+|=)(?:(["'])(.*?)\1|([^\s"';&|<>]+))/
204
205// The `codex ... review ...` part of a command, up to where the next command starts.
206function codexPart(command: string) {
207  const m = CODEX_REVIEW.exec(command)
208  if (!m) return ''
209  const rest = command.slice(m.index).replace(TITLE, '--title _')
210  return rest.split(/\s*(?:&&|\|\||;|\|(?!&))\s*/)[0]!
211}
212
213export function modelOf(command: string) {
214  const part = codexPart(command)
215  return /\s-c\s+["']?model\s*=\s*\\?["']?([\w.:-]+)/.exec(part)?.[1] ?? /[\s"'](?:--model|-m)(?:\s+|=)["']?([\w.:-]+)/.exec(part)?.[1]
216}
217
218export function labelOf(command: string) {
219  const title = titleOf(command)
220  if (title) return title
221  const part = codexPart(command)
222  const commit = /--commit(?:\s+|=)["']?(\w+)/.exec(part)?.[1]
223  if (commit) return `commit ${commit.slice(0, 7)}`
224  const base = /--base(?:\s+|=)["']?([^\s"']+)/.exec(part)?.[1]
225  return base ? `changes vs ${base}` : 'uncommitted changes'
226}
227
228function titleOf(command: string) {
229  const m = TITLE.exec(command.slice(Math.max(0, command.search(CODEX_REVIEW))))
230  return m ? (m[2] ?? m[3] ?? '') : ''
231}
232
233// What finds this review's process in ps: its arguments as ps prints them (quotes gone).
234export function keyOf(command: string) {
235  const m = CODEX_REVIEW.exec(command)
236  if (!m) return 'codex review'
237  const rest = command.slice(m.index + m[0].search(/codex(?:-companion\.mjs["']?)?\s/))
238  // Up to the first redirect or the next command, past a quoted title.
239  const masked = rest.replace(TITLE, x => x.replace(/[;&|<>]/g, '_'))
240  const cut = masked.search(/\s(?:\d?>|&>)|\s*(?:&&|\|\||;|\|)/)
241  return plain(cut >= 0 ? rest.slice(0, cut) : rest)
242}
243
244// The file the review's stdout goes to (`> file`): `~` from HOME, a relative one from a `cd` before it or the session's folder.
245export function outputOf(command: string, cwd: string, home = '') {
246  const at = Math.max(0, command.search(CODEX_REVIEW))
247  const re = /(\d|&)?>>?\s*(["']?)([^\s"'&|;<>]+)\2/g
248  const rest = command.slice(at).replace(TITLE, '--title _')
249  for (const m of rest.matchAll(re)) {
250    if (m[1] === '2' || m[3]!.startsWith('&')) continue
251    let path = m[3]!
252    if (path === '/dev/null' || path.includes('$')) return undefined
253    if (path.startsWith('~/')) {
254      if (!home) return undefined
255      path = home + path.slice(1)
256    }
257    if (path.startsWith('/')) return path
258    const cds = [...command.slice(0, at).matchAll(/(?:^|[;&|])\s*cd\s+(["']?)([^\s"';&|]+)\1/g)]
259    const dir = cds.at(-1)?.[2]
260    const base = dir ? (dir.startsWith('/') ? dir : dir.startsWith('~/') && home ? home + dir.slice(1) : `${cwd}/${dir}`) : cwd
261    return base ? `${base}/${path}` : undefined
262  }
263  return undefined
264}
265
266// Codex ends with a "Full review comments:" block of `- [P2] title — file:line` items.
267// Undefined when the output says neither (an error, a killed run, an unreadable file).
268export function findingsOf(text: string): string[] | undefined {
269  const at = text.lastIndexOf('Full review comments:')
270  if (at >= 0) return [...text.slice(at).matchAll(/^\s*-\s*\[(P\d)\]/gm)].map(m => m[1]!)
271  if (/\b(?:no (?:issues|findings|problems|bugs)|did not (?:find|identify)|didn't (?:find|identify)|looks good)\b/i.test(text.slice(-3000))) return []
272  return undefined
273}
274
275export function findingsText(findings: string[]) {
276  if (findings.length === 0) return 'no findings'
277  return `${findings.length} finding${findings.length === 1 ? '' : 's'} (${findings.join(', ')})`
278}
279
280export function isShown(v: Pick<Review, 'status' | 'endedAt'>, at = Date.now()) {
281  return v.status === 'running' || at - (v.endedAt ?? 0) < SHOW_DONE_MS
282}
283
284export function elapsed(v: Pick<Review, 'startedAt' | 'endedAt'>) {
285  const s = Math.max(0, Math.round(((v.endedAt ?? Date.now()) - v.startedAt) / 1000))
286  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
287}
288
289// A command line as ps prints it: no quotes, single spaces.
290function plain(text: string) {
291  return text.replace(/["']/g, '').replace(/\s+/g, ' ').trim()
292}
293
294function clean(text: string) {
295  return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
296}
297
298function icon(v: Review) {
299  return v.status === 'running' ? '⏳' : v.status === 'done' ? '✅' : '✗'
300}
301
302function color(v: Review) {
303  return v.status === 'running' ? 'cyan' : v.status === 'done' ? 'green' : 'red'
304}
305
hooks/bands.ts 73 lines
1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5//   column, adds its own band and sorts again, so the last one to draw sorts them all.
6//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7//   Reading the file needs $, which Claude Code follows only within one file, so each mod
8//   reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22  try {
23    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25  } catch {
26    return []
27  }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39  const others = unpack(rest)
40  const items = at === 'above' ? [own, ...others] : [...others, own]
41  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46  if (!rest) return []
47  if (!isColumn(rest)) return [rest]
48  const kids = rest.children ?? []
49  const owner = rest.part?.plugin
50  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55  return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59  if (typeof node === 'string' || node.type !== 'Box') return undefined
60  const key = node.props?.key
61  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65  const rank = (n: RenderNode) => {
66    const name = bandName(n)
67    if (name === undefined) return -2 // not a band: first
68    const i = order.indexOf(name)
69    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70  }
71  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73
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