SLOPSHOPPER

relay

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…

newpanebandcommandtoastprocess
v0.1.2MITupdated 2026-10-16hal-9/claude-mods/mods/relay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · relay
│ ┃ relay ✕ › fix the failing auth test and add an audit log call │ ┃ No runs yet. Press c, a, h or v in the band, │ ┃ or /relay. ⏺ 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 │ │ › /relay │ ⎿ relay: relay: check | adversarial | hand <task> | verify │ │ relay [ check ] [ adversarial ] [ hand over ] [ verify ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
relay [ check ] [ adversarial ] [ hand over ] [ verify ]
Pane · relay
No runs yet. Press c, a, h or v in the band, or /relay.
README

relay

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.

ButtonHotkeyWhat runs
checkccodex-companion review on the working tree, in the background
adversarialacodex-companion adversarial-review (challenges the design, not just defects)
hand overhfills the composer with /relay hand so you type the task; runs codex-companion task --write
verifyva 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.

Install

/plugin install relay --marketplace hal-9/claude-mods

Requirements

  • The openai-codex plugin installed and 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.

Settings

  • 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.

Develop

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).

Source 2 files
hooks/register.tsx 296 lines
1import 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}
296
types/index.d.ts 37 lines
1/** 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