Codex 리뷰·작업 진행 상황을 우측 패널 대시보드로 표시

Claude Code mod 마켓플레이스.
claude plugin marketplace add ban-dal/claude-mods
claude plugin install usage-meter@claude-mods
claude plugin install codex-board@claude-mods
프롬프트 위에 컨텍스트 사용량, 프롬프트 캐시, 세션(5시간) 한도, 주간(7일) 한도, 세션 비용을 한 줄로 표시한다.
<img src="docs/usage-meter.png" alt="usage-meter 미터 줄" width="640">
◔ ↯ ◷ ▦ $ 글자)상세: 상세 패널. 탭으로 컨텍스트 구성(항목별 토큰), 캐시 상세(직전 응답의 읽기·쓰기·미캐시 토큰, 세션 누적), 세션/주간 사용 추이 그래프와 리셋 시 예상 사용률을 전환/usage-meter: 표시/숨기기Codex 플러그인으로 요청한 리뷰·작업의 진행 상황을 우측 패널에 표시한다.
<img src="docs/codex-board.png" alt="codex-board 패널" width="420">
~/.claude/plugins/data/codex-*/state)을 읽어 단계, 경과 시간, 최근 로그를 갱신결과로 지적 목록이나 원문을 펼침--model·--effort → ~/.codex/config.toml 순으로 확인codex-companion cancel로 자동 종료/codex-board: 패널 열기/닫기 (열어 두면 다음 세션에도 열림)claude plugin validate plugins/usage-meter
claude plugin test plugins/usage-meter
claude plugin validate plugins/codex-board
claude plugin test plugins/codex-boardhooks/register.tsx 556 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Job, JobDetail, Pending, Run, Scope } from '../types'
5import {
6 baseName,
7 companionOf,
8 countBySeverity,
9 elapsedOf,
10 formatClock,
11 formatElapsed,
12 idleOf,
13 isActive,
14 kindLabelOf,
15 latestVersion,
16 marketplaceOf,
17 parseCodexConfig,
18 parseDetail,
19 parseRunFlags,
20 parseState,
21 PHASE_LABEL,
22 pluginDataOf,
23 resolveRun,
24 SEVERITY_LABEL,
25 sortJobs,
26 stepOf,
27 STEPS,
28} from './jobs'
29import { PALETTE, stepsSvg } from './theme'
30import type { Palette } from './theme'
31
32const jobs = atom({ plugin: 'codex-board', key: 'jobs' } as const, [])
33const details = atom({ plugin: 'codex-board', key: 'details' } as const, {})
34const pending = atom({ plugin: 'codex-board', key: 'pending' } as const, [])
35const expanded = atom({ plugin: 'codex-board', key: 'expanded' } as const, null)
36const scope = atom({ plugin: 'codex-board', key: 'scope' } as const, 'mine')
37const isDark = atom({ plugin: 'codex-board', key: 'isDark' } as const, true)
38const runs = atom({ plugin: 'codex-board', key: 'runs' } as const, {})
39const defaults = atom({ plugin: 'codex-board', key: 'defaults' } as const, {})
40const timedOut = atom({ plugin: 'codex-board', key: 'timedOut' } as const, [])
41
42const PANE = 'codex-board'
43const TITLE = 'Codex'
44const COMMAND = 'codex-board'
45const TICK_MS = 1000
46// 진행 중인 작업이 없을 때는 이 틱마다 한 번만 읽는다
47const IDLE_TICKS = 10
48const MAX_JOBS = 50
49const MAX_SHOWN = 8
50const MAX_FINDINGS = 12
51// 요청 직후 상태 파일에 작업이 생기기까지 기다리는 시간
52const PENDING_TTL = 90_000
53const CODEX_RUN = /codex-companion\.mjs["']?\s+(review|adversarial-review|task)\b/
54const RUN_LABEL: Record<string, string> = { review: '리뷰', 'adversarial-review': '적대적 리뷰', task: '작업' }
55const SPINNER = ['◐', '◓', '◑', '◒']
56// 로그가 이만큼 멈춰 있으면 마지막 활동 시각을 표시한다
57const STALL_MS = 30_000
58// 로그가 이만큼 멈춰 있으면 응답 없는 작업으로 보고 codex-companion cancel로 종료한다
59const TIMEOUT_MS = 10 * 60_000
60const CANCEL_TIMEOUT_MS = 60_000
61
62type Dirs = { configDir: string; tmpDir: string; codexHome: string }
63type Context = Dirs & { sessionId: string; root: string; startedAt: number }
64
65// 세션 정보는 재로드 때 session.start에서 다시 채운다
66let context: Context = { sessionId: '', root: '', configDir: '', tmpDir: '/tmp', codexHome: '', startedAt: 0 }
67let ticks = 0
68let isRefreshing = false
69
70async function locateDirs($: EngineInterface): Promise<Dirs> {
71 const probe = await $.process
72 .run(['sh', '-c', 'printf "%s\\n%s\\n%s" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" "${TMPDIR:-/tmp}" "${CODEX_HOME:-$HOME/.codex}"'])
73 .catch(() => undefined)
74 const [configDir = '', tmpDir = '', codexHome = ''] = (probe?.stdout ?? '').split('\n').map(line => line.trim().replace(/\/$/, ''))
75 const fromRoot = $.plugin.root.match(/^(.*?)\/(?:plugins\/cache|dev-mods)\//)?.[1]
76 return { configDir: configDir || fromRoot || '', tmpDir: tmpDir || '/tmp', codexHome }
77}
78
79async function refreshDefaults($: EngineInterface) {
80 if (context.codexHome === '') return
81 const toml = await $.fs.read(`${context.codexHome}/config.toml`).catch(() => undefined)
82 const next = typeof toml === 'string' ? parseCodexConfig(toml) : {}
83 if (JSON.stringify(await read($, defaults)) !== JSON.stringify(next)) await update($, defaults, () => next)
84}
85
86// 요청마다 그 뒤에 생긴 같은 종류의 작업을 요청 순서대로 하나씩 짝짓고,
87// 요청 명령에서 읽은 model·effort를 붙인다. 짝지은 요청의 id를 돌려준다
88async function claimRuns($: EngineInterface, list: Job[], waiting: Pending[]): Promise<Set<string>> {
89 const known = await read($, runs)
90 const claimed: Record<string, Run> = {}
91 const matched = new Set<string>()
92 for (const one of [...waiting].sort((a, b) => a.at - b.at)) {
93 const match = list
94 .filter(
95 job =>
96 isOwn(job) &&
97 job.kind === one.kind &&
98 known[job.id] === undefined &&
99 claimed[job.id] === undefined &&
100 Date.parse(job.createdAt) >= one.at - 5_000,
101 )
102 .sort((a, b) => a.createdAt.localeCompare(b.createdAt))[0]
103 if (match === undefined) continue
104 claimed[match.id] = one.isAmbiguous === true ? {} : (one.run ?? {})
105 matched.add(one.id)
106 }
107 if (matched.size === 0) return matched
108 const ids = new Set(list.map(job => job.id))
109 // 동시에 끝난 다른 요청의 짝짓기를 덮어쓰지 않도록 최신 값에 합친다
110 const saved = await update($, runs, current => ({
111 ...Object.fromEntries(Object.entries(current).filter(([id]) => ids.has(id))),
112 ...claimed,
113 }))
114 await $.store.set('runs', saved).catch(() => undefined)
115 return matched
116}
117
118// 첫 Codex 요청 때 생기는 디렉터리도 잡도록 갱신할 때마다 다시 찾는다
119async function stateRoots($: EngineInterface): Promise<string[]> {
120 const roots: string[] = []
121 const dataDir = `${context.configDir}/plugins/data`
122 for (const entry of await $.fs.list(dataDir).catch(() => [])) {
123 if (entry.kind === 'dir' && entry.name.startsWith('codex')) roots.push(`${dataDir}/${entry.name}/state`)
124 }
125 roots.push(`${context.tmpDir}/codex-companion`)
126 return roots
127}
128
129const isMine = (job: Job) => job.sessionId === context.sessionId || job.workspaceRoot === context.root
130// 다른 세션이 같은 레포에서 시작한 작업은 빼고, 세션 ID가 없는 작업만 레포로 판단한다
131const isOwn = (job: Job) => (job.sessionId === undefined ? isMine(job) : job.sessionId === context.sessionId)
132const inScope = (current: Scope) => (job: Job) => current === 'all' || isMine(job)
133
134async function loadJobs($: EngineInterface): Promise<Job[]> {
135 const loaded: Job[] = []
136 for (const root of await stateRoots($)) {
137 for (const entry of await $.fs.list(root).catch(() => [])) {
138 if (entry.kind !== 'dir') continue
139 const dir = `${root}/${entry.name}`
140 const source = await $.fs.read(`${dir}/state.json`).catch(() => undefined)
141 if (typeof source === 'string') loaded.push(...parseState(source, dir))
142 }
143 }
144 const byId = new Map<string, Job>()
145 for (const job of loaded) {
146 const known = byId.get(job.id)
147 if (known === undefined || known.updatedAt < job.updatedAt) byId.set(job.id, job)
148 }
149 // 다른 레포의 작업이 많아도 이 세션의 작업은 잘리지 않게 한다
150 const sorted = sortJobs([...byId.values()])
151 const mine = sorted.filter(isMine)
152 const others = sorted.filter(job => !isMine(job)).slice(0, Math.max(0, MAX_JOBS - mine.length))
153 return sortJobs([...mine, ...others])
154}
155
156async function loadDetails($: EngineInterface, list: Job[], current: Record<string, JobDetail>, open: string | null) {
157 const next: Record<string, JobDetail> = {}
158 for (const job of list) {
159 const known = current[job.id]
160 const needsLog = isActive(job) || job.id === open
161 // 끝난 작업은 결과를 읽을 때까지 다시 시도한다
162 const isSettled = isActive(job) || known?.isResultLoaded === true
163 if (known !== undefined && known.updatedAt === job.updatedAt && !needsLog && isSettled) {
164 next[job.id] = known
165 continue
166 }
167 const jobFile = `${job.stateDir}/jobs/${job.id}.json`
168 // 진행 중에도 백그라운드 작업의 요청 값을 읽는다
169 const source = await $.fs.read(jobFile).catch(() => undefined)
170 const log = await $.fs.read(job.logFile ?? `${job.stateDir}/jobs/${job.id}.log`).catch(() => undefined)
171 next[job.id] = parseDetail(typeof source === 'string' ? source : undefined, typeof log === 'string' ? log : undefined, job.updatedAt)
172 }
173 return next
174}
175
176function outcomeOf(job: Job, detail: JobDetail | undefined, stopped: readonly string[]): string {
177 if (job.status === 'failed') return `실패${job.errorMessage ? ` · ${job.errorMessage}` : ''}`
178 if (job.status === 'cancelled') return stopped.includes(job.id) ? `${TIMEOUT_MS / 60_000}분 동안 응답이 없어 종료됨` : '취소됨'
179 const counts = countBySeverity(detail?.findings ?? [])
180 const found = counts.map(([severity, count]) => `${SEVERITY_LABEL[severity]} ${count}`).join(' · ')
181 if (detail?.verdict === 'approve') return '승인'
182 if (found !== '') return `지적 ${found}`
183 if (detail?.verdict === 'needs-attention') return '확인 필요'
184 return '완료'
185}
186
187async function notify($: EngineInterface, before: Job[], after: Job[], loaded: Record<string, JobDetail>, stopped: readonly string[]) {
188 const previous = new Map(before.map(job => [job.id, job]))
189 let hasStarted = false
190 for (const job of after.filter(isMine)) {
191 const old = previous.get(job.id)
192 // 이전 세션에서 시작해 아직 도는 작업으로는 패널을 열지 않는다
193 // 세션 ID가 없는 작업만 시작 시각으로 판단한다
194 const isStartedHere =
195 job.sessionId !== undefined ? job.sessionId === context.sessionId : Date.parse(job.createdAt) >= context.startedAt
196 if (isActive(job) && isStartedHere && (old === undefined || !isActive(old))) hasStarted = true
197 // 두 번의 갱신 사이에 시작하고 끝난 작업도 알린다
198 const hasFinishedSinceStart = old === undefined && Date.parse(job.completedAt ?? job.updatedAt) >= context.startedAt
199 if (isStartedHere && !isActive(job) && ((old !== undefined && isActive(old)) || hasFinishedSinceStart)) {
200 $.ui.toast(`Codex ${kindLabelOf(job)} ${outcomeOf(job, loaded[job.id], stopped)}`)
201 }
202 }
203 if (hasStarted) await $.ui.open({ id: PANE, title: TITLE })
204}
205
206// 플러그인 캐시의 최신 버전을 먼저 찾고, 없으면 요청 명령에서 본 경로를 쓴다
207async function companionFor($: EngineInterface, job: Job): Promise<string | undefined> {
208 const market = marketplaceOf(job.stateDir)
209 if (market !== undefined) {
210 const base = `${context.configDir}/plugins/cache/${market}/codex`
211 const versions = (await $.fs.list(base).catch(() => [])).filter(entry => entry.kind === 'dir').map(entry => entry.name)
212 const version = latestVersion(versions)
213 if (version !== undefined) return `${base}/${version}/scripts/codex-companion.mjs`
214 }
215 const seen = await $.store.get('companion')
216 return typeof seen === 'string' ? seen : undefined
217}
218
219async function cancel($: EngineInterface, job: Job) {
220 const script = await companionFor($, job)
221 const result =
222 script === undefined
223 ? undefined
224 : await $.process
225 .run(['node', script, 'cancel', job.id, '--cwd', job.workspaceRoot, '--json'], {
226 cwd: job.workspaceRoot,
227 env: { CLAUDE_PLUGIN_DATA: pluginDataOf(job.stateDir) },
228 timeoutMs: CANCEL_TIMEOUT_MS,
229 })
230 .catch(() => undefined)
231 if (result?.exitCode !== 0) $.ui.toast(`Codex ${kindLabelOf(job)} 자동 종료 실패 · /codex:cancel ${job.id}`)
232}
233
234// 이 세션의 응답 없는 작업마다 한 번만 종료를 시도한다. 종료는 갱신을 막지 않도록 기다리지 않는다
235async function stopIdle($: EngineInterface, list: Job[], loaded: Record<string, JobDetail>, now: number) {
236 const tried = await read($, timedOut)
237 const idle = list.filter(job => isOwn(job) && !tried.includes(job.id) && (idleOf(job, loaded[job.id], now) ?? 0) >= TIMEOUT_MS)
238 const ids = new Set(list.map(job => job.id))
239 const kept = tried.filter(id => ids.has(id))
240 if (idle.length === 0 && kept.length === tried.length) return
241 await update($, timedOut, () => [...kept, ...idle.map(job => job.id)])
242 for (const job of idle) void cancel($, job)
243}
244
245async function refresh($: EngineInterface) {
246 if (isRefreshing || context.sessionId === '') return
247 isRefreshing = true
248 try {
249 const before = await read($, jobs)
250 const after = await loadJobs($)
251 const open = await read($, expanded)
252 const shown = after.filter(isMine).slice(0, MAX_SHOWN)
253 const visible = [...shown, ...after.filter(job => !shown.includes(job)).slice(0, MAX_SHOWN)]
254 const loaded = await loadDetails($, visible, await read($, details), open)
255
256 if (JSON.stringify(before) !== JSON.stringify(after)) await update($, jobs, () => after)
257 if (JSON.stringify(await read($, details)) !== JSON.stringify(loaded)) await update($, details, () => loaded)
258 await notify($, before, after, loaded, await read($, timedOut))
259
260 await refreshDefaults($)
261
262 const now = await $.clock.now()
263 await stopIdle($, visible, loaded, now)
264
265 // 상태 파일에 작업이 생겼거나 오래된 요청은 지운다
266 const waiting = await read($, pending)
267 const matched = await claimRuns($, after, waiting)
268 const kept = waiting.filter(one => now - one.at < PENDING_TTL && !matched.has(one.id))
269 if (kept.length !== waiting.length) await update($, pending, () => kept)
270 } finally {
271 isRefreshing = false
272 }
273}
274
275async function refreshTheme($: EngineInterface) {
276 const rows = await $.config.list().catch(() => [])
277 const theme = rows.find(row => row.key === 'theme')?.value
278 if (typeof theme === 'string') await update($, isDark, () => !theme.startsWith('light'))
279}
280
281// 요청이 끝나면 성공·실패와 상관없이 '요청 중' 행을 지운다
282async function track<T>($: EngineInterface, request: Omit<Pending, 'at'>, run: () => Promise<T>): Promise<T> {
283 const now = await $.clock.now()
284 await update($, pending, list => {
285 const others = list.filter(one => one.id !== request.id)
286 const runOf = (one: Pick<Pending, 'run'>) => JSON.stringify(one.run ?? {})
287 const clashes = (one: Pending) => one.kind === request.kind && runOf(one) !== runOf(request)
288 const isAmbiguous = others.some(clashes)
289 return [...others.map(one => (clashes(one) ? { ...one, isAmbiguous: true } : one)), { ...request, at: now, ...(isAmbiguous ? { isAmbiguous } : {}) }]
290 })
291 await $.ui.open({ id: PANE, title: TITLE })
292 try {
293 return await run()
294 } finally {
295 // 백그라운드 실행은 작업이 생기기 전에 끝날 수 있어, 짝짓지 못한 요청은 표시만 끄고 남긴다
296 const matched = await claimRuns($, await loadJobs($), await read($, pending))
297 await update($, pending, list =>
298 matched.has(request.id) ? list.filter(one => one.id !== request.id) : list.map(one => (one.id === request.id ? { ...one, isDone: true } : one)),
299 )
300 await refresh($)
301 }
302}
303
304export const register: Register = on => {
305 on('session.start', async ($, e, next) => {
306 await $.command.register({ name: COMMAND, description: 'Codex 리뷰 대시보드 열기/닫기' })
307 context = { sessionId: await $.session.id(), root: await $.session.root(), ...(await locateDirs($)), startedAt: await $.clock.now() }
308 const stored = await $.store.get('runs')
309 if (stored !== null && typeof stored === 'object') await update($, runs, () => stored as Record<string, Run>)
310 await refreshTheme($)
311 await refresh($)
312
313 $.clock.every(TICK_MS, async () => {
314 ticks += 1
315 const isBusy = (await read($, jobs)).some(job => isMine(job) && isActive(job)) || (await read($, pending)).length > 0
316 if (isBusy || ticks % IDLE_TICKS === 0) await refresh($)
317 // 경과 시간을 초 단위로 갱신한다
318 if (isBusy) $.ui.invalidate('ui.render')
319 })
320
321 if ((await $.store.get('isPinned')) === true) void $.ui.open({ id: PANE, title: TITLE })
322 return next(e)
323 })
324
325 on('config.set', { key: 'theme' }, async ($, e, next) => {
326 const result = await next(e)
327 await refreshTheme($)
328 return result
329 })
330
331 on('command.run', { command: COMMAND }, async $ => {
332 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
333 if (isOpen) {
334 await $.ui.close({ id: PANE })
335 await $.store.set('isPinned', false)
336 return { text: 'Codex 대시보드를 닫았습니다.' }
337 }
338 await refresh($)
339 await $.ui.open({ id: PANE, title: TITLE })
340 await $.store.set('isPinned', true)
341 return { text: 'Codex 대시보드를 열었습니다. 다음 세션에도 열린 상태로 시작합니다.' }
342 })
343
344 on('ui.close', async ($, e, next) => {
345 if (e.id === PANE && e.origin.kind === 'person') await $.store.set('isPinned', false)
346 return next(e)
347 })
348
349 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
350 const kind = e.command.match(CODEX_RUN)?.[1]
351 if (kind === undefined || e.tool_use_id === undefined) return next(e)
352 // 상태 디렉터리로 플러그인 위치를 알 수 없을 때 종료에 쓴다
353 const script = companionOf(e.command)
354 if (script?.startsWith('/') === true) await $.store.set('companion', script)
355 const request = { id: e.tool_use_id, label: `Codex ${RUN_LABEL[kind] ?? kind}`, kind, run: parseRunFlags(e.command) }
356 return track($, request, () => next(e))
357 })
358
359 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
360 if (e.subagent_type !== 'codex:codex-rescue' || e.tool_use_id === undefined) return next(e)
361 return track($, { id: e.tool_use_id, label: 'Codex rescue 위임', kind: 'task' }, () => next(e))
362 })
363
364 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
365 const table: ElementTable<RenderSurface> = $.ui.resolve(e)
366 const { Box, Text, Button, Markdown } = table
367 const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
368 const palette = PALETTE[(await read($, isDark)) ? 'dark' : 'light']
369 const current = await read($, scope)
370 const all = await read($, jobs)
371 const loaded = await read($, details)
372 const opened = await read($, expanded)
373 const waiting = (await read($, pending)).filter(one => one.isDone !== true)
374 const requested = await read($, runs)
375 const configured = await read($, defaults)
376 const stopped = await read($, timedOut)
377 const now = await $.clock.now()
378 const width = e.props.bodyColumns
379 const spinner = SPINNER[Math.floor(now / TICK_MS) % SPINNER.length] ?? '◐'
380
381 const list = all.filter(inScope(current))
382 const shown = list.slice(0, MAX_SHOWN)
383 const running = list.filter(isActive).length
384 const done = list.filter(job => job.status === 'completed').length
385 const failed = list.filter(job => job.status === 'failed').length
386
387 const toggleScope = () => update($, scope, value => (value === 'mine' ? 'all' : 'mine'))
388 const toggleJob = (id: string) => () => update($, expanded, value => (value === id ? null : id))
389
390 const statusOf = (job: Job): { icon: string; label: string; color: string } => {
391 if (isActive(job)) return { icon: spinner, label: PHASE_LABEL[job.phase] ?? job.phase, color: palette.active }
392 if (job.status === 'failed') return { icon: '✕', label: '실패', color: palette.danger }
393 if (job.status === 'cancelled') return { icon: '■', label: '취소됨', color: palette.muted }
394 const detail = loaded[job.id]
395 const hasIssues = (detail?.findings?.length ?? 0) > 0 || detail?.verdict === 'needs-attention'
396 return hasIssues ? { icon: '!', label: '확인 필요', color: palette.warn } : { icon: '✓', label: '완료', color: palette.success }
397 }
398
399 const stepper = (job: Job) => {
400 const step = stepOf(job)
401 if (Svg) {
402 const barWidth = Math.max(120, Math.min(260, width * 6))
403 return (
404 <Box flexDirection="column" rowGap={0}>
405 <Svg
406 key={`steps-${job.id}`}
407 source={stepsSvg(STEPS.length, step, barWidth, palette)}
408 alt={`${STEPS.length}단계 중 ${step + 1}단계`}
409 width={barWidth}
410 height={4}
411 />
412 </Box>
413 )
414 }
415 return (
416 <Box flexDirection="row">
417 {STEPS.map((label, index) => (
418 <Text key={label} color={index <= step ? palette.active : palette.muted} bold={index === step}>
419 {index > 0 ? ' › ' : ''}
420 {label}
421 </Text>
422 ))}
423 </Box>
424 )
425 }
426
427 const findingsOf = (detail: JobDetail) => {
428 const findings = detail.findings ?? []
429 if (findings.length === 0 || detail.verdict === undefined) {
430 return detail.rendered === undefined ? <Text color={palette.label}>결과를 아직 읽지 못했습니다.</Text> : <Markdown text={detail.rendered} />
431 }
432 const severityColor = { critical: palette.danger, high: palette.danger, medium: palette.warn, low: palette.label }
433 return (
434 <Box flexDirection="column" rowGap={0}>
435 {findings.slice(0, MAX_FINDINGS).map((finding, index) => (
436 <Box key={`finding-${index}`} flexDirection="column">
437 <Box flexDirection="row" columnGap={1}>
438 <Text color={severityColor[finding.severity]} bold>
439 {SEVERITY_LABEL[finding.severity]}
440 </Text>
441 <Text color={palette.value} wrap="wrap">
442 {finding.title}
443 </Text>
444 </Box>
445 {finding.location !== undefined && (
446 <Text color={palette.muted} wrap="truncate-start">
447 {' '}
448 {finding.location}
449 </Text>
450 )}
451 </Box>
452 ))}
453 {findings.length > MAX_FINDINGS && <Text color={palette.muted}>외 {findings.length - MAX_FINDINGS}건</Text>}
454 </Box>
455 )
456 }
457
458 const card = (job: Job) => {
459 const status = statusOf(job)
460 const detail = loaded[job.id]
461 const elapsed = elapsedOf(job, now)
462 const isOpen = opened === job.id
463 const idleMs = idleOf(job, detail, now)
464 const untilStop = isOwn(job) && idleMs !== undefined ? TIMEOUT_MS - idleMs : undefined
465 const canOpen = !isActive(job) && detail !== undefined && (detail.rendered !== undefined || (detail.findings?.length ?? 0) > 0)
466 const where = job.workspaceRoot === context.root ? '' : `${baseName(job.workspaceRoot)} · `
467 const used = resolveRun(job, requested[job.id], detail?.request, configured)
468 const usedLabel = used === undefined ? undefined : [used.model, used.effort].filter(Boolean).join(' · ')
469
470 return (
471 <Box
472 key={`job-${job.id}`}
473 flexDirection="column"
474 borderStyle="round"
475 borderColor={isActive(job) ? palette.active : palette.border}
476 paddingX={1}
477 >
478 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
479 <Box flexDirection="row" columnGap={1} flexShrink={1}>
480 <Text color={status.color} bold>
481 {status.icon}
482 </Text>
483 <Text color={palette.value} bold wrap="truncate-end">
484 {kindLabelOf(job)}
485 </Text>
486 <Text color={status.color}>{status.label}</Text>
487 </Box>
488 {elapsed !== undefined && <Text color={palette.label}>{formatElapsed(elapsed)}</Text>}
489 </Box>
490 {job.summary !== '' && (
491 <Text color={palette.label} wrap="truncate-end">
492 {job.summary}
493 </Text>
494 )}
495 {isActive(job) && stepper(job)}
496 {idleMs !== undefined && idleMs >= STALL_MS && (
497 <Text color={palette.warn} wrap="truncate-end">
498 마지막 활동 {formatElapsed(idleMs)} 전{untilStop !== undefined && untilStop > 0 ? ` · ${formatElapsed(untilStop)} 뒤 자동 종료` : ''}
499 </Text>
500 )}
501 {isActive(job) &&
502 (detail?.logTail ?? []).slice(-3).map((line, index) => (
503 <Text key={`log-${index}`} color={palette.muted} wrap="truncate-end">
504 › {line}
505 </Text>
506 ))}
507 {!isActive(job) && (
508 <Text color={status.color} wrap="truncate-end">
509 {outcomeOf(job, detail, stopped)}
510 </Text>
511 )}
512 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
513 <Text color={palette.muted} wrap="truncate-end">
514 {where}
515 {formatClock(job.startedAt ?? job.createdAt)} 시작
516 {usedLabel ? ` · ${usedLabel}` : ''}
517 </Text>
518 {canOpen && <Button key={`open-${job.id}`} label={isOpen ? '접기 ▴' : '결과 ▾'} plain onPress={toggleJob(job.id)} />}
519 </Box>
520 {isOpen && detail !== undefined && findingsOf(detail)}
521 </Box>
522 )
523 }
524
525 return (
526 <Box flexDirection="column" rowGap={1}>
527 <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1}>
528 <Box flexDirection="row" columnGap={1}>
529 <Text color={running > 0 ? palette.active : palette.muted}>진행 {running}</Text>
530 <Text color={palette.border}>│</Text>
531 <Text color={palette.success}>완료 {done}</Text>
532 {failed > 0 && <Text color={palette.border}>│</Text>}
533 {failed > 0 && <Text color={palette.danger}>실패 {failed}</Text>}
534 </Box>
535 <Button key="scope" label={current === 'mine' ? '이 세션 ▾' : '전체 ▾'} plain onPress={toggleScope} />
536 </Box>
537 {waiting.map(one => (
538 <Box key={`pending-${one.id}`} flexDirection="row" columnGap={1} paddingX={1}>
539 <Text color={palette.active}>{spinner}</Text>
540 <Text color={palette.value}>{one.label}</Text>
541 <Text color={palette.label}>요청 중 · {formatElapsed(now - one.at)}</Text>
542 </Box>
543 ))}
544 {shown.length === 0 && waiting.length === 0 && (
545 <Box flexDirection="column" paddingX={1}>
546 <Text color={palette.label}>{current === 'mine' ? '이 세션의 Codex 작업이 없습니다.' : 'Codex 작업 기록이 없습니다.'}</Text>
547 <Text color={palette.muted}>/codex:review, /codex:rescue 로 요청하면 여기에 표시됩니다.</Text>
548 </Box>
549 )}
550 {shown.map(card)}
551 {list.length > shown.length && <Text color={palette.muted}>이전 작업 {list.length - shown.length}건은 /codex:status 에서 볼 수 있습니다.</Text>}
552 </Box>
553 )
554 })
555}
556hooks/jobs.ts 331 lines1import type { CodexDefaults, Finding, Job, JobDetail, JobStatus, Run, Severity } from '../types'
2
3const STATUSES: readonly JobStatus[] = ['queued', 'running', 'completed', 'failed', 'cancelled']
4const SEVERITIES: readonly Severity[] = ['critical', 'high', 'medium', 'low']
5// 네이티브 리뷰는 [P0]~[P3] 태그로 우선순위를 적는다
6const PRIORITY: Record<string, Severity> = { '0': 'critical', '1': 'high', '2': 'medium', '3': 'low' }
7const LOG_TAIL = 4
8const RENDERED_LIMIT = 9000
9
10export const PHASE_LABEL: Record<string, string> = {
11 queued: '대기 중',
12 starting: '시작 중',
13 reviewing: '리뷰 중',
14 investigating: '코드 조사 중',
15 running: '명령 실행 중',
16 verifying: '검증 중',
17 editing: '수정 중',
18 finalizing: '결과 정리 중',
19 done: '완료',
20 failed: '실패',
21 cancelled: '취소됨',
22}
23
24export const STEPS = ['대기', '시작', '분석', '정리', '완료']
25
26const KIND_LABEL: Record<string, string> = {
27 review: '리뷰',
28 'adversarial-review': '적대적 리뷰',
29 task: '작업',
30}
31
32export const SEVERITY_LABEL: Record<Severity, string> = {
33 critical: '치명',
34 high: '높음',
35 medium: '보통',
36 low: '낮음',
37}
38
39export function isActive(job: Job): boolean {
40 return job.status === 'queued' || job.status === 'running'
41}
42
43export function kindLabelOf(job: Job): string {
44 if (job.title === 'Codex Stop Gate Review') return '중단 시점 리뷰'
45 return KIND_LABEL[job.kind] ?? job.kind
46}
47
48export function stepOf(job: Job): number {
49 if (job.status === 'completed') return STEPS.length - 1
50 switch (job.phase) {
51 case 'queued':
52 return 0
53 case 'starting':
54 return 1
55 case 'finalizing':
56 return 3
57 case 'done':
58 return 4
59 default:
60 return 2
61 }
62}
63
64const text = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value : undefined)
65
66function toJob(raw: unknown, stateDir: string): Job | null {
67 if (!raw || typeof raw !== 'object') return null
68 const job = raw as Record<string, unknown>
69 const id = text(job.id)
70 const status = STATUSES.find(one => one === job.status)
71 const workspaceRoot = text(job.workspaceRoot)
72 if (id === undefined || status === undefined || workspaceRoot === undefined) return null
73 const createdAt = text(job.createdAt) ?? ''
74
75 return {
76 id,
77 kind: text(job.kind) ?? 'task',
78 title: text(job.title) ?? 'Codex',
79 summary: text(job.summary) ?? '',
80 status,
81 phase: text(job.phase) ?? status,
82 workspaceRoot,
83 sessionId: text(job.sessionId),
84 createdAt,
85 updatedAt: text(job.updatedAt) ?? createdAt,
86 startedAt: text(job.startedAt),
87 completedAt: text(job.completedAt),
88 logFile: text(job.logFile),
89 errorMessage: text(job.errorMessage),
90 stateDir,
91 }
92}
93
94export function parseState(source: string, stateDir: string): Job[] {
95 try {
96 const state = JSON.parse(source) as { jobs?: unknown }
97 if (!Array.isArray(state.jobs)) return []
98 return state.jobs.map(raw => toJob(raw, stateDir)).filter((job): job is Job => job !== null)
99 } catch {
100 return []
101 }
102}
103
104export function sortJobs(jobs: Job[]): Job[] {
105 return [...jobs].sort((a, b) => {
106 if (isActive(a) !== isActive(b)) return isActive(a) ? -1 : 1
107 return b.updatedAt.localeCompare(a.updatedAt)
108 })
109}
110
111export function tailOf(log: string, count = LOG_TAIL): string[] {
112 return log
113 .split('\n')
114 .map(line => line.match(/^\[[^\]]+\]\s+(.*)$/)?.[1]?.trim() ?? '')
115 .filter(line => line !== '')
116 .slice(-count)
117}
118
119function nativeFindings(review: string): Finding[] {
120 return review
121 .split('\n')
122 .map(line => line.match(/\[P([0-3])\]\s*(.+)$/))
123 .filter((match): match is RegExpMatchArray => match !== null)
124 .map(match => {
125 const rest = (match[2] ?? '').trim()
126 const location = rest.match(/([\w./@-]+\.\w+(?::\d+(?:-\d+)?)?)\s*$/)?.[1]
127 const title = location === undefined ? rest : rest.slice(0, rest.length - location.length).replace(/[\s—–-]+$/, '')
128 return { severity: PRIORITY[match[1] ?? '3'] ?? 'low', title: title || rest, location }
129 })
130}
131
132function structuredFindings(value: unknown): Finding[] | undefined {
133 if (!Array.isArray(value)) return undefined
134 return value.flatMap(raw => {
135 if (!raw || typeof raw !== 'object') return []
136 const finding = raw as Record<string, unknown>
137 const severity = SEVERITIES.find(one => one === finding.severity) ?? 'low'
138 const file = text(finding.file)
139 const start = typeof finding.line_start === 'number' ? finding.line_start : undefined
140 const end = typeof finding.line_end === 'number' && finding.line_end !== start ? `-${finding.line_end}` : ''
141 const location = file === undefined ? undefined : start === undefined ? file : `${file}:${start}${end}`
142 return [{ severity, title: text(finding.title) ?? '(제목 없음)', location }]
143 })
144}
145
146export function parseDetail(source: string | undefined, log: string | undefined, updatedAt: string): JobDetail {
147 const detail: JobDetail = { updatedAt, logTail: log === undefined ? [] : tailOf(log) }
148 const stamps = log?.match(/^\[([^\]]+)\]/gm)
149 const last = stamps?.[stamps.length - 1]
150 if (last !== undefined) detail.lastActivityAt = last.slice(1, -1)
151 if (source === undefined) return detail
152
153 try {
154 const stored = JSON.parse(source) as Record<string, unknown>
155 const payload = (stored.result ?? {}) as Record<string, unknown>
156 const parsed = (payload.result ?? {}) as Record<string, unknown>
157 const codex = (payload.codex ?? {}) as Record<string, unknown>
158
159 detail.isResultLoaded = STATUSES.some(status => status === stored.status && status !== 'queued' && status !== 'running')
160 detail.verdict = text(parsed.verdict)
161 detail.findings = structuredFindings(parsed.findings)
162 if (detail.findings === undefined && payload.review === 'Review') {
163 detail.findings = nativeFindings(text(codex.stdout) ?? '')
164 }
165 const request = (stored.request ?? {}) as Record<string, unknown>
166 const requested = { model: text(request.model), effort: text(request.effort) }
167 if (requested.model !== undefined || requested.effort !== undefined) detail.request = requested
168 const rendered = text(stored.rendered)
169 if (rendered !== undefined) {
170 detail.rendered = rendered.length > RENDERED_LIMIT ? `${rendered.slice(0, RENDERED_LIMIT)}\n\n…(생략)` : rendered
171 }
172 } catch {
173 // 작성 중인 파일은 다음 갱신 때 다시 읽는다
174 }
175 return detail
176}
177
178// codex-companion이 받아들이는 모델 별칭
179const MODEL_ALIAS: Record<string, string> = { spark: 'gpt-5.3-codex-spark' }
180
181// 따옴표로 묶인 프롬프트가 한 토큰이 되도록 셸 규칙대로 나눈다
182function tokensOf(command: string): string[] {
183 const tokens: string[] = []
184 let current = ''
185 let quote: string | null = null
186 let hasToken = false
187 for (let index = 0; index < command.length; index += 1) {
188 const char = command[index] ?? ''
189 if (quote !== null) {
190 if (char === quote) quote = null
191 else if (char === '\\' && quote === '"' && index + 1 < command.length) current += command[++index] ?? ''
192 else current += char
193 } else if (char === '"' || char === "'") {
194 quote = char
195 hasToken = true
196 } else if (char === '\\' && index + 1 < command.length) {
197 current += command[++index] ?? ''
198 hasToken = true
199 } else if (/\s/.test(char)) {
200 if (hasToken || current !== '') tokens.push(current)
201 current = ''
202 hasToken = false
203 } else {
204 current += char
205 }
206 }
207 if (hasToken || current !== '') tokens.push(current)
208 return tokens
209}
210
211// codex-companion의 parseArgs처럼 값을 받는 옵션만 골라 읽는다
212const VALUE_OPTIONS: Record<string, keyof Run | null> = {
213 '--model': 'model',
214 '-m': 'model',
215 '--effort': 'effort',
216 '--base': null,
217 '--scope': null,
218 '--cwd': null,
219 '-C': null,
220 '--prompt-file': null,
221}
222
223function optionsOf(argv: string[]): Run {
224 const found: Run = {}
225 for (let index = 0; index < argv.length; index += 1) {
226 const token = argv[index] ?? ''
227 if (token === '--') break
228 const [name = '', inline] = token.startsWith('--') ? [token.split('=', 1)[0], token.includes('=') ? token.slice(token.indexOf('=') + 1) : undefined] : [token, undefined]
229 if (!(name in VALUE_OPTIONS)) continue
230 const value = inline ?? argv[index + 1]
231 if (inline === undefined) index += 1
232 const key = VALUE_OPTIONS[name]
233 if (key && value !== undefined) found[key] = value
234 }
235 return found
236}
237
238export function parseRunFlags(command: string): Run {
239 const tokens = tokensOf(command)
240 const start = tokens.findIndex(token => /codex-companion\.mjs$/.test(token))
241 // 서브커맨드 뒤 인자가 하나면 codex-companion이 그것을 다시 나눈다 ("$ARGUMENTS")
242 const rest = start < 0 ? [] : tokens.slice(start + 2)
243 const argv = rest.length === 1 ? tokensOf(rest[0] ?? '') : rest
244 const { model, effort } = optionsOf(argv)
245 return {
246 ...(model === undefined ? {} : { model: MODEL_ALIAS[model.toLowerCase()] ?? model }),
247 ...(effort === undefined ? {} : { effort: effort.toLowerCase() }),
248 }
249}
250
251export function parseCodexConfig(toml: string): CodexDefaults {
252 const top = toml.split(/^\s*\[/m)[0] ?? ''
253 const value = (key: string) => top.match(new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']+)["']`, 'm'))?.[1]
254 const defaults: CodexDefaults = {}
255 const model = value('model')
256 const effort = value('model_reasoning_effort')
257 const reviewModel = value('review_model')
258 if (model !== undefined) defaults.model = model
259 if (effort !== undefined) defaults.effort = effort
260 if (reviewModel !== undefined) defaults.reviewModel = reviewModel
261 return defaults
262}
263
264export type ResolvedRun = { model?: string; effort?: string }
265
266// 작업 파일 > 요청 명령 > config.toml 순으로 고른다
267export function resolveRun(job: Job, run: Run | undefined, request: Run | undefined, defaults: CodexDefaults): ResolvedRun | undefined {
268 const known = { ...run, ...request }
269 const fallbackModel = job.kind === 'review' ? (defaults.reviewModel ?? defaults.model) : defaults.model
270 const model = known.model ?? fallbackModel
271 const effort = known.effort ?? defaults.effort
272 if (model === undefined && effort === undefined) return undefined
273 return { model, effort }
274}
275
276export function countBySeverity(findings: Finding[]): [Severity, number][] {
277 return SEVERITIES.map(severity => [severity, findings.filter(one => one.severity === severity).length] as [Severity, number]).filter(
278 ([, count]) => count > 0,
279 )
280}
281
282export function formatElapsed(ms: number): string {
283 const seconds = Math.max(0, Math.floor(ms / 1000))
284 if (seconds < 60) return `${seconds}초`
285 const minutes = Math.floor(seconds / 60)
286 if (minutes < 60) return `${minutes}분 ${String(seconds % 60).padStart(2, '0')}초`
287 return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분`
288}
289
290export function formatClock(iso: string | undefined): string {
291 const time = iso === undefined ? NaN : Date.parse(iso)
292 if (Number.isNaN(time)) return ''
293 const date = new Date(time)
294 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
295}
296
297export function elapsedOf(job: Job, now: number): number | undefined {
298 const start = Date.parse(job.startedAt ?? job.createdAt)
299 if (Number.isNaN(start)) return undefined
300 const end = isActive(job) ? now : Date.parse(job.completedAt ?? job.updatedAt)
301 return Number.isNaN(end) ? undefined : end - start
302}
303
304export function baseName(path: string): string {
305 return path.split('/').filter(Boolean).pop() ?? path
306}
307
308// 진행 중인 작업의 마지막 로그 시각부터 지난 시간. 로그가 없으면 상태 갱신 시각부터 센다
309export function idleOf(job: Job, detail: JobDetail | undefined, now: number): number | undefined {
310 if (!isActive(job)) return undefined
311 const last = Date.parse(detail?.lastActivityAt ?? job.updatedAt)
312 return Number.isNaN(last) ? undefined : now - last
313}
314
315export function companionOf(command: string): string | undefined {
316 return tokensOf(command).find(token => /codex-companion\.mjs$/.test(token))
317}
318
319// 상태 디렉터리에서 codex-companion이 쓰던 CLAUDE_PLUGIN_DATA를 되찾는다. 임시 디렉터리 상태면 빈 값
320export function pluginDataOf(stateDir: string): string {
321 return stateDir.match(/^(.*\/plugins\/data\/[^/]+)\/state\/[^/]+$/)?.[1] ?? ''
322}
323
324export function marketplaceOf(stateDir: string): string | undefined {
325 return stateDir.match(/\/plugins\/data\/codex-([^/]+)\/state\//)?.[1]
326}
327
328export function latestVersion(names: string[]): string | undefined {
329 return [...names].sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0]
330}
331hooks/theme.ts 53 lines1export type Palette = {
2 label: string
3 value: string
4 muted: string
5 border: string
6 surface: string
7 track: string
8 active: string
9 success: string
10 warn: string
11 danger: string
12}
13
14export const PALETTE: Record<'dark' | 'light', Palette> = {
15 dark: {
16 label: '#a1a1aa',
17 value: '#f4f4f5',
18 muted: '#71717a',
19 border: '#3f3f46',
20 surface: '#1c1c20',
21 track: '#3f3f46',
22 active: '#818cf8',
23 success: '#4ade80',
24 warn: '#fbbf24',
25 danger: '#f87171',
26 },
27 light: {
28 label: '#71717a',
29 value: '#18181b',
30 muted: '#a1a1aa',
31 border: '#d4d4d8',
32 surface: '#ffffff',
33 track: '#e4e4e7',
34 active: '#4f46e5',
35 success: '#16a34a',
36 warn: '#b45309',
37 danger: '#dc2626',
38 },
39}
40
41// 단계 수만큼 나눈 진행 막대 (Desktop)
42export function stepsSvg(count: number, current: number, width: number, palette: Palette): string {
43 const gap = 4
44 const segment = (width - gap * (count - 1)) / count
45 const rects = Array.from({ length: count }, (_, index) => {
46 const fill = index < current ? palette.active : index === current ? palette.active : palette.track
47 const opacity = index === current ? ' fill-opacity="0.55"' : ''
48 const x = (segment + gap) * index
49 return `<rect x="${x.toFixed(1)}" y="0" width="${segment.toFixed(1)}" height="4" rx="2" fill="${fill}"${opacity}/>`
50 })
51 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="4" viewBox="0 0 ${width} 4">${rects.join('')}</svg>`
52}
53types/index.d.ts 83 lines1export type JobStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
2
3export type Severity = 'critical' | 'high' | 'medium' | 'low'
4
5export type Job = {
6 id: string
7 kind: string
8 title: string
9 summary: string
10 status: JobStatus
11 phase: string
12 workspaceRoot: string
13 sessionId?: string
14 createdAt: string
15 updatedAt: string
16 startedAt?: string
17 completedAt?: string
18 logFile?: string
19 errorMessage?: string
20 // 작업이 기록된 codex-companion 상태 디렉터리
21 stateDir: string
22}
23
24export type Finding = {
25 severity: Severity
26 title: string
27 location?: string
28}
29
30export type JobDetail = {
31 updatedAt: string
32 logTail: string[]
33 lastActivityAt?: string
34 // 끝난 작업의 작업 파일을 끝까지 읽었는지
35 isResultLoaded?: boolean
36 verdict?: string
37 findings?: Finding[]
38 rendered?: string
39 // 백그라운드 작업이 작업 파일에 남긴 요청 값
40 request?: Run
41}
42
43export type Run = {
44 model?: string
45 effort?: string
46}
47
48// ~/.codex/config.toml 최상위 기본값
49export type CodexDefaults = Run & { reviewModel?: string }
50
51// tool.call로 감지했지만 아직 상태 파일에 작업이 생기지 않은 요청
52export type Pending = {
53 id: string
54 label: string
55 // codex-companion 작업 종류: review, adversarial-review, task
56 kind: string
57 at: number
58 run?: Run
59 // 값이 다른 같은 종류의 요청과 겹쳐 어느 작업의 것인지 알 수 없음
60 isAmbiguous?: boolean
61 // 호출은 끝났지만 작업과 짝짓기 전이라 model·effort를 붙이려고 남겨 둠
62 isDone?: boolean
63}
64
65export type Scope = 'mine' | 'all'
66
67declare module 'claude-code' {
68 interface PluginState {
69 'codex-board': {
70 jobs: Job[]
71 details: Record<string, JobDetail>
72 pending: Pending[]
73 runs: Record<string, Run>
74 defaults: CodexDefaults
75 // 응답이 없어 자동 종료를 시도한 작업 id
76 timedOut: string[]
77 expanded: string | null
78 scope: Scope
79 isDark: boolean
80 }
81 }
82}
83