Role buttons above the prompt: check or adversarially review the working tree with Codex, hand a task over to Codex, or verify with a second Claude model. A…

Role buttons for Claude Code: let the model that writes the code hand parts of the job to other models, Pi-style, from a band above the prompt.
| Button | Hotkey | What runs |
|---|---|---|
| check | c | codex-companion review on the working tree, in the background |
| adversarial | a | codex-companion adversarial-review (challenges the design, not just defects) |
| hand over | h | fills the composer with /relay hand so you type the task; runs codex-companion task --write |
| verify | v | a Claude subagent on the verifyModel (default opus) that reviews and runs tests, read-only |
A pane (relay) lists the last three runs: elapsed time and the Codex log tail while running; the verdict, summary and findings when done. Each finding has fix (submits a prompt to the main agent with the finding and Codex's recommendation) and dismiss; fix all sends every finding at once. A task's or verifier's output gets pull into Claude.
/relay check | adversarial | hand <task> | verify does the same from the prompt.
/plugin install relay --marketplace hal-9/claude-mods
codex set up (/codex:setup). relay drives its scripts/codex-companion.mjs and shares its job state, so /codex:status and /codex:result see relay's runs too.node on PATH.codexPluginRoot: folder holding scripts/codex-companion.mjs; empty means ~/.claude/plugins/marketplaces/openai-codex/plugins/codex.codexModel: passed as --model; empty means Codex's default.verifyModel: Claude alias or id for the verify subagent.Validate and test with the engine build the types came from (the desktop app's binary, not necessarily the claude on PATH):
claude plugin validate <folder>
claude plugin test <folder>
Hotkeys work while the band or pane holds the focus (ctrl+x tab, or a click).
hooks/register.tsx 296 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import type { Finding, Job, Role } from '../types'
4
5const JOBS = { plugin: 'relay', key: 'jobs' } as const
6
7const PANE = 'relay'
8const POLL_MS = 2500
9const LOG_TAIL = 1200
10const PROMPT_CAP = 8000
11
12type $ = EngineInterface
13
14const LABEL: Record<Role, string> = {
15 check: 'codex review',
16 adversarial: 'codex adversarial',
17 hand: 'codex task',
18 verify: 'claude verify',
19}
20
21const VERIFY_PROMPT = `You are the verifier of another agent's work. Review the uncommitted changes in this working tree (git status, git diff, untracked files) against what they appear to be meant to do. Run the tests that cover the touched code when they exist.
22Do not edit any file.
23Reply with: VERDICT: pass or fail, one line why; then one line per problem as "severity | file:line | title | why | suggested fix".`
24
25const minutes = (ms: number) => `${Math.floor(ms / 60_000)}:${String(Math.floor((ms % 60_000) / 1000)).padStart(2, '0')}`
26
27const fixPrompt = (job: Job, list: Finding[]) =>
28 `[relay] ${LABEL[job.role]} found ${list.length} issue${list.length === 1 ? '' : 's'} in the working tree. Fix ${list.length === 1 ? 'it' : 'them'}, then say what you changed.\n\n` +
29 list
30 .map(f => `- (${f.severity}) ${f.file}${f.line_start ? `:${f.line_start}` : ''}: ${f.title}\n ${f.body}${f.recommendation ? `\n Suggested: ${f.recommendation}` : ''}`)
31 .join('\n')
32
33const pullPrompt = (job: Job) =>
34 `[relay] Result of ${LABEL[job.role]}. Read it, then continue the work in this working tree accordingly.\n\n${(job.output ?? '').slice(0, PROMPT_CAP)}`
35
36let companion: string[] = []
37let env: Record<string, string> = {}
38let opts: PluginOptions = {}
39
40// Helpers take $ and may only spell $.noun.method on it, so they use $.state directly; the hooks use read/update.
41async function getJobs($: $): Promise<Job[]> {
42 return (await $.state.get(JOBS)).value ?? []
43}
44
45async function setJob($: $, id: string, patch: Partial<Job>) {
46 const list = await getJobs($)
47 await $.state.set(JOBS, list.map(j => (j.id === id ? { ...j, ...patch } : j)))
48}
49
50async function removeJob($: $, id: string) {
51 await $.state.set(JOBS, (await getJobs($)).filter(j => j.id !== id))
52}
53
54async function addJob($: $, job: Job) {
55 const list = await getJobs($)
56 await $.state.set(JOBS, [job, ...list].slice(0, 20))
57 void $.ui.open({ id: PANE, title: 'relay' })
58}
59
60async function run($: $, args: string[], timeoutMs = 60_000) {
61 const { exitCode, stdout, stderr } = await $.process.run([...companion, ...args, '--json'], { env, timeoutMs })
62 if (exitCode !== 0) throw new Error(stderr.trim() || `codex-companion exited ${exitCode}`)
63 return JSON.parse(stdout) as Record<string, any>
64}
65
66const toastError = ($: $, error: unknown) => $.ui.toast(`relay: ${error instanceof Error ? error.message : String(error)}`)
67
68// A review runs inside the companion's own process until it is done (`--background` only queues tasks), so the
69// call is left running for up to ten minutes and the job is adopted from the companion's listing once it is on disk.
70async function adopt($: $, role: 'check' | 'adversarial', known: Set<string>) {
71 try {
72 const { running } = (await run($, ['status'])) as { running?: { id: string; logFile?: string }[] }
73 const fresh = (running ?? []).find(j => !known.has(j.id))
74 if (!fresh) return $.ui.toast(`relay: ${LABEL[role]} did not start`)
75 await addJob($, { id: fresh.id, role, status: 'running', startedAt: await $.clock.now(), log: '', logFile: fresh.logFile })
76 } catch (error) {
77 toastError($, error)
78 }
79}
80
81async function startCodex($: $, role: 'check' | 'adversarial' | 'hand', extra: string[]) {
82 const model = String(opts.codexModel ?? '')
83 const modelArgs = model ? ['--model', model] : []
84 if (role === 'hand') {
85 try {
86 const payload = await run($, ['task', '--background', ...modelArgs, ...extra])
87 await addJob($, { id: String(payload.jobId), role, status: 'running', startedAt: await $.clock.now(), log: '' })
88 } catch (error) {
89 toastError($, error)
90 }
91 return
92 }
93 const known = new Set((await getJobs($)).map(j => j.id))
94 void run($, [role === 'check' ? 'review' : 'adversarial-review', ...modelArgs, ...extra], 600_000).catch(error => toastError($, error))
95 $.clock.after(1500, () => void adopt($, role, known))
96}
97
98async function startVerify($: $) {
99 const { agentId, deny } = await $.agent.spawn({
100 prompt: VERIFY_PROMPT,
101 description: 'relay verify',
102 subagentType: 'general-purpose',
103 model: String(opts.verifyModel || 'opus'),
104 })
105 if (deny !== undefined || agentId === undefined) {
106 $.ui.toast(`relay: verify refused: ${deny ?? 'no agent'}`)
107 return
108 }
109 await addJob($, { id: agentId, role: 'verify', status: 'running', startedAt: await $.clock.now(), log: '' })
110}
111
112async function finish($: $, job: Job) {
113 const { storedJob } = await run($, ['result', job.id])
114 const parsed = storedJob?.result?.result
115 const output: string = storedJob?.result?.rawOutput || storedJob?.result?.codex?.stdout || storedJob?.rendered || ''
116 await setJob($, job.id, {
117 status: 'done',
118 verdict: parsed?.verdict,
119 summary: parsed?.summary ?? storedJob?.result?.parseError,
120 findings: parsed?.findings,
121 output,
122 })
123 const n = parsed?.findings?.length
124 $.ui.toast(`relay: ${LABEL[job.role]} done${n === undefined ? '' : `, ${n} finding${n === 1 ? '' : 's'}`}`)
125}
126
127// The engine's own PATH may lack a version-managed node (nvm, asdf): ask the login shell once.
128async function nodePath($: $): Promise<string> {
129 try {
130 await $.process.run(['node', '--version'])
131 return 'node'
132 } catch {}
133 const shell = (await $.env.get('SHELL')) || '/bin/zsh'
134 try {
135 const { exitCode, stdout } = await $.process.run([shell, '-lic', 'command -v node'])
136 if (exitCode === 0 && stdout.trim()) return stdout.trim().split('\n').pop() ?? 'node'
137 } catch {}
138 return 'node'
139}
140
141// ponytail: one poll loop over the companion's status; a log-file watch if polling ever feels slow.
142let isPolling = false
143async function poll($: $) {
144 if (isPolling) return
145 isPolling = true
146 try {
147 const running = (await getJobs($)).filter(j => j.status === 'running' && j.role !== 'verify')
148 for (const job of running) {
149 try {
150 const { job: snap } = await run($, ['status', job.id])
151 const logFile: string | undefined = snap?.logFile ?? job.logFile
152 let log = job.log
153 if (logFile) {
154 try {
155 log = (await $.fs.read(logFile)).slice(-LOG_TAIL)
156 } catch {}
157 }
158 if (snap?.status === 'completed') await finish($, { ...job, logFile })
159 else if (snap?.status === 'failed' || snap?.status === 'cancelled') {
160 await setJob($, job.id, { status: 'failed', log, summary: snap.errorMessage ?? snap.status })
161 $.ui.toast(`relay: ${LABEL[job.role]} ${snap.status}`)
162 } else await setJob($, job.id, { log, logFile })
163 } catch (error) {
164 await setJob($, job.id, { status: 'failed', summary: error instanceof Error ? error.message : String(error) })
165 }
166 }
167 } finally {
168 isPolling = false
169 }
170}
171
172export const register: Register = (on, options) => {
173 opts = options
174
175 on('session.start', async ($, e, next) => {
176 const home = (await $.env.get('HOME')) ?? ''
177 const root = String(options.codexPluginRoot || '') || `${home}/.claude/plugins/marketplaces/openai-codex/plugins/codex`
178 companion = [await nodePath($), `${root}/scripts/codex-companion.mjs`]
179 env = { CLAUDE_PLUGIN_DATA: `${home}/.claude/plugins/data/codex-openai-codex` }
180 await $.command.register({ name: 'relay', description: 'relay check | adversarial | hand <task> | verify | pane' })
181 $.clock.every(POLL_MS, () => void poll($))
182 return next(e)
183 })
184
185 on('command.run', { command: 'relay' }, async ($, e) => {
186 const [verb, ...rest] = (e.args ?? '').trim().split(/\s+/)
187 const task = rest.join(' ')
188 if (verb === 'check' || verb === 'adversarial') void startCodex($, verb, [])
189 else if (verb === 'hand' && task) void startCodex($, 'hand', ['--write', task])
190 else if (verb === 'verify') void startVerify($)
191 else {
192 await $.ui.open({ id: PANE, title: 'relay' })
193 return { text: 'relay: check | adversarial | hand <task> | verify' }
194 }
195 return { text: `relay: ${verb} started.` }
196 })
197
198 on('turn.complete', async ($, e, next) => {
199 if (e.agentId !== undefined) {
200 const job = (await getJobs($)).find(j => j.id === e.agentId && j.status === 'running')
201 if (job) {
202 await setJob($, job.id, { status: 'done', output: e.answer, verdict: /VERDICT:\s*pass/i.test(e.answer) ? 'pass' : 'fail' })
203 $.ui.toast(`relay: claude verify done`)
204 }
205 }
206 return next(e)
207 })
208
209 // The desktop drops a press whose handler runs past ~10 s, and a Codex start can take that long: presses only schedule the work.
210 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
211 if (e.props.hasSurvey) return next(e)
212 const { Box, Button, Text } = $.ui.resolve(e)
213 const now = await $.clock.now()
214 const running = (await getJobs($)).filter(j => j.status === 'running')
215 // Another mod's band (cache-meter) rides along above the buttons; the engine's own band is empty.
216 const below = await next(e)
217
218 return (
219 <Box flexDirection="column" gap="xs">
220 {below.type !== 'engine' && below}
221 <Box>
222 <Text dimColor>relay </Text>
223 {running.map(j => (
224 <Text key={j.id}>
225 {LABEL[j.role]} <Text dimColor>{minutes(now - j.startedAt)}</Text>{' '}
226 </Text>
227 ))}
228 <Button key="check" hotkey="c" label="check" dimColor onPress={() => $.clock.after(0, () => void startCodex($, 'check', []))} />
229 <Text> </Text>
230 <Button key="adversarial" hotkey="a" label="adversarial" dimColor onPress={() => $.clock.after(0, () => void startCodex($, 'adversarial', []))} />
231 <Text> </Text>
232 <Button key="hand" hotkey="h" label="hand over" dimColor onPress={() => $.prompt.fill({ text: '/relay hand ', mode: 'insert' })} />
233 <Text> </Text>
234 <Button key="verify" hotkey="v" label="verify" dimColor onPress={() => $.clock.after(0, () => void startVerify($))} />
235 </Box>
236 </Box>
237 )
238 })
239
240 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
241 const { Box, Button, Text } = $.ui.resolve(e)
242 const list = await getJobs($)
243 const now = await $.clock.now()
244 const rows = Math.max(6, (e.viewport?.rows ?? 24) - 6)
245
246 const dismiss = (job: Job, f: Finding) =>
247 setJob($, job.id, { findings: (job.findings ?? []).filter(x => x !== f) })
248
249 return (
250 <Box flexDirection="column">
251 {list.length === 0 && <Text dimColor>No runs yet. Press c, a, h or v in the band, or /relay.</Text>}
252 {list.slice(0, 3).map(job => (
253 <Box key={job.id} flexDirection="column" marginBottom={1}>
254 <Box>
255 <Text bold>{LABEL[job.role]}</Text>
256 <Text dimColor>
257 {' '}{job.status === 'running' ? minutes(now - job.startedAt) : job.status}
258 {job.verdict ? ` · ${job.verdict}` : ''}
259 </Text>
260 <Text> </Text>
261 {job.status === 'running' && job.role !== 'verify' && (
262 <Button key={`cancel-${job.id}`} label="cancel" dimColor onPress={() => $.clock.after(0, () => void run($, ['cancel', job.id]).catch(() => undefined))} />
263 )}
264 {job.status !== 'running' && (
265 <Button key={`clear-${job.id}`} label="clear" dimColor onPress={() => removeJob($, job.id)} />
266 )}
267 </Box>
268 {job.status === 'running' && job.log && (
269 <Text dimColor>{job.log.split('\n').slice(-rows).join('\n')}</Text>
270 )}
271 {job.summary && <Text>{job.summary}</Text>}
272 {(job.findings ?? []).map((f, i) => (
273 <Box key={`${job.id}-${i}`}>
274 <Text color={/critical|high/.test(f.severity) ? 'red' : /medium/.test(f.severity) ? 'yellow' : undefined}>{f.severity}</Text>
275 <Text> {f.file}{f.line_start ? `:${f.line_start}` : ''} {f.title} </Text>
276 <Button key={`fix-${job.id}-${i}`} label="fix" onPress={() => $.prompt.submit({ text: fixPrompt(job, [f]) })} />
277 <Text> </Text>
278 <Button key={`drop-${job.id}-${i}`} label="dismiss" dimColor onPress={() => dismiss(job, f)} />
279 </Box>
280 ))}
281 {job.status === 'done' && (job.findings?.length ?? 0) > 1 && (
282 <Button key={`fixall-${job.id}`} variant="primary" label="fix all" onPress={() => $.prompt.submit({ text: fixPrompt(job, job.findings ?? []) })} />
283 )}
284 {job.status === 'done' && !job.findings && job.output && (
285 <Box flexDirection="column">
286 <Text dimColor>{job.output.slice(-LOG_TAIL)}</Text>
287 <Button key={`pull-${job.id}`} variant="primary" label="pull into Claude" onPress={() => $.prompt.submit({ text: pullPrompt(job) })} />
288 </Box>
289 )}
290 </Box>
291 ))}
292 </Box>
293 )
294 })
295}
296types/index.d.ts 37 lines1/** The role a run plays, in Pi's words. */
2export type Role = 'check' | 'adversarial' | 'hand' | 'verify'
3
4export type Finding = {
5 severity: string
6 title: string
7 body: string
8 file: string
9 line_start: number | null
10 recommendation: string
11}
12
13/** One run of a role: a Codex companion job or a Claude verifier subagent. */
14export type Job = {
15 /** Companion job id, or the subagent's agentId. */
16 id: string
17 role: Role
18 status: 'running' | 'done' | 'failed'
19 startedAt: number
20 /** Last lines of the Codex log while running. */
21 log: string
22 /** Review results, once done. */
23 verdict?: string
24 summary?: string
25 findings?: Finding[]
26 /** A task's or verifier's final text, once done. */
27 output?: string
28 /** Path of the companion's log file, polled while running. */
29 logFile?: string
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 relay: { jobs: Job[] }
35 }
36}
37