SLOPSHOPPER

codex-job-board

A job board for Codex CLI runs started from Bash: a status-line count while any run is going, and a /codex pane with each run's session id, model, time…

newpaneguardcommandstatusprocess
★ 1v0.1.0MITupdated 2026-10-09jsnkle/ai-native-sdlc/mods/codex-job-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-job-board
│ ┃ Codex jobs ✕ › fix the failing auth test and add an audit log call │ ┃ No Codex runs in this session 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-job-board: No Codex runs in this session yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Codex jobs
No Codex runs in this session yet.
README

Codex Job Board

A Claude Code mod that keeps track of the OpenAI Codex CLI runs Claude starts from Bash. A long codex exec run in the background can take ten minutes or more, and its output is out of sight. This mod shows how many runs are going on the status line, and lists each run in a /codex pane with the session id you need to resume it, the time it took, whether it finished, how many tokens it used and how full its context is.

What this shows

While at least one run is going, the status line reads:

codex 1 running 12m

/codex opens a pane with one row per run started in this session. The pane also opens by itself the first time a run finishes, if the terminal has room to place it.

session  model         time   status  tokens  ctx  report
6c148ada gpt-6-astra   4m     done    734.5K  42%  out/review.md
9c01d7e2 gpt-5.6-sol   38s    running 81.2K   8%   out/second.md
  • session: the last 8 characters of the Codex session id. Codex ids are UUIDv7, so their first characters are a timestamp and repeat for runs started within a minute of each other. The /codex command's output lists the full ids, ready for codex exec resume <id>.
  • time: elapsed while running, the run's length once finished.
  • status: pending while the Bash call waits (for a permission prompt, say), then running, done or failed.
  • tokens: total_token_usage.total_tokens from the run's last token_count event. It is cumulative over every turn of the Codex session, so a resumed session's figure includes the earlier runs.
  • ctx: how full the context is: last_token_usage.total_tokens over model_context_window, both from that same event. This is the figure to watch for context rot.
  • report: the -o path, as written in the command.

The mod only watches. It never blocks, delays or rewrites a Bash call, and it settles a finished call's job after the result has gone back to the model.

The patterns it demonstrates:

  • Observing tool.call for Bash and always passing the call on, with a .catch so a failure in the mod can never stop the call.
  • Finding the completion of background work from files another program writes, on a $.clock.every timer that runs only while there is work to watch, and that session.start starts again from $.state after a hot reload.
  • A /codex slash command, a Pane and a status-line entry, all drawn from the same $.state value.

Demo

No screenshot yet. The two blocks above show the status line and the pane's layout.

How it was built

  • Model: Claude in Claude Code.
  • Prompt(s): a brief from a lead agent: watch Bash calls containing codex exec, correlate each to its Codex rollout file, show a status line while a run is going and a /codex pane listing the runs; observe only. A second round applied an independent review.
  • Transcript: not shared.
  • Iterations:
  • The finishing marker came from counting event types in real rollout files written by codex exec (originator codex_exec). Each finished turn has a task_started and a task_complete. Interrupted runs end in turn_aborted, or stop with no closing event.
  • Rollout files of long runs are several megabytes, more than one $.fs.read takes, so the mod reads only the lines it needs through grep.
  • codex exec resume appends to the original rollout file. Only events after the call's start count toward that call's status, and a finished job's file can be claimed again by a later resume.
  • The review found that the first percent used the cumulative total. On a real run that showed 70% where the context was 42% full, and a resumed session could pass 100%. The percent now comes from the last request and the window Codex reports.
  • The first process check matched codex exec-server, which the ChatGPT desktop app keeps running. The check now needs a space or the end after exec.
  • A permission prompt is part of the Bash call. Jobs now stay pending and get no verdict until the call is under way.

Run it

Requirements:

  • Claude Code with function-hook mods (built and tested on 2.1.295).
  • The Codex CLI, writing its sessions under ~/.codex (or $CODEX_HOME).
  • macOS or Linux: the mod runs find, head, grep and pgrep.

No options and no environment variables.

Steps:

  1. Install it at a terminal prompt:
   /plugin install codex-job-board --marketplace jsnkle/ai-native-sdlc

Answer y to add the marketplace, then pick a scope.

  1. Have Claude run a Codex job, for example in the background:
   codex exec -m <model> -s read-only -C <dir> --color never -o <report.md> - < <prompt.md>
  1. Watch the status line, and type /codex to see the board.

To try it from a clone without installing: claude --plugin-dir mods/codex-job-board.

What it detects

  • A job: a Bash call whose command runs codex exec where a command starts. That is the start, or after ;, &&, |, (, {, $(, a newline, then, do or else. nohup, time, sudo, env [-u NAME], timeout N or VAR=value may come first, and the binary may be named or given by path. Quoted strings and heredoc bodies are skipped, so grep "codex exec" notes.md, echo "a; codex exec b", git commit -m '(codex exec later)' and a heredoc line that starts with codex exec are not jobs.
  • What the job records: the command; -m/--model; -o/--output-last-message; -C/--cd, or a cd <dir> && before the call; the id after codex exec resume, or --last; the start time; whether the call ran in the background.
  • Its rollout file:
  • For resume <id>: the file under sessions/ whose name ends in that id.
  • For a new run: the earliest rollout file in the date folders around the start that was created after the call started and that no open job has claimed. Its first line must say codex_exec wrote it in the job's directory (the -C directory, else the cd, else the session's, with symbolic links resolved). When -m is given, its first turn_context must name that model. So runs from the Codex desktop app, from a terminal elsewhere, or with another model are not claimed.
  • For resume --last, or a resume id with no file of that name: the most recently created matching file from before the call that has been written since.
  • Pending: from the call until it is under way. A background call is under way when the Bash tool returns; its start time is then taken again, so a permission prompt does not count toward the run's time. A foreground call is under way once its rollout file appears. A call that comes back as an error with no rollout file (for example, refused at the permission prompt) is dropped.
  • Done:
  • the rollout file has a task_complete event after the call started; or
  • the -o report file was written after the call started, the rollout file has been quiet for 30 seconds, and no codex exec process is left; or
  • a foreground Bash call returned without an error.
  • Failed:
  • the rollout file has a turn_aborted event after the call started; or
  • the rollout file has been quiet for 30 seconds (or never appeared), no codex exec process is left, and there is no fresh report; or
  • a foreground Bash call returned an error. The reason given is the last error event when there was one.

An error event alone is not a verdict, because Codex carries on after some errors.

  • Background: a call made with run_in_background, or one the Bash tool moved to the background (its result carries a background task id, as after a timeout or Ctrl+B). These are followed from Codex's files every 10 seconds while any job is open. A job settled by the quiet rule is looked at again for 5 minutes, and is reopened if its rollout file grows.

What it does not detect

  • A run started any other way: from a terminal, a script Claude runs (./review.sh), or another session.
  • codex exec inside quotes (bash -c "codex exec ..."), a variable, an alias or a shell function, or backgrounded inside the command with &. The last one returns at once, so it is marked done when the Bash call returns.
  • Two new runs started together in the same directory on the same model (or with no -m) cannot be told apart. They get the files in the order the files were created.
  • The process check is machine-wide and best effort. While any codex exec runs, a job whose run died stays running until its rollout file settles it, or that other run ends.
  • A cd is read only when it comes directly before the codex call, joined by && or ;.
  • An error event has not been seen in a real rollout file. The mod uses it only to name a failure.
  • Model names are not looked up. The model is what -m says (default when absent).
  • Jobs are kept for the session only (the last 50), in $.state. They survive a hot reload of the mod, not a restart.

Notes / limitations

  • The pane opens unasked only once per session, and only where it can be placed (a wide enough terminal). Otherwise use /codex.
  • Times use Claude Code's clock for the start, and the rollout's own timestamps for the finish when it has one.

Dependencies

NameVersionLicense (SPDX)Source
None

Third-party notices

OpenAI and Codex are trademarks of OpenAI; use here is descriptive and implies no endorsement.

Source 2 files
hooks/register.tsx 595 lines
1// codex-job-board: watches Bash calls that run `codex exec`, finds each run's
2// rollout file under the Codex home, and shows the runs on the status line
3// and in a /codex pane. It only observes: every Bash call goes on unchanged.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register, Timer } from 'claude-code'
7
8import type { CodexJob } from '../types'
9
10const PANE = 'codex-jobs'
11const TITLE = 'Codex jobs'
12const POLL_MS = 10_000
13const QUIET_MS = 30_000
14/** How long a job settled by a quiet file is looked at again, in case its file grows. */
15const RECHECK_MS = 5 * 60_000
16/** Slack between the clock that stamps a job and the one that stamps a rollout. */
17const SKEW_MS = 5_000
18const DAY_MS = 86_400_000
19const TURN_EVENTS = '"type":"(task_started|task_complete|turn_aborted|error|token_count)"'
20const TURN_CONTEXT = '"type":"turn_context"'
21
22/**
23 * `codex exec` where a command starts: at the start, after `;`, `&`, `|`,
24 * `(`, `{`, a newline, `$(`, `then`, `do` or `else`, past `nohup`, `time`,
25 * `sudo`, `env [-u X]`, `timeout N` or `VAR=value`, the binary by name or
26 * path. Run on the command with quoted spans and heredoc bodies masked.
27 */
28const CODEX_EXEC = /(?:^|[;&|(\n`{]|\$\(|\b(?:then|do|else)\s)\s*(?:(?:nohup|time|command|sudo|timeout\s+\S+|env(?:\s+-u\s+\S+)*)\s+|\w+=\S*\s+)*(?:[^\s;&|()`]*\/)?codex\s+exec(?=\s|$)/
29/** A `cd <dir> &&` (or `;`) before the codex call, on the masked command. */
30const CD = /(?:^|[;&|(\n{]|\b(?:then|do)\s)\s*cd\s+([^\s;&|()]+)\s*(?:&&|;|\n)/g
31
32const jobs = atom({ plugin: 'codex-job-board', key: 'jobs' } as const, [])
33const hasAutoOpened = atom({ plugin: 'codex-job-board', key: 'hasAutoOpened' } as const, false)
34
35// Module variables start over on a hot reload, which also drops their timer;
36// the jobs live in $.state, and session.start (fired again by a reload)
37// starts the poller again while a job is open.
38let ticker: Timer | undefined
39let isChecking = false
40/** Rollout files another Codex client wrote: never a job's, so never read twice. */
41const foreign = new Set<string>()
42
43// ---------------------------------------------------------------- parsing
44
45/** The command at the same length, quoted spans and heredoc bodies blanked to `_`. */
46function mask(command: string): string {
47  let out = ''
48  let quote = ''
49  let docs: { tag: string; isTabbed: boolean }[] = []
50  let i = 0
51  while (i < command.length) {
52    const ch = command[i] ?? ''
53    if (quote !== '') {
54      if (ch === quote) quote = ''
55      if (quote === '"' && ch === '\\' && i + 1 < command.length) {
56        out += '__'
57        i += 2
58        continue
59      }
60      out += ch === quote || quote === '' ? ch : ch === '\n' ? '\n' : '_'
61      i++
62      continue
63    }
64    if (ch === '\\') {
65      out += i + 1 < command.length ? '__' : '_'
66      i += 2
67      continue
68    }
69    if (ch === "'" || ch === '"') {
70      quote = ch
71      out += ch
72      i++
73      continue
74    }
75    if (ch === '<' && command.startsWith('<<', i) && !command.startsWith('<<<', i)) {
76      const doc = /^<<(-?)[ \t]*(['"]?)([\w.-]+)\2/.exec(command.slice(i, i + 256))
77      if (doc !== null) {
78        docs.push({ tag: doc[3] ?? '', isTabbed: doc[1] === '-' })
79        out += '_'.repeat(doc[0].length)
80        i += doc[0].length
81        continue
82      }
83    }
84    if (ch === '\n' && docs.length > 0) {
85      out += '\n'
86      i++
87      for (const doc of docs) {
88        while (i < command.length) {
89          const end = command.indexOf('\n', i)
90          const stop = end < 0 ? command.length : end
91          const text = command.slice(i, stop)
92          out += '_'.repeat(text.length) + (end < 0 ? '' : '\n')
93          i = stop + 1
94          if ((doc.isTabbed ? text.replace(/^\t+/, '') : text) === doc.tag) break
95        }
96      }
97      docs = []
98      continue
99    }
100    out += ch
101    i++
102  }
103  return out
104}
105
106/** Shell-ish words from `at` up to the first operator. */
107function wordsFrom(command: string, at: number): string[] {
108  const words: string[] = []
109  let word = ''
110  let hasWord = false
111  let quote = ''
112  for (const ch of command.slice(at)) {
113    if (quote !== '') {
114      if (ch === quote) quote = ''
115      else word += ch
116      continue
117    }
118    if (ch === "'" || ch === '"') {
119      quote = ch
120      hasWord = true
121      continue
122    }
123    if (/[;|&<>\n]/.test(ch)) break
124    if (/\s/.test(ch)) {
125      if (hasWord) words.push(word)
126      word = ''
127      hasWord = false
128      continue
129    }
130    word += ch
131    hasWord = true
132  }
133  if (hasWord) words.push(word)
134  return words
135}
136
137const VALUE_FLAGS = new Set([
138  '-m', '--model', '-o', '--output-last-message', '-c', '--config', '-C', '--cd',
139  '-s', '--sandbox', '-p', '--profile', '-i', '--image', '--color', '--output-schema',
140  '--add-dir', '--enable', '--disable',
141])
142
143type ParsedCommand = {
144  model?: string
145  reportPath?: string
146  cd?: string
147  cdBefore?: string
148  resumeId?: string
149  isResumeLast?: boolean
150}
151
152function parseCodexCommand(command: string): ParsedCommand | undefined {
153  const masked = mask(command)
154  const match = CODEX_EXEC.exec(masked)
155  if (match === null) return undefined
156  const at = match.index + match[0].lastIndexOf('codex')
157  const words = wordsFrom(command, at)
158  const parsed: ParsedCommand = {}
159  for (const cd of masked.slice(0, at).matchAll(CD)) {
160    const start = (cd.index ?? 0) + cd[0].lastIndexOf(cd[1] ?? '')
161    parsed.cdBefore = wordsFrom(command, start)[0]
162  }
163  const positional: string[] = []
164  for (let i = 2; i < words.length; i++) {
165    const word = words[i] ?? ''
166    const eq = word.indexOf('=')
167    const flag = word.startsWith('--') && eq > 0 ? word.slice(0, eq) : word
168    const inline = flag !== word ? word.slice(eq + 1) : undefined
169    if (VALUE_FLAGS.has(flag)) {
170      const value = inline ?? words[++i]
171      if (flag === '-m' || flag === '--model') parsed.model = value
172      if (flag === '-o' || flag === '--output-last-message') parsed.reportPath = value
173      if (flag === '-C' || flag === '--cd') parsed.cd = value
174      continue
175    }
176    if (word === '--last') parsed.isResumeLast = true
177    if (!word.startsWith('-')) positional.push(word)
178  }
179  if (positional[0] === 'resume' && !parsed.isResumeLast && positional[1] !== undefined) {
180    parsed.resumeId = positional[1]
181  }
182  return parsed
183}
184
185function resolvePath(base: string, path: string, home: string): string {
186  if (path.startsWith('/')) return path
187  if (path === '~') return home
188  if (path.startsWith('~/')) return `${home}${path.slice(1)}`
189  return `${base.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
190}
191
192// ------------------------------------------------------------- formatting
193
194function formatDuration(ms: number): string {
195  const s = Math.max(0, Math.floor(ms / 1000))
196  if (s < 60) return `${s}s`
197  const m = Math.floor(s / 60)
198  if (m < 60) return `${m}m`
199  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
200}
201
202function formatTokens(n: number | undefined): string {
203  if (n === undefined) return '-'
204  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
205  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
206  return String(n)
207}
208
209function contextPercent(job: CodexJob): string {
210  const { contextTokens, contextWindow } = job
211  if (contextTokens === undefined || contextWindow === undefined || contextWindow <= 0) return '-'
212  return `${Math.round((contextTokens / contextWindow) * 100)}%`
213}
214
215/** The last 8 characters: Codex ids are UUIDv7, whose leading ones repeat for runs a minute apart. */
216const shortId = (id: string | undefined) => (id === undefined ? 'pending' : id.slice(-8))
217
218const isOpen = (job: CodexJob) => job.status === 'pending' || job.status === 'running'
219
220function statusLine(list: readonly CodexJob[], now: number): string | undefined {
221  const running = list.filter(job => job.status === 'running')
222  if (running.length === 0) return undefined
223  const oldest = Math.min(...running.map(job => job.startedAt))
224  return `codex ${running.length} running ${formatDuration(now - oldest)}`
225}
226
227// --------------------------------------------------------- codex's files
228
229async function codexHome($: EngineInterface): Promise<string> {
230  const own = await $.env.get('CODEX_HOME')
231  if (own !== undefined && own !== '') return own
232  return `${(await $.env.get('HOME')) ?? ''}/.codex`
233}
234
235async function run($: EngineInterface, argv: string[]): Promise<string> {
236  try {
237    const { exitCode, stdout } = await $.process.run(argv)
238    return exitCode === 0 ? stdout : ''
239  } catch {
240    return ''
241  }
242}
243
244const lines = (text: string) => text.split('\n').filter(line => line.trim() !== '')
245
246function parseLine(line: string | undefined): Record<string, unknown> | undefined {
247  try {
248    const value: unknown = JSON.parse(line ?? '')
249    return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
250  } catch {
251    return undefined
252  }
253}
254
255const payloadOf = (row: Record<string, unknown> | undefined) =>
256  (typeof row?.payload === 'object' && row.payload !== null ? row.payload : (row ?? {})) as Record<string, unknown>
257
258/** `YYYY/MM/DD` folders that can hold a file created at `at`, whatever the local time zone. */
259function dateFolders(sessions: string, at: number): string[] {
260  return [-1, 0, 1].map(days => {
261    const d = new Date(at + days * DAY_MS)
262    const mm = String(d.getUTCMonth() + 1).padStart(2, '0')
263    const dd = String(d.getUTCDate()).padStart(2, '0')
264    return `${sessions}/${d.getUTCFullYear()}/${mm}/${dd}`
265  })
266}
267
268/**
269 * When the rollout at `path` was created, if `codex exec` wrote it in the
270 * job's directory (and, for a new run with `-m`, on the job's model).
271 */
272async function fit($: EngineInterface, job: CodexJob, path: string, isNewRun: boolean) {
273  const meta = payloadOf(parseLine(await run($, ['head', '-n', '1', path])))
274  if (meta.originator !== 'codex_exec') {
275    if (meta.originator !== undefined) foreign.add(path)
276    return undefined
277  }
278  if (String(meta.cwd ?? '').replace(/\/$/, '') !== job.codexCwd.replace(/\/$/, '')) return undefined
279  const at = Date.parse(String(meta.timestamp ?? ''))
280  if (Number.isNaN(at)) return undefined
281  if (isNewRun && job.model !== undefined) {
282    const turn = payloadOf(parseLine(await run($, ['grep', '-m', '1', TURN_CONTEXT, path])))
283    if (turn.model !== job.model) return undefined
284  }
285  return at
286}
287
288/** Finds the rollout file a job writes, skipping files other open jobs claimed. */
289async function findRollout($: EngineInterface, job: CodexJob, claimed: Set<string>, now: number) {
290  const sessions = `${await codexHome($)}/sessions`
291  if (job.resumeId !== undefined) {
292    const named = lines(await run($, ['find', sessions, '-name', `rollout-*${job.resumeId}.jsonl`]))[0]
293    if (named !== undefined) return named
294  }
295  if (job.resumeId !== undefined || job.isResumeLast === true) {
296    // Resumed: the latest file created before the call and written since.
297    const minutes = String(Math.ceil((now - job.startedAt) / 60_000) + 1)
298    let best: { path: string; at: number } | undefined
299    for (const path of lines(await run($, ['find', sessions, '-name', 'rollout-*.jsonl', '-mmin', `-${minutes}`]))) {
300      if (claimed.has(path) || foreign.has(path)) continue
301      const stat = await $.fs.stat(path).catch(() => undefined)
302      if (stat === undefined || stat.mtimeMs < job.startedAt - SKEW_MS) continue
303      const at = await fit($, job, path, false)
304      if (at !== undefined && at < job.startedAt && (best === undefined || at > best.at)) best = { path, at }
305    }
306    return best?.path
307  }
308  // New: the earliest file created after the call, from the date folders around it.
309  let best: { path: string; at: number } | undefined
310  for (const folder of dateFolders(sessions, job.startedAt)) {
311    const entries = await $.fs.list(folder).catch(() => [])
312    for (const entry of entries) {
313      const path = `${folder}/${entry.name}`
314      if (!/^rollout-.*\.jsonl$/.test(entry.name) || entry.mtimeMs < job.startedAt - SKEW_MS) continue
315      if (claimed.has(path) || foreign.has(path)) continue
316      const at = await fit($, job, path, true)
317      if (at !== undefined && at >= job.startedAt - SKEW_MS && (best === undefined || at < best.at)) best = { path, at }
318    }
319  }
320  return best?.path
321}
322
323type RolloutScan = {
324  totalTokens?: number
325  contextTokens?: number
326  contextWindow?: number
327  finish?: { status: 'done' | 'failed'; at: number; reason?: string }
328  lastError?: string
329}
330
331/** The last token counts, and how the job's turn ended: events before `since` belong to earlier runs. */
332function scanRollout(text: string, since: number): RolloutScan {
333  const scan: RolloutScan = {}
334  for (const line of lines(text)) {
335    const row = parseLine(line)
336    const event = payloadOf(row)
337    if (event.type === 'token_count') {
338      const info = event.info as {
339        total_token_usage?: { total_tokens?: unknown }
340        last_token_usage?: { total_tokens?: unknown }
341        model_context_window?: unknown
342      } | null | undefined
343      const total = info?.total_token_usage?.total_tokens
344      const last = info?.last_token_usage?.total_tokens
345      if (typeof total === 'number') scan.totalTokens = total
346      if (typeof last === 'number') scan.contextTokens = last
347      if (typeof info?.model_context_window === 'number') scan.contextWindow = info.model_context_window
348      continue
349    }
350    const at = Date.parse(String(row?.timestamp ?? ''))
351    if (!Number.isNaN(at) && at < since - SKEW_MS) continue
352    if (event.type === 'task_started') delete scan.finish
353    if (event.type === 'task_complete') scan.finish = { status: 'done', at }
354    if (event.type === 'turn_aborted') scan.finish = { status: 'failed', at, reason: `aborted: ${String(event.reason ?? '')}` }
355    // Not a verdict: Codex goes on after some errors. It names the failure if the run dies.
356    if (event.type === 'error') scan.lastError = `error: ${String(event.message ?? '').slice(0, 80)}`
357  }
358  return scan
359}
360
361/** Asks once per check whether any `codex exec` runs (not `codex exec-server`, which the ChatGPT app keeps). */
362async function isCodexAlive($: EngineInterface, memo: { isAlive?: boolean }) {
363  memo.isAlive ??= (await run($, ['pgrep', '-f', 'codex exec([[:space:]]|$)'])).trim() !== ''
364  return memo.isAlive
365}
366
367type Ended = { isError: boolean }
368
369/**
370 * Brings one job up to date. `ended` is set when its foreground Bash call
371 * returned; `undefined` back means drop the job (the call was refused).
372 */
373async function check($: EngineInterface, job: CodexJob, claimed: Set<string>, now: number, memo: { isAlive?: boolean }, ended?: Ended) {
374  const updated: CodexJob = { ...job }
375  updated.rolloutPath ??= await findRollout($, job, claimed, now)
376  let scan: RolloutScan = {}
377  if (updated.rolloutPath !== undefined) {
378    claimed.add(updated.rolloutPath)
379    // A rollout of its own means the call got past its permission prompt.
380    if (updated.status === 'pending') updated.status = 'running'
381    updated.sessionId = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(updated.rolloutPath)?.[1]
382    scan = scanRollout(await run($, ['grep', '-E', TURN_EVENTS, updated.rolloutPath]), job.startedAt)
383    if (scan.totalTokens !== undefined) updated.totalTokens = scan.totalTokens
384    if (scan.contextTokens !== undefined) updated.contextTokens = scan.contextTokens
385    if (scan.contextWindow !== undefined) updated.contextWindow = scan.contextWindow
386  }
387  const settle = (status: 'done' | 'failed', endedAt: number, endedBy: CodexJob['endedBy'], reason?: string): CodexJob =>
388    ({ ...updated, status, endedAt, endedBy, ...(reason === undefined ? {} : { reason }) })
389  const { finish } = scan
390  if (finish !== undefined && updated.status === 'running') {
391    return settle(finish.status, Number.isNaN(finish.at) ? now : finish.at, 'rollout', finish.reason)
392  }
393  if (ended !== undefined) {
394    if (ended.isError && updated.rolloutPath === undefined) return undefined
395    return ended.isError ? settle('failed', now, 'bash', scan.lastError ?? 'the Bash call failed') : settle('done', now, 'bash')
396  }
397  if (updated.status === 'pending') return updated
398  const rollout = updated.rolloutPath === undefined ? undefined : await $.fs.stat(updated.rolloutPath).catch(() => undefined)
399  const quietSince = rollout?.mtimeMs ?? job.startedAt
400  if (now - quietSince < QUIET_MS || (await isCodexAlive($, memo))) return updated
401  const report = job.reportFile === undefined ? undefined : await $.fs.stat(job.reportFile).catch(() => undefined)
402  if (rollout !== undefined && report !== undefined && report.mtimeMs >= job.startedAt) return settle('done', quietSince, 'quiet')
403  const reason = scan.lastError ?? (rollout === undefined ? 'no rollout file and no codex process' : 'codex exited without a report')
404  return settle('failed', quietSince, 'quiet', reason)
405}
406
407/** A job settled by a quiet file, recent, whose file grew since and no later job took over. */
408async function shouldRecheck($: EngineInterface, job: CodexJob, list: readonly CodexJob[], now: number) {
409  if (job.endedBy !== 'quiet' || job.rolloutPath === undefined || job.endedAt === undefined) return false
410  if (now - job.endedAt > RECHECK_MS) return false
411  const isTakenOver = list.some(other => other.startedAt > job.startedAt && (other.rolloutPath === job.rolloutPath || other.resumeId === job.sessionId))
412  if (isTakenOver) return false
413  const stat = await $.fs.stat(job.rolloutPath).catch(() => undefined)
414  return stat !== undefined && stat.mtimeMs > job.endedAt + 1_000
415}
416
417// ------------------------------------------------------------------ polling
418
419type Focus = { id: string; ended?: Ended; isBackground?: boolean }
420
421/** Checks the open jobs (or only `focus`) and writes what changed. */
422async function refresh($: EngineInterface, focus?: Focus) {
423  const isTick = focus === undefined
424  if (isTick && isChecking) return
425  if (isTick) isChecking = true
426  try {
427    const now = await $.clock.now()
428    const list = await read($, jobs)
429    const claimed = new Set(list.flatMap(job => (isOpen(job) && job.rolloutPath !== undefined ? [job.rolloutPath] : [])))
430    const memo: { isAlive?: boolean } = {}
431    const checked = new Map<string, CodexJob | undefined>()
432    const reopened = new Set<string>()
433    for (const job of list) {
434      if (focus !== undefined && job.id !== focus.id) continue
435      if (!isOpen(job)) continue
436      let current = job
437      if (focus?.isBackground === true) {
438        // The call returned: the run starts now, not when it asked for permission.
439        current = { ...job, isBackground: true, status: 'running', startedAt: job.status === 'pending' ? now : job.startedAt }
440      }
441      checked.set(job.id, await check($, current, claimed, now, memo, focus?.ended))
442    }
443    if (isTick) {
444      for (const job of list) {
445        if (isOpen(job) || !(await shouldRecheck($, job, list, now))) continue
446        const again = await check($, { ...job, status: 'running' }, claimed, now, memo)
447        if (again?.status === 'running') {
448          const { endedAt: _endedAt, endedBy: _endedBy, reason: _reason, ...rest } = again
449          checked.set(job.id, rest)
450          reopened.add(job.id)
451        }
452      }
453    }
454    const after = await update($, jobs, current =>
455      current.flatMap(job => {
456        if (!checked.has(job.id)) return [job]
457        const next = checked.get(job.id)
458        // A verdict written meanwhile (a foreground call's own check) stands.
459        if (next === undefined) return []
460        return isOpen(job) || reopened.has(job.id) ? [next] : [job]
461      }),
462    )
463    $.ui.status(statusLine(after, now))
464    const isWatching = after.some(job => isOpen(job) || (job.endedBy === 'quiet' && job.endedAt !== undefined && now - job.endedAt < RECHECK_MS))
465    if (!isWatching && ticker !== undefined) {
466      ticker.cancel()
467      ticker = undefined
468    }
469    const hasFinished = [...checked.values()].some(job => job !== undefined && !isOpen(job))
470    if (hasFinished && !(await read($, hasAutoOpened))) {
471      const opened = await $.ui.open({ id: PANE, title: TITLE })
472      if (opened.isPlaced) await update($, hasAutoOpened, () => true)
473      else await $.ui.close({ id: PANE })
474    }
475  } finally {
476    if (isTick) isChecking = false
477  }
478}
479
480function startPolling($: EngineInterface) {
481  ticker ??= $.clock.every(POLL_MS, () => void refresh($))
482}
483
484/** After the Bash call returned: settle a foreground job, or mark a background one running. */
485async function settleCall($: EngineInterface, id: string, ran: { isError?: true; result?: unknown }) {
486  const record = ran.result as { backgroundTaskId?: unknown; interrupted?: unknown } | undefined
487  const list = await read($, jobs)
488  const job = list.find(one => one.id === id)
489  if (job === undefined) return
490  const isBackground = job.isBackground || (ran.isError !== true && typeof record?.backgroundTaskId === 'string')
491  if (isBackground && ran.isError !== true) {
492    await refresh($, { id, isBackground: true })
493    startPolling($)
494    return
495  }
496  await refresh($, { id, ended: { isError: ran.isError === true || record?.interrupted === true } })
497}
498
499/** Records a job for a Bash call that runs `codex exec`. */
500async function recordJob($: EngineInterface, id: string, command: string, isBackground: boolean): Promise<boolean> {
501  const parsed = parseCodexCommand(command)
502  if (parsed === undefined) return false
503  const startedAt = await $.clock.now()
504  const home = (await $.env.get('HOME')) ?? ''
505  const sessionCwd = await $.session.cwd()
506  const base = parsed.cdBefore === undefined ? sessionCwd : resolvePath(sessionCwd, parsed.cdBefore, home)
507  const asked = parsed.cd === undefined ? base : resolvePath(base, parsed.cd, home)
508  // Codex records its directory with links resolved (/tmp is /private/tmp on macOS).
509  const codexCwd = (await $.fs.stat(asked, { resolve: true }).catch(() => undefined))?.realPath ?? asked
510  const { cd: _cd, cdBefore: _cdBefore, ...rest } = parsed
511  const job: CodexJob = {
512    id,
513    command,
514    ...rest,
515    ...(parsed.reportPath === undefined ? {} : { reportFile: resolvePath(base, parsed.reportPath, home) }),
516    codexCwd,
517    isBackground,
518    startedAt,
519    status: 'pending',
520  }
521  await update($, jobs, current => [...current, job].slice(-50))
522  startPolling($)
523  return true
524}
525
526export const register: Register = on => {
527  on('session.start', async ($, e, next) => {
528    await $.command.register({ name: 'codex', description: 'Show the Codex runs started in this session' })
529    const list = await read($, jobs)
530    if (list.some(isOpen)) {
531      startPolling($)
532      $.ui.status(statusLine(list, await $.clock.now()))
533    }
534    return next(e)
535  })
536
537  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
538    if (!e.command.includes('codex')) return next(e)
539    const isJob = await recordJob($, e.tool_use_id, e.command, e.run_in_background === true)
540    const ran = await next(e)
541    // The model gets the result now; the job is settled just after.
542    if (isJob) $.clock.after(0, () => void settleCall($, e.tool_use_id, ran))
543    return ran
544  }).catch(($, e, next) => next(e))
545
546  on('command.run', { command: 'codex' }, async $ => {
547    await $.ui.open({ id: PANE, title: TITLE })
548    const list = await read($, jobs)
549    if (list.length === 0) return { text: 'No Codex runs in this session yet.' }
550    const rows = list.map(job => {
551      const id = job.sessionId ?? 'session id not found yet'
552      const tokens = job.totalTokens === undefined ? '' : `, ${job.totalTokens} tokens, context ${contextPercent(job)}`
553      const report = job.reportPath === undefined ? '' : `, report ${job.reportPath}`
554      return `${id}: ${job.status}${tokens}${report}`
555    })
556    return { text: `Codex runs:\n${rows.join('\n')}` }
557  })
558
559  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
560    const { Box, Text } = $.ui.resolve(e)
561    const list = await read($, jobs)
562    const now = await $.clock.now()
563    if (list.length === 0) {
564      return (
565        <Box flexDirection="column">
566          <Text dimColor>No Codex runs in this session yet.</Text>
567        </Box>
568      )
569    }
570    const head = `${'session'.padEnd(9)}${'model'.padEnd(14)}${'time'.padEnd(7)}${'status'.padEnd(8)}${'tokens'.padEnd(8)}${'ctx'.padEnd(5)}report`
571    return (
572      <Box flexDirection="column">
573        <Text dimColor wrap="truncate-end">{head}</Text>
574        {list.map(job => {
575          const time = formatDuration((job.endedAt ?? now) - job.startedAt)
576          const color = job.status === 'failed' ? 'error' : job.status === 'done' ? 'success' : 'warning'
577          return (
578            <Box key={`job-${job.id}`} flexDirection="row">
579              <Text wrap="truncate-end">
580                {shortId(job.sessionId).padEnd(9)}
581                {(job.model ?? 'default').slice(0, 13).padEnd(14)}
582                {time.padEnd(7)}
583                <Text color={color}>{job.status.padEnd(8)}</Text>
584                {formatTokens(job.totalTokens).padEnd(8)}
585                {contextPercent(job).padEnd(5)}
586              </Text>
587              <Text dimColor wrap="truncate-start">{job.reportPath ?? '-'}</Text>
588            </Box>
589          )
590        })}
591      </Box>
592    )
593  })
594}
595
types/index.d.ts 51 lines
1/**
2 * `pending` until the Bash call is under way (its permission prompt may still
3 * be open): a job gets no verdict while pending.
4 */
5export type CodexJobStatus = 'pending' | 'running' | 'done' | 'failed'
6
7export type CodexJob = {
8  /** The Bash call's tool_use_id. */
9  id: string
10  command: string
11  model?: string
12  /** The `-o` path as written in the command. */
13  reportPath?: string
14  /** The `-o` path resolved against the directory the command runs in. */
15  reportFile?: string
16  /** The directory Codex runs in: `-C`, else a leading `cd`, else the session's. */
17  codexCwd: string
18  /** The id after `codex exec resume`. */
19  resumeId?: string
20  /** `codex exec resume --last`. */
21  isResumeLast?: boolean
22  /** The call asked for `run_in_background`, or the tool moved it there. */
23  isBackground: boolean
24  startedAt: number
25  endedAt?: number
26  /** What settled it: a turn event, a quiet file with no process, or the Bash call returning. */
27  endedBy?: 'rollout' | 'quiet' | 'bash'
28  status: CodexJobStatus
29  /** Why a job counts as failed, in a few words. */
30  reason?: string
31  /** The Codex session id, from the rollout file's name. */
32  sessionId?: string
33  rolloutPath?: string
34  /** `total_token_usage.total_tokens` of the last `token_count`: cumulative over the session's turns. */
35  totalTokens?: number
36  /** `last_token_usage.total_tokens` of the last `token_count`: how full the context is. */
37  contextTokens?: number
38  /** `model_context_window` of the last `token_count`. */
39  contextWindow?: number
40}
41
42declare module 'claude-code' {
43  interface PluginState {
44    'codex-job-board': {
45      jobs: CodexJob[]
46      /** The pane has been opened once, unasked, when a job finished. */
47      hasAutoOpened: boolean
48    }
49  }
50}
51