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…

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.
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
/codex command's output lists the full ids, ready for codex exec resume <id>.pending while the Bash call waits (for a permission prompt, say), then running, done or failed.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.last_token_usage.total_tokens over model_context_window, both from that same event. This is the figure to watch for context rot.-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:
tool.call for Bash and always passing the call on, with a .catch so a failure in the mod can never stop the call.$.clock.every timer that runs only while there is work to watch, and that session.start starts again from $.state after a hot reload./codex slash command, a Pane and a status-line entry, all drawn from the same $.state value.No screenshot yet. The two blocks above show the status line and the pane's layout.
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.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.$.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.codex exec-server, which the ChatGPT desktop app keeps running. The check now needs a space or the end after exec.pending and get no verdict until the call is under way.Requirements:
~/.codex (or $CODEX_HOME).find, head, grep and pgrep.No options and no environment variables.
Steps:
/plugin install codex-job-board --marketplace jsnkle/ai-native-sdlc
Answer y to add the marketplace, then pick a scope.
codex exec -m <model> -s read-only -C <dir> --color never -o <report.md> - < <prompt.md>
/codex to see the board.To try it from a clone without installing: claude --plugin-dir mods/codex-job-board.
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.-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.resume <id>: the file under sessions/ whose name ends in that id.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.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.task_complete event after the call started; or-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; orturn_aborted event after the call started; orcodex exec process is left, and there is no fresh report; orerror event when there was one.An error event alone is not a verdict, because Codex carries on after some errors.
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../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.-m) cannot be told apart. They get the files in the order the files were created.codex exec runs, a job whose run died stays running until its rollout file settles it, or that other run ends.cd is read only when it comes directly before the codex call, joined by && or ;.error event has not been seen in a real rollout file. The mod uses it only to name a failure.-m says (default when absent).$.state. They survive a hot reload of the mod, not a restart./codex.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
OpenAI and Codex are trademarks of OpenAI; use here is descriptive and implies no endorsement.
hooks/register.tsx 595 lines1// 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}
595types/index.d.ts 51 lines1/**
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