SLOPSHOPPER

codex-bridge

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

newguardstatustoolprocess
★ 1v0.1.1MITupdated 2026-10-12finalpantasy/claude-mod-pack/codex-bridge
A shopper browsing a rack in a slop shop
README

Claude Mod Pack

Support me on Ko-fi

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.

Claude Status Band above the Claude Code prompt: week and 5-hour limit rings, context, spend and token rows, with Compact now and Handoff buttons, and the Claude Mod Hider arrow (boxed) in the corner

A real screenshot from the Claude desktop app. The dollar and token figures are pixelated.

Install

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

The mods

Claude Status Band (status-band)

One tidy band above the prompt, each row labelled:

  • Week and 5-hour: your plan's limits as rings, with a pace for the week that learns when you actually work. Nights and weekends you are away are not counted as burn, and it gets more accurate the longer you use it.
  • Context: how full the chat is and when it will need compacting (red from 75%).
  • Spend and Tokens: this chat, today, 7 days and all time, across every open chat.
  • Last turn: how long it took, files edited with lines added and removed, commands run, failures, reads and agents.
  • Tasks: your open to-do list, from TodoWrite or claude-mem's work state.
  • Compact now and Handoff buttons, always there. Handoff has Claude write a handoff packet and move the work to a fresh chat.

Claude Status Band with sample data: week, 5-hour and context rings, spend and token tiles for this chat, today, 7 days and all time, Compact now and Handoff buttons, the last turn and an open task list

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

The /mods panel: Compact now and Handoff, a switch for each band row, and every installed mod with an on/off switch and its full description

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

Claude Push Guard (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:

  • Your words: your real name, personal email, company, anything you list in the plugin's settings (/plugin configure push-guard@claude-mod-pack). Empty until you fill it in.
  • Your home folder path, read from your own machine, in any spelling (C:\Users\you, /c/Users/you, escaped JSON, /home/you).
  • Your everyday git name and email, but only when the commits are made under a different identity, the way a real name slips into a repo you publish under a handle.
  • Em dashes, if you turn that on.

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.

Claude Codex Bridge (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.

  • Needs the Codex CLI installed and signed in.
  • Set which models you get in the plugin's settings (models, as name=model pairs).

Support

These are free hobby projects. If one saves you time, you can buy me a coffee on Ko-fi.

License

MIT

Source 1 files
hooks/register.ts 395 lines
1import 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