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…

A private reference collection of public Claude Code mods, with a security-oriented analysis.
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.
hooks/register.tsx 305 lines1// 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}
305hooks/bands.ts 73 lines1// 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}
73types/index.d.ts 22 lines1export 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