SLOPSHOPPER

codex-agent

Delegate tasks to OpenAI models as Codex subagents, on your ChatGPT/Codex subscription

newpaneguardcommandtoasttool
★ 2v0.2.0no licenseupdated 2026-10-20joshrwolf/dots/src/claude/codex-agent
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-agent
│ ┃ Codex ✕ › fix the failing auth test and add an audit log call │ ┃ No Codex runs yet. │ ⏺ 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 │ ⎿ codex-agent: Codex pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Codex
No Codex runs yet.
README

Home sweet $HOME

Personal macOS dotfiles managed with GNU Stow, with mise tasks for development and explicit installation.

Setup

Install Homebrew and Apple Command Line Tools, then run from this checkout:

brew bundle --file=home/brew/.Brewfile
mise trust
mise install --locked
mise run install

Base installation links shell, terminal, theme, and agent configuration. It does not install application binaries, activate Herdr plugins, or enable optional services. See build and activation for checks and installation boundaries.

Codex settings stay machine-local in ~/.codex/config.toml. Private Brew additions can live in ~/.Brewfile.local; apply them with brew bundle --file="$HOME/.Brewfile.local". Keep credentials and runtime state outside this public repository.

Use

Shared themes live in home/theme/.config/theme/themes/; the current symlink selects a palette. Applications consume their own adapters from that directory.

Source 2 files
hooks/register.tsx 280 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CodexJob } from '../types'
5
6const PANE = 'codex'
7const TOOL = 'mcp__codex-agent__codex'
8const SANDBOXES = ['read-only', 'workspace-write'] as const
9const EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const
10const MAX_JOBS = 30
11const MAX_LINES = 200
12
13const jobs = atom({ plugin: 'codex-agent', key: 'jobs' } as const, [])
14
15type Input = {
16  prompt: string
17  model?: string
18  sandbox?: (typeof SANDBOXES)[number]
19  effort?: (typeof EFFORTS)[number]
20  cwd?: string
21  session_id?: string
22  background?: boolean
23}
24
25type Item = {
26  type: string
27  text?: string
28  command?: string
29  exit_code?: number | null
30  changes?: { path: string }[]
31  server?: string
32  tool?: string
33  query?: string
34}
35
36type Event =
37  | { type: 'thread.started'; thread_id: string }
38  | { type: 'item.started' | 'item.completed'; item: Item }
39  | { type: 'turn.completed'; usage?: Record<string, number> }
40  | { type: 'turn.failed' | 'error'; error?: { message?: string }; message?: string }
41
42type Outcome = {
43  answer?: string
44  failure?: string
45  threadId?: string
46  usage?: Record<string, number>
47  stderr: string
48  isStopped: boolean
49}
50
51const DESCRIPTION = `Run a task on an OpenAI model as a Codex subagent (the Codex CLI, billed to the user's ChatGPT subscription). It works autonomously in its own context, can read files and run commands in a sandbox, and returns its final message plus a session_id. Its progress shows live in the Codex pane (/codex).
52
53Use it for a second opinion from a different model family, independent review, or parallelizable research. Give it a self-contained prompt: it has none of this conversation's context.
54
55- sandbox: "read-only" (default) or "workspace-write" (may edit files under cwd).
56- model / effort: omit to use the user's Codex config defaults.
57- session_id: pass a previous call's session_id to continue that Codex thread with a follow-up prompt.
58- background: true returns at once with a job id; the result arrives later as a message from this plugin. Use it for long tasks you do not need to wait on.`
59
60// Stops for this module's running children; a reload kills them all.
61const stops = new Map<string, () => void>()
62
63export const register: Register = on => {
64  on('session.start', async ($, e, next) => {
65    const started = await next(e)
66    const now = await $.clock.now()
67    await update($, jobs, list =>
68      list.map(job =>
69        job.status === 'running'
70          ? { ...job, status: 'stopped', endedAt: now, lines: [...job.lines, 'stopped: the mod reloaded'] }
71          : job,
72      ),
73    )
74    await $.command.register({ name: 'codex', description: 'Show Codex subagent runs in a pane' })
75    await $.tool.register({
76      name: 'codex',
77      description: DESCRIPTION,
78      isDeferred: false,
79      inputSchema: {
80        type: 'object',
81        properties: {
82          prompt: { type: 'string', description: 'Self-contained task for the Codex agent.' },
83          model: { type: 'string', description: 'OpenAI model id; omit for the Codex default.' },
84          sandbox: { type: 'string', enum: SANDBOXES },
85          effort: { type: 'string', enum: EFFORTS, description: 'Reasoning effort.' },
86          cwd: { type: 'string', description: 'Working root; defaults to the session cwd.' },
87          session_id: { type: 'string', description: 'Continue an earlier Codex thread.' },
88          background: { type: 'boolean', description: 'Return at once; deliver the result later.' },
89        },
90        required: ['prompt'],
91        additionalProperties: false,
92      },
93    })
94    return started
95  })
96
97  on('command.run', { command: 'codex' }, async $ => {
98    await $.ui.open({ id: PANE, title: 'Codex' })
99    return { text: 'Codex pane opened.' }
100  })
101
102  on('tool.call', { tool: TOOL }, async ($, e) => {
103    const input = e as unknown as Input
104    const job = await startJob($, input)
105    void $.ui.open({ id: PANE, title: 'Codex' }).catch(() => undefined)
106
107    if (input.background) {
108      void runJob($, job, input)
109        .then(outcome => $.prompt.submit({ text: `Codex job ${job.id} finished.\n\n${report(outcome)}` }))
110        .catch(() => $.ui.toast(`Codex job ${job.id} finished, but its result could not be delivered; see /codex`))
111      return { result: `Started Codex job ${job.id} in the background. Its result arrives as a message when it finishes.` }
112    }
113
114    const outcome = await runJob($, job, input)
115    return outcome.answer === undefined ? { result: report(outcome), isError: true } : { result: report(outcome) }
116  }).catch(($, e, next) => ({ deny: `${$.plugin.name}: the codex tool failed.` }))
117
118  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
119    const { Box, Button, Text } = $.ui.resolve(e)
120    const list = await read($, jobs)
121    const room = Math.max(4, (e.viewport?.rows ?? 24) - 2)
122    const running = list.filter(job => job.status === 'running')
123    const finished = list.filter(job => job.status !== 'running').slice(-5).reverse()
124    const tail = Math.max(2, Math.floor((room - finished.length) / Math.max(1, running.length)) - 1)
125
126    return (
127      <Box flexDirection="column">
128        {list.length === 0 && <Text dimColor>No Codex runs yet.</Text>}
129        {running.map(job => (
130          <Box key={job.id} flexDirection="column">
131            <Box>
132              <Text color="yellow">{header(job)} </Text>
133              <Button key={`stop-${job.id}`} label="Stop" onPress={() => stops.get(job.id)?.()} />
134            </Box>
135            {job.lines.slice(-tail).map(line => (
136              <Text dimColor>  {line}</Text>
137            ))}
138          </Box>
139        ))}
140        {finished.map(job => (
141          <Text key={job.id} color={job.status === 'done' ? 'green' : 'red'} dimColor={job.status === 'stopped'}>
142            {header(job)}
143          </Text>
144        ))}
145      </Box>
146    )
147  })
148}
149
150async function startJob($: EngineInterface, input: Input): Promise<CodexJob> {
151  const list = await read($, jobs)
152  const job: CodexJob = {
153    id: `c${(Number(list.at(-1)?.id.slice(1)) || 0) + 1}`,
154    title: firstLine(input.prompt, 60),
155    model: input.model,
156    sandbox: input.sandbox ?? 'read-only',
157    isBackground: input.background === true,
158    status: 'running',
159    startedAt: await $.clock.now(),
160    sessionId: input.session_id,
161    lines: [],
162  }
163  await update($, jobs, all => [...all, job].slice(-MAX_JOBS))
164  return job
165}
166
167async function runJob($: EngineInterface, job: CodexJob, input: Input): Promise<Outcome> {
168  const flags = ['--json', '--skip-git-repo-check', '-c', `sandbox_mode="${job.sandbox}"`]
169  if (input.model) flags.push('-m', input.model)
170  if (input.effort) flags.push('-c', `model_reasoning_effort="${input.effort}"`)
171  const argv = input.session_id
172    ? ['codex', 'exec', 'resume', ...flags, input.session_id, '-']
173    : ['codex', 'exec', ...flags, ...(input.cwd ? ['-C', input.cwd] : []), '-']
174
175  const outcome: Outcome = { threadId: input.session_id, stderr: '', isStopped: false }
176  let buffered = ''
177  try {
178    const child = $.process.spawn({ argv, cwd: input.cwd, input: input.prompt })
179    stops.set(job.id, () => {
180      outcome.isStopped = true
181      void child.return(undefined as never)
182    })
183    for await (const { stream, text } of child) {
184      if (outcome.isStopped) break
185      if (stream === 'stderr') {
186        outcome.stderr += text
187        continue
188      }
189      const lines = (buffered + text).split('\n')
190      buffered = lines.pop() ?? ''
191      const shown: string[] = []
192      for (const line of lines) {
193        const ev = parse(line)
194        if (!ev) continue
195        if (ev.type === 'thread.started') outcome.threadId = ev.thread_id
196        else if (ev.type === 'turn.completed') outcome.usage = ev.usage
197        else if (ev.type === 'turn.failed' || ev.type === 'error')
198          outcome.failure = ev.error?.message ?? ev.message ?? 'Codex reported an error'
199        else if (ev.type === 'item.completed' && ev.item.type === 'agent_message') outcome.answer = ev.item.text
200        const said = describe(ev)
201        if (said) shown.push(said)
202      }
203      if (shown.length > 0) await patch($, job.id, one => ({ lines: [...one.lines, ...shown].slice(-MAX_LINES) }))
204    }
205  } catch (err) {
206    outcome.failure = `could not start codex: ${String(err)}`
207  } finally {
208    stops.delete(job.id)
209  }
210
211  if (outcome.isStopped) outcome.failure = 'stopped from the Codex pane'
212  const status = outcome.isStopped ? 'stopped' : outcome.answer === undefined ? 'failed' : 'done'
213  const endedAt = await $.clock.now()
214  await patch($, job.id, () => ({ status, endedAt, sessionId: outcome.threadId }))
215  return outcome
216}
217
218function patch($: EngineInterface, id: string, fn: (job: CodexJob) => Partial<CodexJob>) {
219  return update($, jobs, list => list.map(job => (job.id === id ? { ...job, ...fn(job) } : job)))
220}
221
222function report(outcome: Outcome): string {
223  if (outcome.answer === undefined || outcome.isStopped) {
224    const tail = outcome.stderr.trim().split('\n').slice(-20).join('\n')
225    return `Codex failed: ${outcome.failure ?? 'no final message'}${tail ? `\n\nstderr:\n${tail}` : ''}`
226  }
227  const meta = [`session_id: ${outcome.threadId ?? 'unknown'}`]
228  if (outcome.usage) meta.push(`tokens: ${outcome.usage.input_tokens ?? 0} in / ${outcome.usage.output_tokens ?? 0} out`)
229  return `${outcome.answer}\n\n[codex ${meta.join(', ')}]`
230}
231
232function describe(ev: Event): string | undefined {
233  if (ev.type === 'turn.failed' || ev.type === 'error') return `error: ${ev.error?.message ?? ev.message ?? ''}`
234  if (ev.type !== 'item.started' && ev.type !== 'item.completed') return undefined
235  const { item } = ev
236  const isDone = ev.type === 'item.completed'
237  switch (item.type) {
238    case 'command_execution':
239      return isDone ? `  exit ${item.exit_code ?? '?'}` : `$ ${unwrap(item.command ?? '')}`
240    case 'agent_message':
241      return isDone ? firstLine(item.text ?? '', 200) : undefined
242    case 'reasoning':
243      return isDone ? `… ${firstLine(item.text ?? '', 120)}` : undefined
244    case 'file_change':
245      return isDone ? `edit ${(item.changes ?? []).map(change => change.path).join(', ')}` : undefined
246    case 'mcp_tool_call':
247      return isDone ? undefined : `tool ${item.server}.${item.tool}`
248    case 'web_search':
249      return isDone ? undefined : `search ${item.query ?? ''}`
250    default:
251      return undefined
252  }
253}
254
255function header(job: CodexJob): string {
256  const took = job.endedAt === undefined ? '' : ` ${Math.round((job.endedAt - job.startedAt) / 1000)}s`
257  const tags = [job.model, job.sandbox, job.isBackground ? 'bg' : undefined].filter(Boolean).join(' · ')
258  return `${job.id} ${job.status}${took} [${tags}] ${job.title}`
259}
260
261// Codex runs commands through a login shell; show the command itself.
262function unwrap(command: string): string {
263  const match = /^\S+ -lc '(.*)'$/s.exec(command)
264  return firstLine(match?.[1] ?? command, 160)
265}
266
267function firstLine(text: string, max: number): string {
268  const line = text.trim().split('\n')[0] ?? ''
269  return line.length > max ? `${line.slice(0, max - 1)}…` : line
270}
271
272function parse(line: string): Event | undefined {
273  if (!line.trim()) return undefined
274  try {
275    return JSON.parse(line) as Event
276  } catch {
277    return undefined
278  }
279}
280
types/index.d.ts 21 lines
1export type CodexJobStatus = 'running' | 'done' | 'failed' | 'stopped'
2
3export type CodexJob = {
4  id: string
5  title: string
6  model?: string
7  sandbox: string
8  isBackground: boolean
9  status: CodexJobStatus
10  startedAt: number
11  endedAt?: number
12  sessionId?: string
13  lines: string[]
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'codex-agent': { jobs: CodexJob[] }
19  }
20}
21