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

Claude Code에서 Codex 리뷰 게이트를 입력창 위 토글 스위치로 켜고 끄는 모드입니다. 대화에서는 Claude와 Codex의 답변을 색과 아이콘으로 구분합니다.
◆ Codex 리뷰 옆 스위치로 현재 프로젝트(git 루트)의 stop-time 리뷰 게이트를 켜고 끕니다. 데스크톱 앱에서는 macOS 스타일 토글, 터미널에서는 글자 스위치로 그립니다.✻ Claude를 붙입니다.✓ 지적 사항 없음: 통과해도 결과를 남깁니다.✗ 지적 있음: 지적 내용을 보여주고, Claude가 이어서 수정합니다.! 리뷰 실행 실패: 리뷰가 실패한 이유를 보여줍니다./codex-review-switch: 게이트 상태를 다시 읽어 알려줍니다.codex@openai-codex)이 설치되어 있어야 합니다. 리뷰 자체는 이 플러그인의 Stop 훅이 실행하고, 이 모드는 스위치와 결과 표시를 맡습니다./codex:setup을 실행해 확인하세요./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을 누르세요.
설치 후 새 세션부터 적용됩니다.
MIT
hooks/register.tsx 262 lines1import { 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}
262types/index.d.ts 24 lines1export 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