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

Personal macOS dotfiles managed with GNU Stow, with mise tasks for development and explicit installation.
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.
Shared themes live in home/theme/.config/theme/themes/; the current symlink selects a palette. Applications consume their own adapters from that directory.
hooks/register.tsx 280 lines1import { 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}
280types/index.d.ts 21 lines1export 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