Runs OpenAI Codex models (SOL, Astra, Luna) as native Claude Code subagents: same task rows, timers, token counts, tool rows, background notices and…

Small Claude Code mods: a status band for your usage limits, a switchboard for your mods, a button that hides them all, a guard that keeps your name out of public pushes, and OpenAI Codex as native subagents.
Each mod installs on its own. Pick the ones you want.

A real screenshot from the Claude desktop app. The dollar and token figures are pixelated.
/plugin marketplace add finalpantasy/claude-mod-pack
/plugin install status-band@claude-mod-pack
/plugin install claude-mod-hider@claude-mod-pack
/plugin install push-guard@claude-mod-pack
/plugin install codex-bridge@claude-mod-pack
Restart Claude Code after installing. Mods need Claude Code with mod support (function hooks).
status-band)One tidy band above the prompt, each row labelled:

/mods opens a panel listing every installed mod with its full description and an on/off switch, plus a switch for each band row. /band hides or shows the band.

These two pictures are drawn from status-band's own output with sample data: its screenshot test records exactly what the mod draws, and tools/shots.py lays that out in the desktop app's dark style and photographs it. The mods named focus-timer and pixel-pet are made-up examples.
claude-mod-hider)One small arrow in the corner of the band. Click it and every mod above the prompt folds down to a single ▸ mods button; click again and they are back. It remembers your choice across sessions. More, including how other mods can opt in.
push-guard)Stops a git push Claude runs when the commits about to leave would publish something about you, and tells Claude exactly where it is so it can fix the files and the history first. It checks only what is new in the push: added lines, file names, commit messages and author names.
What it looks for is yours, never anyone else's:
/plugin configure push-guard@claude-mod-pack). Empty until you fill it in.C:\Users\you, /c/Users/you, escaped JSON, /home/you).What Claude reads when a push is stopped (from the mod's tests, with a made-up name):
Push Guard stopped this push: one place in the commits would publish something private.
- README.md:13: "Alex Rivera"
Remove it from the files and from the commit history (amend or rebase the commits, and fix author names with the repo's own git identity), then push again.
If it is meant to be public, the person can run /push-guard off to push without the check for this session.
Private GitHub repos are skipped when the GitHub CLI can see they are private; any other host is checked. /push-guard says what it checks, and /push-guard off lets a push through for the rest of the session.
codex-bridge)Runs OpenAI Codex models as real Claude Code subagents. Each model becomes an agent type (codex-bridge:sol, codex-bridge:sol-ro for read-only, and so on) that the Agent tool can start like any other. You get the same task row, timer, token count, tool rows, background notice and SendMessage follow-ups as a native agent, which is handy for a second opinion from a different model family.
models, as name=model pairs).These are free hobby projects. If one saves you time, you can buy me a coffee on Ko-fi.
MIT
hooks/register.ts 395 lines1import type { EngineInterface, Register, TurnStepChunk, TurnStepInput, TurnStepResult, TurnUsage } from 'claude-code'
2
3// codex-bridge: OpenAI Codex as a native Claude Code subagent.
4//
5// Each Codex model is registered as an agent type, so the Agent tool spawns a
6// real subagent loop: task row, timer, background notice, SendMessage and the
7// agent's transcript all come from Claude Code itself. Every model request
8// that loop makes (turn.step) is answered here from `codex exec --json`
9// instead of from the API. Each Codex command, patch, MCP call or search is
10// replayed as a tool call of the subagent (answered from what Codex already
11// did), so the agent's tool count and tool rows match a native agent's. Codex
12// reasoning streams as live thinking; the final Codex message is the agent's
13// answer; Codex's token usage is reported on the closing step.
14
15const TOOLS = {
16 shell: 'mcp__codex-bridge__shell',
17 patch: 'mcp__codex-bridge__patch',
18 tool: 'mcp__codex-bridge__tool',
19 search: 'mcp__codex-bridge__search',
20} as const
21const TOOL_NAMES: string[] = Object.values(TOOLS)
22const OUTPUT_CAP = 20000
23
24type AgentType = { name: string; model: string; label: string; sandbox: 'workspace-write' | 'read-only' }
25type CodexEvent = { type: string; [k: string]: unknown }
26type CodexItem = { id?: string; type: string; [k: string]: unknown }
27type Pull = IteratorResult<{ stream: 'stdout' | 'stderr'; text: string }, { code: number | null; signal: string | null } | void>
28type Run = {
29 iter: AsyncIterator<{ stream: 'stdout' | 'stderr'; text: string }, unknown>
30 buf: string
31 lines: string[]
32 stderr: string
33 exited: boolean
34 exitCode: number | null
35 done: boolean
36 type: AgentType
37}
38
39const types = new Map<string, AgentType>() // `codex-bridge:<name>` -> type
40const agentTypeOf = new Map<string, AgentType | null>() // agentId -> its codex type, null when not codex
41const runs = new Map<string, Run>() // agentId -> the Codex process serving it
42const threads = new Map<string, string>() // agentId -> Codex thread id, for follow-ups
43const stash = new Map<string, string>() // tool_use_id -> what Codex already got from that call
44const activity = new Map<string, string>() // agentId -> what its Codex is doing, for the status line
45let resolvedCommand: string[] | undefined
46let seq = 0
47
48function showStatus($: EngineInterface) {
49 const lines = [...activity.values()]
50 $.ui.status(lines.length === 0 ? undefined : lines.length === 1 ? lines[0] : `codex: ${lines.length} agents running · ${lines[lines.length - 1]}`)
51}
52
53function parseModels(spec: string): AgentType[] {
54 const out: AgentType[] = []
55 for (const pair of spec.split(',')) {
56 const [rawName, rawModel] = pair.split('=').map(s => s.trim())
57 if (!rawName || !rawModel || !/^[A-Za-z0-9_-]{1,60}$/.test(rawName)) continue
58 out.push({ name: rawName, model: rawModel, label: rawName, sandbox: 'workspace-write' })
59 out.push({ name: `${rawName}-ro`, model: rawModel, label: `${rawName} (read-only)`, sandbox: 'read-only' })
60 }
61 return out
62}
63
64const AGENT_PROMPT =
65 'This agent is answered by OpenAI Codex through the codex-bridge mod. If you are reading this, ' +
66 "the bridge did not run. Reply exactly: 'codex-bridge: the Codex hook did not run, so no work was done.' and stop."
67
68function describe(t: AgentType): string {
69 const edits = t.sandbox === 'read-only'
70 ? 'Read-only sandbox: reads files and runs commands, cannot edit.'
71 : 'Workspace-write sandbox: can read, run commands and edit files in the working directory.'
72 return `OpenAI Codex running ${t.model}, a non-Claude model with its own tools, as a subagent. ${edits} ` +
73 'Use for a second opinion from a different model family, independent review, or parallel work. ' +
74 'Give it a self-contained prompt: it does not see this conversation.'
75}
76
77const stripReminders = (s: string) => s.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
78const cap = (s: string) => (s.length > OUTPUT_CAP ? `${s.slice(0, OUTPUT_CAP)}\n[... ${s.length - OUTPUT_CAP} more characters cut by codex-bridge]` : s)
79const oneLine = (s: string, n = 80) => { const t = s.replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
80
81// What the Codex agent was asked since its last answer: the opening prompt, or
82// the SendMessage follow-ups that woke it.
83async function pendingPrompt($: EngineInterface, agentId: string): Promise<string> {
84 const rows = await $.session.messages({ agentId })
85 if (!Array.isArray(rows)) return ''
86 let last = -1
87 rows.forEach((r, i) => { if (r.role === 'assistant') last = i })
88 const asked = rows.slice(last + 1).filter(r => r.role === 'user' && !(r.toolResults?.length)).map(r => stripReminders(r.text)).filter(Boolean)
89 if (asked.length) return asked.join('\n\n')
90 return rows.filter(r => r.role === 'user').map(r => stripReminders(r.text)).filter(Boolean).join('\n\n')
91}
92
93async function codexTypeOf($: EngineInterface, agentId: string): Promise<AgentType | null> {
94 if (agentTypeOf.has(agentId)) return agentTypeOf.get(agentId) ?? null
95 for (let attempt = 0; attempt < 10; attempt++) {
96 const found = (await $.agent.list()).find(a => a.id === agentId)
97 if (found) {
98 const t = types.get(found.type) ?? null
99 agentTypeOf.set(agentId, t)
100 return t
101 }
102 await $.clock.sleep(100)
103 }
104 return null
105}
106
107function ingest(run: Run, r: Pull) {
108 if (r.done) {
109 run.exited = true
110 const v = r.value as { code?: number | null } | undefined
111 run.exitCode = v?.code ?? null
112 if (run.buf.trim()) run.lines.push(run.buf)
113 run.buf = ''
114 return
115 }
116 const { stream, text } = r.value
117 if (stream === 'stderr') { run.stderr = (run.stderr + text).slice(-4000); return }
118 run.buf += text
119 const parts = run.buf.split('\n')
120 run.buf = parts.pop() ?? ''
121 for (const p of parts) if (p.trim()) run.lines.push(p)
122}
123
124async function nextEvent(run: Run): Promise<CodexEvent> {
125 while (run.lines.length === 0) {
126 if (run.exited) return { type: '__exit' }
127 ingest(run, (await run.iter.next()) as Pull)
128 }
129 const line = run.lines.shift() as string
130 try { return JSON.parse(line) as CodexEvent } catch { return { type: '__raw', text: line } }
131}
132
133function effortFlag(effort: TurnStepInput['effort']): string[] {
134 if (typeof effort !== 'string') return []
135 const level = effort === 'max' ? 'xhigh' : effort
136 return ['-c', `model_reasoning_effort="${level}"`]
137}
138
139// How to start Codex: the configured command, else `codex` on PATH, else (on
140// Windows, where the npm shim is a .cmd no shell-less spawn runs) its codex.js.
141async function commandCandidate($: EngineInterface, configured: string, attempt: number): Promise<string[] | undefined> {
142 if (resolvedCommand) return attempt === 0 ? resolvedCommand : undefined
143 if (configured.trim()) return attempt === 0 ? configured.trim().split(/\s+/) : undefined
144 if (attempt === 0) return ['codex']
145 if (attempt > 1) return undefined
146 const appData = await $.env.get('APPDATA')
147 return appData ? ['node', `${appData}\\npm\\node_modules\\@openai\\codex\\bin\\codex.js`] : undefined
148}
149
150async function startRun($: EngineInterface, agentId: string, type: AgentType, prompt: string, effort: TurnStepInput['effort'], configured: string): Promise<Run> {
151 const thread = threads.get(agentId)
152 const args = [
153 'exec', '--json', '--skip-git-repo-check',
154 '-m', type.model, '-s', type.sandbox,
155 '-c', 'approval_policy="never"',
156 ...effortFlag(effort),
157 ...(thread ? ['resume', thread, '-'] : ['-']),
158 ]
159 let lastError = ''
160 for (let attempt = 0; ; attempt++) {
161 const cmd = await commandCandidate($, configured, attempt)
162 if (!cmd) break
163 const iter = $.process.spawn({ argv: [...cmd, ...args], input: prompt })[Symbol.asyncIterator]()
164 const run: Run = { iter, buf: '', lines: [], stderr: '', exited: false, exitCode: null, done: false, type }
165 try {
166 ingest(run, (await iter.next()) as Pull)
167 } catch (err) {
168 lastError = String(err)
169 continue
170 }
171 resolvedCommand = cmd
172 return run
173 }
174 throw new Error(`could not start Codex (${lastError}). Set the codexCommand option of codex-bridge.`)
175}
176
177function kill(run: Run | undefined) {
178 if (!run || run.done) return
179 run.done = true
180 void run.iter.return?.(undefined)
181}
182
183function usageOf(u: unknown, model: string): TurnUsage | null {
184 if (!u || typeof u !== 'object') return null
185 const n = (k: string) => Number((u as Record<string, unknown>)[k] ?? 0) || 0
186 const cached = n('cached_input_tokens')
187 return {
188 model,
189 input_tokens: Math.max(0, n('input_tokens') - cached),
190 cache_read_input_tokens: cached,
191 cache_creation_input_tokens: n('cache_write_input_tokens'),
192 output_tokens: n('output_tokens'),
193 }
194}
195
196// One finished Codex action as the subagent's tool call: the tool, its input,
197// and what Codex got back.
198function asToolCall(item: CodexItem): { name: string; input: Record<string, unknown>; output: string } | undefined {
199 const s = (k: string) => (typeof item[k] === 'string' ? (item[k] as string) : '')
200 switch (item.type) {
201 case 'command_execution': {
202 const code = item.exit_code
203 return {
204 name: TOOLS.shell,
205 input: { command: s('command'), exit_code: typeof code === 'number' ? code : null },
206 output: cap(`${s('aggregated_output')}${typeof code === 'number' && code !== 0 ? `\n[exit code ${code}]` : ''}`) || '(no output)',
207 }
208 }
209 case 'file_change': {
210 const changes = Array.isArray(item.changes) ? (item.changes as { path?: string; kind?: string }[]) : []
211 return {
212 name: TOOLS.patch,
213 input: { changes: changes.map(c => ({ path: c.path ?? '', kind: c.kind ?? '' })) },
214 output: `${s('status') || 'applied'}: ${changes.map(c => `${c.kind ?? 'edit'} ${c.path ?? ''}`).join(', ')}`,
215 }
216 }
217 case 'mcp_tool_call': {
218 const result = item.error ?? item.result ?? ''
219 return {
220 name: TOOLS.tool,
221 input: { server: s('server'), tool: s('tool'), arguments: item.arguments ?? {} },
222 output: cap(typeof result === 'string' ? result : JSON.stringify(result)) || s('status') || 'done',
223 }
224 }
225 case 'web_search':
226 return { name: TOOLS.search, input: { query: s('query') }, output: s('query') ? `searched: ${s('query')}` : 'searched' }
227 default:
228 return undefined
229 }
230}
231
232function thoughtOf(item: CodexItem): string | undefined {
233 switch (item.type) {
234 case 'reasoning': return typeof item.text === 'string' ? item.text : undefined
235 case 'error': return `codex warning: ${String(item.message ?? '')}`
236 case 'todo_list': {
237 const items = Array.isArray(item.items) ? (item.items as { text?: string; completed?: boolean }[]) : []
238 return `plan:\n${items.map(i => `${i.completed ? '[x]' : '[ ]'} ${i.text ?? ''}`).join('\n')}`
239 }
240 default: return `codex ${item.type}`
241 }
242}
243
244export const register: Register = (on, options) => {
245 const configured = typeof options.codexCommand === 'string' ? options.codexCommand : ''
246 for (const t of parseModels(String(options.models ?? ''))) types.set(`codex-bridge:${t.name}`, t)
247
248 on('session.start', async ($, e, next) => {
249 const started = await next(e)
250 const toolSpec = (name: string, description: string, properties: Record<string, unknown>) =>
251 $.tool.register({ name, description, inputSchema: { type: 'object', properties }, isDeferred: true })
252 await toolSpec('shell', 'Record of a shell command a Codex subagent ran. Codex subagents only; not callable directly.', {
253 command: { type: 'string' }, exit_code: { type: ['number', 'null'] },
254 })
255 await toolSpec('patch', 'Record of file edits a Codex subagent applied. Codex subagents only; not callable directly.', {
256 changes: { type: 'array', items: { type: 'object' } },
257 })
258 await toolSpec('tool', 'Record of an MCP tool call a Codex subagent made. Codex subagents only; not callable directly.', {
259 server: { type: 'string' }, tool: { type: 'string' }, arguments: {},
260 })
261 await toolSpec('search', 'Record of a web search a Codex subagent ran. Codex subagents only; not callable directly.', {
262 query: { type: 'string' },
263 })
264 for (const t of types.values()) {
265 await $.agent.register({
266 name: t.name,
267 description: describe(t),
268 prompt: AGENT_PROMPT,
269 tools: TOOL_NAMES,
270 model: 'sonnet',
271 omitClaudeMd: true,
272 })
273 }
274 return started
275 })
276
277 // The replayed calls ran inside Codex already: allow them, answer them from the stash.
278 on('tool.check', { tool: /^mcp__codex-bridge__(shell|patch|tool|search)$/ }, () => ({ decision: 'allow' as const }))
279 on('tool.call', { tool: /^mcp__codex-bridge__(shell|patch|tool|search)$/ }, ($, e) => {
280 const out = stash.get(e.tool_use_id)
281 if (out === undefined) return { deny: 'codex-bridge: this tool records work a Codex subagent already did; it cannot be called directly.' }
282 stash.delete(e.tool_use_id)
283 return { result: out }
284 })
285
286 on('turn.step', async function* ($, e, next): AsyncGenerator<TurnStepChunk, TurnStepResult | void> {
287 const agentId = e.agentId
288 const type = agentId ? await codexTypeOf($, agentId) : null
289 if (!agentId || !type) return yield* next(e)
290
291 let block = 0
292 const finish = function* (answer: string, stopReason: 'end_turn' | 'tool_use', usage: TurnUsage | null): Generator<TurnStepChunk, TurnStepResult> {
293 if (answer) yield { kind: 'text', index: block++, text: answer }
294 yield { kind: 'stop', stopReason, usage }
295 return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason, usage }
296 }
297
298 let run = runs.get(agentId)
299 try {
300 if (!run || run.done) {
301 const prompt = await pendingPrompt($, agentId)
302 if (!prompt) return yield* finish('codex-bridge: no prompt to send to Codex.', 'end_turn', null)
303 run = await startRun($, agentId, type, prompt, e.effort, configured)
304 runs.set(agentId, run)
305 activity.set(agentId, `codex ${type.label}: starting`)
306 showStatus($)
307 }
308 } catch (err) {
309 return yield* finish(`codex-bridge: ${String(err)}`, 'end_turn', null)
310 }
311
312 const current = run
313 const onAbort = () => { kill(current); activity.delete(agentId); showStatus($) }
314 next.signal.addEventListener('abort', onAbort)
315 let text = ''
316 try {
317 for (;;) {
318 const ev = await nextEvent(current)
319 switch (ev.type) {
320 case 'thread.started':
321 if (typeof ev.thread_id === 'string') threads.set(agentId, ev.thread_id)
322 break
323 case 'item.started': {
324 const item = ev.item as CodexItem
325 if (item?.type === 'command_execution') {
326 activity.set(agentId, `codex ${type.label}: ${oneLine(String(item.command ?? ''), 60)}`)
327 showStatus($)
328 yield { kind: 'thinking', index: block++, text: `running: ${oneLine(String(item.command ?? ''), 200)}` }
329 }
330 break
331 }
332 case 'item.completed': {
333 const item = ev.item as CodexItem
334 if (!item) break
335 if (item.type === 'agent_message') {
336 const t = typeof item.text === 'string' ? item.text : ''
337 text = text ? `${text}\n\n${t}` : t
338 break
339 }
340 const call = asToolCall(item)
341 if (!call) {
342 const thought = thoughtOf(item)
343 if (thought) yield { kind: 'thinking', index: block++, text: thought }
344 break
345 }
346 if (text) yield { kind: 'text', index: block++, text }
347 const id = `toolu_codex_${Date.now().toString(36)}${(seq++).toString(36)}`
348 stash.set(id, call.output)
349 yield { kind: 'tool', index: block, id, name: call.name }
350 yield { kind: 'input', index: block, json: JSON.stringify(call.input) }
351 block++
352 yield { kind: 'stop', stopReason: 'tool_use', usage: null }
353 return { turnId: e.turnId, index: e.index, answer: text, toolUses: [{ name: call.name, input: call.input }], stopReason: 'tool_use', usage: null }
354 }
355 case 'turn.completed': {
356 current.done = true
357 activity.delete(agentId)
358 showStatus($)
359 return yield* finish(text || '(Codex finished without a message.)', 'end_turn', usageOf(ev.usage, type.model))
360 }
361 case 'turn.failed': {
362 kill(current)
363 activity.delete(agentId)
364 showStatus($)
365 const err = ev.error as { message?: string } | undefined
366 return yield* finish(`${text ? `${text}\n\n` : ''}Codex failed: ${err?.message ?? 'unknown error'}`, 'end_turn', null)
367 }
368 case 'error':
369 yield { kind: 'thinking', index: block++, text: `codex: ${String(ev.message ?? '')}` }
370 break
371 case '__raw':
372 yield { kind: 'thinking', index: block++, text: String(ev.text ?? '') }
373 break
374 case '__exit': {
375 current.done = true
376 activity.delete(agentId)
377 showStatus($)
378 const tail = current.stderr.trim().split('\n').slice(-6).join('\n')
379 return yield* finish(`${text ? `${text}\n\n` : ''}Codex exited (code ${current.exitCode}) before finishing.${tail ? `\n${tail}` : ''}`, 'end_turn', null)
380 }
381 default:
382 break
383 }
384 }
385 } finally {
386 next.signal.removeEventListener('abort', onAbort)
387 }
388 })
389
390 on('session.end', ($, e, next) => {
391 for (const run of runs.values()) kill(run)
392 return next(e)
393 })
394}
395