SLOPSHOPPER

codex-review-switch

Switch above the prompt for the Codex stop-time review gate, with colored Claude and Codex marks in the transcript

newbandrowscommandprocess
v0.5.1MITupdated 2026-10-24artiiisoot/claude-codex-review-switch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-review-switch
› fix the failing auth test and add an audit log call ● codex-review-switch: codex-review-switch: JSON Parse error: Unexpected EOF ⏺ 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 › /codex-review-switch ⎿ codex-review-switch: Codex 리뷰 상태를 읽지 못했습니다: JSON Parse error: Unexpected EOF ◆ Codex 리뷰 toggle 꺼짐 ↻ JSON Parse error: Unexpected EOF ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ Codex 리뷰 toggle 꺼짐 ↻ JSON Parse error: Unexpected EOF
README

codex-review-switch

Claude Code에서 Codex 리뷰 게이트를 입력창 위 토글 스위치로 켜고 끄는 모드입니다. 대화에서는 Claude와 Codex의 답변을 색과 아이콘으로 구분합니다.

기능

  • 입력창 위 토글 스위치: ◆ Codex 리뷰 옆 스위치로 현재 프로젝트(git 루트)의 stop-time 리뷰 게이트를 켜고 끕니다. 데스크톱 앱에서는 macOS 스타일 토글, 터미널에서는 글자 스위치로 그립니다.
  • Claude 표시: Claude 답변 맨 위에 주황색 ✻ Claude를 붙입니다.
  • Codex 리뷰 결과: 게이트가 켜져 있으면 답변이 끝날 때 Codex가 리뷰한 결과를, 리뷰한 답변 아래 초록 테두리 상자에 남깁니다.
  • ✓ 지적 사항 없음: 통과해도 결과를 남깁니다.
  • ✗ 지적 있음: 지적 내용을 보여주고, Claude가 이어서 수정합니다.
  • ! 리뷰 실행 실패: 리뷰가 실패한 이유를 보여줍니다.
  • /codex-review-switch: 게이트 상태를 다시 읽어 알려줍니다.

필요한 것

  • Codex 플러그인 (codex@openai-codex)이 설치되어 있어야 합니다. 리뷰 자체는 이 플러그인의 Stop 훅이 실행하고, 이 모드는 스위치와 결과 표시를 맡습니다.
  • Codex CLI 설치와 로그인: Claude Code에서 /codex:setup을 실행해 확인하세요.
  • macOS 또는 Linux (/bin/sh와 node를 씁니다).

설치

터미널의 Claude Code 프롬프트에서:

/plugin install codex-review-switch --marketplace artiiisoot/claude-codex-review-switch

셸에서:

claude plugin install codex-review-switch --marketplace artiiisoot/claude-codex-review-switch

데스크톱 앱에서는 /plugin이 동작하지 않습니다. 프롬프트 옆 + → Plugins → Add marketplace에 artiiisoot/claude-codex-review-switch를 넣고 Add plugin을 누르세요.

설치 후 새 세션부터 적용됩니다.

주의

  • 게이트를 켜면 답변이 끝날 때마다 Codex 리뷰가 실행됩니다. 답변마다 최대 15분이 더 걸릴 수 있고 Codex 사용량이 듭니다.
  • 스위치는 프로젝트(git 루트)마다 따로 저장됩니다.
  • Codex 결과는 리뷰한 답변의 글로 짝을 찾기 때문에, 아주 짧은 같은 답변이 반복되면 앞의 답변에도 결과 상자가 보일 수 있습니다.

라이선스

MIT

Source 2 files
hooks/register.tsx 262 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CodexReview, GateStatus } from '../types'
5
6// The side pane earlier versions opened; closed on start so it does not linger.
7const PANE = 'codex-review-switch'
8const gate = atom({ plugin: 'codex-review-switch', key: 'gate' } as const, {
9  phase: 'loading',
10  isEnabled: false,
11  isBusy: false,
12  isCodexReady: false,
13  detail: '',
14} as GateStatus)
15const reviews = atom({ plugin: 'codex-review-switch', key: 'reviews' } as const, [] as CodexReview[])
16const STORE_KEY = 'reviews'
17const MAX_REVIEWS = 200
18
19const CLAUDE_COLOR = '#D97757'
20const CODEX_COLOR = '#10A37F'
21const SWITCH_ON = '#22C55E'
22const SWITCH_OFF = '#9CA3AF'
23const SWITCH_BUSY = '#D1D5DB'
24const KNOB = '#FFFFFF'
25// Non-breaking spaces, so a surface keeps the track's width.
26const PAD = '\u00A0\u00A0'
27// The empty label of the Button laid over the switch picture, about as wide as the picture.
28const HIT_AREA = '\u00A0'.repeat(6)
29
30// A macOS-style switch: a rounded track, green when on, and a white knob with a soft shadow.
31function switchSvg(look: 'on' | 'off' | 'busy'): string {
32  const track = look === 'on' ? '#34C759' : look === 'busy' ? '#D1D1D6' : '#E5E5EA'
33  const knobX = look === 'on' ? 27 : look === 'busy' ? 19 : 11
34  return [
35    '<svg xmlns="http://www.w3.org/2000/svg" width="38" height="22" viewBox="0 0 38 22">',
36    '<defs><filter id="s" x="-50%" y="-50%" width="200%" height="200%">',
37    '<feDropShadow dx="0" dy="1" stdDeviation="0.8" flood-color="#000" flood-opacity="0.3"/></filter></defs>',
38    `<rect x="0" y="0" width="38" height="22" rx="11" fill="${track}"/>`,
39    `<circle cx="${knobX}" cy="11" r="9" fill="#FFFFFF" filter="url(#s)"/>`,
40    '</svg>',
41  ].join('')
42}
43const VERDICT = {
44  pass: { mark: '✓', color: '#22C55E', label: '지적 사항 없음' },
45  block: { mark: '✗', color: '#EF4444', label: '지적 있음' },
46  error: { mark: '!', color: '#EAB308', label: '리뷰 실행 실패' },
47} as const
48
49// Runs the newest installed codex plugin's setup, with the plugin's own data
50// dir so the gate lands in the same state file its Stop hook reads.
51const SETUP = [
52  'P=$(ls -d "$HOME"/.claude/plugins/cache/openai-codex/codex/*/ 2>/dev/null | sort -V | tail -1)',
53  '[ -n "$P" ] || { echo "Codex 플러그인을 찾을 수 없습니다" >&2; exit 1; }',
54  'CLAUDE_PLUGIN_DATA="$HOME/.claude/plugins/data/codex-openai-codex" exec node "${P}scripts/codex-companion.mjs" setup --json "$@"',
55].join('\n')
56
57// The newest finished job of this Claude session, as the codex plugin recorded it.
58const RESULT = [
59  'P=$(ls -d "$HOME"/.claude/plugins/cache/openai-codex/codex/*/ 2>/dev/null | sort -V | tail -1)',
60  '[ -n "$P" ] || exit 1',
61  'CLAUDE_PLUGIN_DATA="$HOME/.claude/plugins/data/codex-openai-codex" exec node "${P}scripts/codex-companion.mjs" result --json',
62].join('\n')
63
64function normalize(text: string): string {
65  return text.replace(/\s+/g, ' ').trim()
66}
67
68// Reads the stop-gate review the codex plugin's Stop hook just ran, if it ran one.
69async function recordReview(
70  $: EngineInterface,
71  e: { session_id: string; cwd: string; last_assistant_message?: string },
72  since: string,
73  block: string | undefined,
74) {
75  const { exitCode, stdout } = await $.process.run(['/bin/sh', '-c', RESULT], {
76    cwd: e.cwd,
77    env: { CODEX_COMPANION_SESSION_ID: e.session_id },
78    timeoutMs: 30_000,
79  })
80  if (exitCode !== 0) return
81  const { job, storedJob } = JSON.parse(stdout)
82  if (job?.title !== 'Codex Stop Gate Review' || !(job.createdAt >= since)) return
83
84  const raw = String(storedJob?.result?.rawOutput ?? job.summary ?? '').trim()
85  const [first = '', ...rest] = raw.split(/\r?\n/)
86  const verdict: CodexReview['verdict'] = first.startsWith('ALLOW:')
87    ? 'pass'
88    : first.startsWith('BLOCK:')
89      ? 'block'
90      : 'error'
91  const review: CodexReview = {
92    id: String(job.id),
93    verdict,
94    summary: verdict === 'error' ? block ?? (first || '결과를 읽을 수 없습니다') : first.replace(/^(ALLOW|BLOCK):\s*/, ''),
95    detail: rest.join('\n').trim(),
96    replyText: normalize(e.last_assistant_message ?? ''),
97    at: new Date().toISOString(),
98  }
99  const list = await update($, reviews, all => [...all.filter(r => r.id !== review.id), review].slice(-MAX_REVIEWS))
100  await $.store.set(STORE_KEY, list)
101}
102
103async function refresh($: EngineInterface, flag?: string) {
104  await update($, gate, g => ({ ...g, isBusy: true }))
105  try {
106    const argv = ['/bin/sh', '-c', SETUP, 'sh', ...(flag ? [flag] : [])]
107    const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: 60_000 })
108    if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
109    const report = JSON.parse(stdout)
110    await update($, gate, (): GateStatus => ({
111      phase: 'ready',
112      isEnabled: Boolean(report.reviewGateEnabled),
113      isBusy: false,
114      isCodexReady: Boolean(report.ready),
115      detail: report.ready ? report.auth?.detail ?? '' : (report.nextSteps ?? []).join(' '),
116    }))
117  } catch (error) {
118    const detail = error instanceof Error ? error.message : String(error)
119    await update($, gate, (g): GateStatus => ({ ...g, phase: 'error', isBusy: false, detail }))
120  }
121}
122
123export const register: Register = on => {
124  on('session.start', async ($, e, next) => {
125    const started = await next(e)
126    await $.command.register({
127      name: 'codex-review-switch',
128      description: 'Codex 리뷰 게이트 상태 다시 읽기',
129    })
130    const kept = await $.store.get(STORE_KEY)
131    if (Array.isArray(kept)) await update($, reviews, () => kept as CodexReview[])
132    void $.ui.close({ id: PANE })
133    void refresh($)
134
135    return started
136  })
137
138  on('classic.Stop', async ($, e, next) => {
139    const since = new Date().toISOString()
140    const result = await next(e)
141    try {
142      await recordReview($, e, since, result.block)
143    } catch (error) {
144      $.ui.log(`codex-review-switch: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
145    }
146
147    return result
148  })
149
150  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
151    const { Box, Text } = $.ui.resolve(e)
152    const own = await next(e)
153    const text = normalize(e.props.text)
154    const all = await read($, reviews)
155    const review = text ? [...all].reverse().find(r => r.replyText.endsWith(text)) : undefined
156    if (!e.props.isFirstOfReply && !review) return own
157    const look = review && VERDICT[review.verdict]
158
159    return (
160      <Box flexDirection="column">
161        {e.props.isFirstOfReply && (
162          <Text bold color={CLAUDE_COLOR}>
163            ✻ Claude
164          </Text>
165        )}
166        {own}
167        {review && look && (
168          <Box flexDirection="column" borderStyle="round" borderColor={CODEX_COLOR} paddingX={1} marginTop={1}>
169            <Text>
170              <Text bold color={CODEX_COLOR}>
171                ◆ Codex
172              </Text>{' '}
173              <Text bold color={look.color}>
174                {look.mark} {look.label}
175              </Text>
176            </Text>
177            {review.summary !== '' && <Text dimColor>{review.summary}</Text>}
178            {review.detail !== '' && <Text>{review.detail}</Text>}
179          </Box>
180        )}
181      </Box>
182    )
183  })
184
185  on('command.run', { command: 'codex-review-switch' }, async $ => {
186    await refresh($)
187    const g = await read($, gate)
188    const text =
189      g.phase === 'error'
190        ? `Codex 리뷰 상태를 읽지 못했습니다: ${g.detail}`
191        : `Codex 리뷰 게이트: ${g.isEnabled ? '켜짐' : '꺼짐'} (현재 프로젝트)`
192
193    return { text }
194  })
195
196  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
197    if (e.props.hasSurvey) return next(e)
198
199    const { Box, Text, Button } = $.ui.resolve(e)
200    const g = await read($, gate)
201    const isOn = g.isEnabled
202    const state = g.isBusy ? '처리 중…' : isOn ? '켜짐' : '꺼짐'
203    const note =
204      g.phase === 'loading'
205        ? { color: undefined, text: '상태 확인 중…' }
206        : g.phase === 'error'
207          ? { color: 'red', text: g.detail }
208          : !g.isCodexReady
209            ? { color: 'yellow', text: `Codex 미준비: ${g.detail}` }
210            : { color: undefined, text: isOn ? '답변마다 리뷰 · 최대 15분' : '/codex:review 로 직접 리뷰' }
211
212    const track = g.isBusy ? SWITCH_BUSY : isOn ? SWITCH_ON : SWITCH_OFF
213    const knob = g.isBusy ? `${PAD}●${PAD}` : isOn ? `${PAD}${PAD}●` : `●${PAD}${PAD}`
214    // Only the remote surfaces carry Svg; the terminal draws the switch in text.
215    let picture = null
216    if (e.surface !== 'terminal') {
217      const { Svg } = $.ui.resolve(e)
218      picture = (
219        <Svg source={switchSvg(g.isBusy ? 'busy' : isOn ? 'on' : 'off')} alt={`Codex 리뷰 ${state}`} width={38} height={22} />
220      )
221    }
222    const toggle = () => {
223      if (g.isBusy || g.phase !== 'ready') return
224      void refresh($, isOn ? '--disable-review-gate' : '--enable-review-gate')
225    }
226
227    return (
228      <Box flexDirection="row" gap={1} alignItems="center">
229        <Text bold color={CODEX_COLOR}>
230          ◆ Codex 리뷰
231        </Text>
232        {/* A pill track with the knob at the right when on, at the left when off. */}
233        {e.surface === 'terminal' ? (
234          <Button key="toggle" plain onPress={toggle}>
235            <Text backgroundColor={track} color={KNOB}>
236              {knob}
237            </Text>
238          </Button>
239        ) : (
240          // A remote surface draws a Button in its own colors, so the switch is a picture with an
241          // empty, chromeless Button laid over it to take the press.
242          <Box marginRight={1}>
243            {picture}
244            <Box position="absolute" top={0} left={0}>
245              <Button key="toggle" plain onPress={toggle}>
246                {HIT_AREA}
247              </Button>
248            </Box>
249          </Box>
250        )}
251        <Text bold={isOn} color={isOn && !g.isBusy ? SWITCH_ON : undefined} dimColor={!isOn || g.isBusy}>
252          {state}
253        </Text>
254        <Button key="refresh" label="↻" plain dimColor onPress={() => void refresh($)} />
255        <Text dimColor={note.color === undefined} color={note.color}>
256          {note.text}
257        </Text>
258      </Box>
259    )
260  })
261}
262
types/index.d.ts 24 lines
1export type GateStatus = {
2  phase: 'loading' | 'ready' | 'error'
3  isEnabled: boolean
4  isBusy: boolean
5  isCodexReady: boolean
6  detail: string
7}
8
9// One stop-gate review, tied to the Claude reply it reviewed by that reply's text.
10export type CodexReview = {
11  id: string
12  verdict: 'pass' | 'block' | 'error'
13  summary: string
14  detail: string
15  replyText: string
16  at: string
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'codex-review-switch': { gate: GateStatus; reviews: CodexReview[] }
22  }
23}
24