Gives Claude a codex tool: hand a task to OpenAI Codex (your own ChatGPT/Codex subscription) as a subagent, with model and effort choice, per-project memory…

codex-bridge)Gives Claude Code a codex tool, so Claude can hand work to OpenAI Codex the way it hands work to its own helper agents. Codex runs through the Codex CLI signed in to your ChatGPT/Codex subscription. There's no API key, and runs count against that subscription rather than Claude's.
/plugin install codex-bridge --marketplace D3K1D/claude-crew-mods
You also need the Codex CLI: the Codex desktop app includes it, or install it with npm i -g @openai/codex and run codex login. Open the pane with /codex. The repository's README covers other ways to install it and troubleshooting.
Ask in plain words: "have Codex review the renderer", "ask Codex for a second opinion on this design", "give Codex a thread called chunk-loader and make chunk loading async while you work on the renderer". Claude writes Codex a self-contained brief (Codex can't see your chat), picks a model and effort, and reads the result back: Codex's answer, the files it changed with lines added and removed, the commands it ran, and how to continue.
Models and effort. The tool lists the models your account offers, with a rough guide:
Leave them unset and your Codex defaults apply.
Read-only vs write. Read-only (the default) lets Codex read files and run read-only commands. Write lets it edit inside the project folder only, and the result reminds Claude to review the diff.
Memory. Each project gets a .codex-bridge/ folder, kept out of git through .git/info/exclude:
NOTES.md: lasting project knowledge that Codex keeps up to date and reads at the start of every run.threads/<name>.md: a log per workstream, with each task, who asked, the result and the files.threads.json: which Codex conversation each named thread continues.Give a workstream a thread name and every call with that name continues the same Codex conversation. If an old conversation can't be resumed, a fresh one starts with the notes and log as context.
The pane's Threads section lists each project's threads, newest first, with how many runs each has had, when it was last used, its model and its last task. Each thread has these buttons:
NOTES.md stays.Claude can manage threads too: the tool has threads, rename_thread, reset_thread and archive_thread actions.
The top of the pane shows your Codex plan and each of its limits (the 5-hour window and the weekly one) as a bar, with how much is used and when it resets. The bar is green under 70%, yellow under 90%, and red above that. The numbers come from the session logs Codex writes after each run (under ~/.codex/sessions), so they're as fresh as your last Codex run; ↻ refresh reads them again. Every result Claude gets ends with the same numbers, and the tool's usage action asks for them directly, so Claude can pick a cheaper model or effort when you're close to a limit.
Each run gets a card showing its thread, model and effort, mode, who asked, elapsed time, what Codex is doing right now, its commands (✓/✗), the files it changed, and a stop button while it runs. Finished runs show a summary and whether Codex updated its notes. Each changed file is a button that opens it in your editor as a diff against your last commit. The editor is the one picked in the crew map, or VS Code if that mod isn't installed.
With the crew-map mod installed, a running Codex also gets its own desk and window in the crew pane, and its own tree on the optional tree map.
AppData, including a chat's own scratch folder. The tool says so and asks for a normal project folder.%LOCALAPPDATA%\OpenAI\Codex\bin\…), then where codex (npm's codex.cmd works too).Codex.app, Homebrew, /usr/local/bin, ~/.local/bin, then your login shell's PATH./usr/local/bin, ~/.local/bin, ~/.npm-global/bin, ~/.bun/bin, then your login shell's PATH.Codex's home is CODEX_HOME if set, otherwise ~/.codex.
Part of claude-crew-mods by D3K1D, made with Claude in Claude Code. MIT licensed.
hooks/register.tsx 1311 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, PaneUi, Registry, Run, RunEvent, Touch, Usage } from '../types'
5
6// ─── State ──────────────────────────────────────────────────────────────────
7
8const PANE = 'codex-bridge'
9/** Shown in the pane's header, so it is plain which version a chat is running. Keep in step with plugin.json. */
10const VERSION = '1.2.2'
11const TOOL = 'codex'
12const MAX_RUNS = 16
13const MAX_EVENTS = 60
14const MAX_TOUCHED = 40
15const MEMORY_DIR = '.codex-bridge'
16
17const runs = atom({ plugin: 'codex-bridge', key: 'runs' } as const, [] as Run[])
18const usage = atom({ plugin: 'codex-bridge', key: 'usage' } as const, { plan: '', limits: [], seenAt: 0, readAt: 0, missing: '' } as Usage)
19const threads = atom({ plugin: 'codex-bridge', key: 'threads' } as const, {} as Record<string, Registry>)
20const paneUi = atom({ plugin: 'codex-bridge', key: 'ui' } as const, { renaming: '', confirmDelete: '', showArchived: false } as PaneUi)
21
22/** Stop switches for runs in flight, by run id. A reload drops them with the runs. */
23const stops = new Map<string, () => void>()
24/** The editor files open in: the crew-map mod's choice when it is installed ('code', 'cursor' or 'system'). */
25let editor = 'code'
26/** Whether the Codex CLI was found when the session started (the pane says how to install it when not). */
27let cliFound: boolean | undefined
28/** An editor's name as a person knows it. */
29const editorName = (id: string) => ({ code: 'VS Code', cursor: 'Cursor' })[id] ?? id
30let tempDir = ''
31
32let platform: 'win' | 'mac' | 'linux' | undefined
33/** Which OS this runs on: Windows says so in OS; elsewhere uname tells macOS from Linux. */
34const platformOf = async ($: EngineInterface) => {
35 if (platform) return platform
36 if ((await $.env.get('OS').catch(() => undefined)) === 'Windows_NT') return (platform = 'win')
37 const u = await $.process.run(['uname', '-s'], { timeoutMs: 5000 }).catch(() => undefined)
38 return (platform = /darwin/i.test(u?.stdout ?? '') ? 'mac' : 'linux')
39}
40
41/** A PowerShell single-quoted literal: nothing inside is read as code (quote marks, curly ones too, are doubled). */
42const psq = (s: string) => `'${s.replace(/['‘’‚‛]/g, m => m + m)}'`
43/** Characters cmd.exe acts on even inside an argument: a path holding one never goes through cmd. */
44const CMD_META = /[&|<>^%!"]/
45
46/** Deletes one file (there is no $.fs call for it): PowerShell's Remove-Item on Windows, `rm` elsewhere. */
47const removeFile = async ($: EngineInterface, path: string) => {
48 const argv =
49 (await platformOf($)) === 'win'
50 ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Remove-Item -LiteralPath ${psq(path.replace(/\//g, '\\'))} -Force -ErrorAction SilentlyContinue`]
51 : ['rm', '-f', path]
52 await $.process.run(argv, { timeoutMs: 15_000 }).catch(() => undefined)
53}
54
55/**
56 * Opens a file in the person's editor. A file Codex changed, inside a git
57 * repository, opens as a diff against HEAD in VS Code or Cursor; anything
58 * else opens plainly. 'system' hands the file to the OS default app.
59 */
60const openInEditor = async ($: EngineInterface, path: string, root: string, diff = true) => {
61 const os = await platformOf($)
62 const isWin = os === 'win'
63 const run = (argv: string[]) => $.process.run(argv, { timeoutMs: 20_000 }).catch(() => undefined)
64 // Editors' command-line launchers are .cmd scripts on Windows, which only cmd runs; cmd would act on
65 // `&`, `|` or `%` in a path, so such a path opens through Explorer instead (the default app, no diff).
66 const shell = (argv: string[]) => (isWin ? ['cmd', '/d', '/c', ...argv] : argv)
67 const viaExplorer = isWin && CMD_META.test(path)
68 if (editor === 'system' || viaExplorer) {
69 await run(isWin ? ['explorer', path.replace(/\//g, '\\')] : os === 'mac' ? ['open', path] : ['xdg-open', path])
70 return
71 }
72 const canDiff = /^(code|cursor|codium|windsurf)$/.test(editor)
73 if (diff && canDiff && root && path.startsWith(`${root}/`)) {
74 const head = await run(['git', '-C', root, 'show', `HEAD:${path.slice(root.length + 1)}`])
75 if (head?.exitCode === 0) {
76 if (!tempDir) tempDir = norm((await $.env.get('TEMP').catch(() => undefined)) || (await $.env.get('TMPDIR').catch(() => undefined)) || (isWin ? 'C:/Windows/Temp' : '/tmp'))
77 const name = baseName(path)
78 const dot = name.lastIndexOf('.')
79 // One folder per file, so two files with one name keep their own copies.
80 const before = `${tempDir}/codex-HEAD/${hashOf(path)}/${dot > 0 ? `${name.slice(0, dot)} (HEAD)${name.slice(dot)}` : `${name} (HEAD)`}`
81 if (await $.fs.write(before, head.stdout).then(() => true, () => false)) {
82 await run(shell([editor, '--diff', before, path]))
83 return
84 }
85 }
86 }
87 await run(shell(canDiff ? [editor, '-g', path] : [editor, path]))
88}
89const hashOf = (s: string) => {
90 let h = 2166136261
91 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
92 return (h >>> 0).toString(36)
93}
94/** The git top level of this session's folder (else the folder): whose threads the pane shows first. */
95let sessionRoot = ''
96let codexPath: string | undefined
97let dirs: { local: string; home: string; codexHome: string } | undefined
98
99type Model = { slug: string; name: string; description: string; efforts: string[]; defaultEffort: string }
100let models: Model[] = []
101let defaults = { model: '', effort: '' }
102
103// ─── Helpers ────────────────────────────────────────────────────────────────
104
105const str = (v: unknown) => (typeof v === 'string' ? v : '')
106const trunc = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
107const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
108const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p
109/** A count in at most five characters: 7, 950, 1.2k, 12k, 1.0M, 33M. */
110const fmtK = (v: number) => {
111 let n = Math.max(0, Number(v) || 0)
112 if (n < 999.5) return String(Math.round(n))
113 let u = 0
114 while (n >= 999.5 && u < 4) {
115 n /= 1000
116 u++
117 }
118 return `${n < 9.95 ? n.toFixed(1) : Math.round(n)}${['', 'k', 'M', 'B', 'T'][u]}`
119}
120const elapsed = (ms: number) => {
121 const s = Math.max(0, Math.round(ms / 1000))
122 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
123}
124const stamp = (ms: number) => {
125 const d = new Date(ms)
126 const p = (n: number) => String(n).padStart(2, '0')
127 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
128}
129const slug = (name: string) =>
130 name
131 .toLowerCase()
132 .replace(/[^a-z0-9_-]+/g, '-')
133 .replace(/^[-_]+|[-_]+$/g, '')
134 .slice(0, 60) || 'thread'
135
136/** A path as the crew map keys files: forward slashes, `..` resolved, drive letter upper case. */
137const norm = (p: string) => {
138 const raw = p.replace(/\\/g, '/')
139 const parts: string[] = []
140 for (const seg of raw.split('/')) {
141 if (seg === '..') parts.pop()
142 else if (seg !== '.' && seg !== '') parts.push(seg)
143 }
144 const out = (raw.startsWith('/') ? '/' : '') + parts.join('/')
145 return out.replace(/^([a-z]):/, (_, d: string) => `${d.toUpperCase()}:`)
146}
147const isAbs = (p: string) => /^([A-Za-z]:[\\/]|[\\/])/.test(p)
148const join = (dir: string, p: string) => norm(isAbs(p) ? p : `${dir}/${p}`)
149
150/** The command Codex ran, without the shell wrapper it adds on Windows. */
151const bareCommand = (cmd: string) => {
152 const inner =
153 cmd.match(/(?:powershell(?:\.exe)?"?|pwsh(?:\.exe)?"?)\s+(?:-\w+\s+)*?-Command\s+([\s\S]*)$/i)?.[1] ??
154 cmd.match(/^(?:\S*\/)?(?:bash|sh|zsh)\s+-l?c\s+([\s\S]*)$/)?.[1]
155 if (inner === undefined) return oneLine(cmd)
156 let s = inner.trim()
157 if (/^['"]/.test(s)) s = s.slice(1)
158 if (/['"]$/.test(s)) s = s.slice(0, -1)
159 return oneLine(s.replace(/''/g, "'"))
160}
161
162/** Paths a command names: tokens that look like files with an extension. */
163const pathsIn = (cmd: string) => {
164 const out = new Set<string>()
165 for (const m of cmd.matchAll(/(?:^|[\s'"(=,])((?:[A-Za-z]:)?[\w./\\-]*[\w-]\.(?:rs|toml|ts|tsx|js|jsx|mjs|py|md|json|ya?ml|c|cc|cpp|h|hpp|cs|go|java|kt|lua|txt|css|scss|html|sh|ps1|glsl|wgsl|hlsl))(?=$|[\s'"):,;])/g))
166 out.add(m[1])
167 return [...out].slice(0, 8)
168}
169
170/** Where things live: Windows' local app data (Windows only), the home folder and Codex's home (CODEX_HOME, else ~/.codex). */
171const findDirs = async ($: EngineInterface) => {
172 if (dirs) return dirs
173 const clean = (v: string | undefined) => (v?.trim() ? norm(v.trim()) : '')
174 const local = clean(await $.env.get('LOCALAPPDATA').catch(() => undefined))
175 const home = clean(await $.env.get('USERPROFILE').catch(() => undefined)) || clean(await $.env.get('HOME').catch(() => undefined))
176 const codexHome = clean(await $.env.get('CODEX_HOME').catch(() => undefined))
177 dirs = { local, home, codexHome: codexHome || (home ? `${home}/.codex` : '') }
178 return dirs
179}
180
181/**
182 * Finds codex: on Windows the newest CLI the Codex desktop app ships (under a
183 * versioned folder that changes as it updates); on macOS the one inside
184 * Codex.app; then the usual install places and whatever is on PATH.
185 */
186const findCodex = async ($: EngineInterface): Promise<string | undefined> => {
187 if (codexPath && (await $.fs.exists(codexPath).catch(() => false))) return codexPath
188 codexPath = undefined
189 const os = await platformOf($)
190 const { local, home } = await findDirs($)
191 const has = (p: string) => $.fs.exists(p).catch(() => false)
192 if (os === 'win' && local) {
193 const bin = `${local}/OpenAI/Codex/bin`
194 let best: { path: string; at: number } | undefined
195 for (const d of await $.fs.list(bin).catch(() => [])) {
196 const exe = `${bin}/${d.name}/codex.exe`
197 if (!(await has(exe))) continue
198 const at = (await $.fs.stat(exe).catch(() => undefined))?.mtimeMs ?? 0
199 if (!best || at > best.at) best = { path: exe, at }
200 }
201 if (best) return (codexPath = best.path)
202 }
203 const places =
204 os === 'win'
205 ? []
206 : [
207 ...(os === 'mac' ? ['/Applications/Codex.app/Contents/Resources/codex', `${home}/Applications/Codex.app/Contents/Resources/codex`] : []),
208 '/opt/homebrew/bin/codex',
209 '/usr/local/bin/codex',
210 `${home}/.local/bin/codex`,
211 `${home}/.npm-global/bin/codex`,
212 `${home}/.bun/bin/codex`,
213 ]
214 for (const p of places) if (await has(p)) return (codexPath = p)
215 const hit = await $.process.run(os === 'win' ? ['where', 'codex'] : ['sh', '-lc', 'command -v codex'], { timeoutMs: 8000 }).catch(() => undefined)
216 const lines = hit?.exitCode === 0 ? hit.stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean) : []
217 // npm's Windows shim is codex.cmd, which runs through cmd (see codexArgv).
218 const exe = lines.find(l => /\.exe$/i.test(l)) ?? lines.find(l => /\.cmd$/i.test(l)) ?? lines.find(l => !/\.(bat|ps1)$/i.test(l))
219 return exe ? (codexPath = exe) : undefined
220}
221
222/** How to start codex: directly, or through cmd for npm's .cmd shim on Windows. */
223const codexArgv = (exe: string) => (/\.cmd$/i.test(exe) ? ['cmd', '/d', '/c', exe] : [exe])
224
225/** The models this Codex account lists, and the user's own defaults, from Codex's home. */
226const loadModels = async ($: EngineInterface) => {
227 const { codexHome } = await findDirs($)
228 if (!codexHome) return
229 try {
230 const cache = JSON.parse(await $.fs.read(`${codexHome}/models_cache.json`)) as { models?: Record<string, unknown>[] }
231 models = (cache.models ?? [])
232 .filter(m => m.visibility !== 'hide')
233 .map(m => ({
234 slug: str(m.slug),
235 name: str(m.display_name) || str(m.slug),
236 description: str(m.description),
237 efforts: (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : []).map(l =>
238 typeof l === 'string' ? l : str((l as Record<string, unknown>).effort),
239 ),
240 defaultEffort: str(m.default_reasoning_level),
241 }))
242 .filter(m => m.slug)
243 } catch {
244 models = []
245 }
246 try {
247 const config = await $.fs.read(`${codexHome}/config.toml`)
248 const top = config.split(/^\[/m)[0]
249 defaults = {
250 model: top.match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1] ?? '',
251 effort: top.match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1] ?? '',
252 }
253 } catch {
254 defaults = { model: '', effort: '' }
255 }
256}
257
258type Snapshot = Map<string, { sig: string; added: number; removed: number; isNew: boolean }>
259
260/**
261 * What git sees as changed under `root`: per path its status, its diff's line
262 * counts and, for a new file, its size and time. Two snapshots differ on a
263 * path exactly when something touched it in between, however it was written.
264 * Paths are relative to the repository root. Undefined outside a repository.
265 */
266const snapshot = async ($: EngineInterface, root: string): Promise<Snapshot | undefined> => {
267 // -z: NUL-separated, paths raw (no quoting or octal escapes); no renames, so every path stands alone.
268 const status = await $.process.run(['git', '-C', root, 'status', '--porcelain=v1', '-z', '-uall', '--no-renames'], { timeoutMs: 15_000 }).catch(() => undefined)
269 if (!status || status.exitCode !== 0) return undefined
270 const numstat = await $.process.run(['git', '-C', root, 'diff', 'HEAD', '--numstat', '-z', '--no-renames'], { timeoutMs: 15_000 }).catch(() => undefined)
271 const counts = new Map<string, [number, number]>()
272 for (const rec of numstat?.exitCode === 0 ? numstat.stdout.split('\0') : []) {
273 const [a, r, ...p] = rec.split('\t')
274 if (p.length) counts.set(p.join('\t'), [Number(a) || 0, Number(r) || 0])
275 }
276 const entries = status.stdout
277 .split('\0')
278 .filter(rec => rec.length > 3)
279 .map(rec => ({ code: rec.slice(0, 2), path: rec.slice(3) }))
280 .filter(x => !x.path.startsWith(`${MEMORY_DIR}/`))
281 // Size and time tell a change apart where git's own counts would not (a second edit on an already-changed line).
282 const stats = new Map<string, string>()
283 const toStat = entries.slice(0, 600)
284 for (let i = 0; i < toStat.length; i += 32)
285 await Promise.all(
286 toStat.slice(i, i + 32).map(async x => {
287 const st = await $.fs.stat(`${root}/${x.path}`).catch(() => undefined)
288 if (st) stats.set(x.path, `${st.size}:${st.mtimeMs}`)
289 }),
290 )
291 const out: Snapshot = new Map()
292 for (const { code, path } of entries) {
293 const [added, removed] = counts.get(path) ?? [0, 0]
294 out.set(path, { sig: `${code}|${added}|${removed}|${stats.get(path) ?? ''}`, added, removed, isNew: code === '??' })
295 }
296 return out
297}
298
299/** Paths whose snapshot entry changed between two snapshots. */
300const changedBetween = (before: Snapshot, after: Snapshot) => [
301 ...[...after].filter(([p, v]) => before.get(p)?.sig !== v.sig).map(([p, v]) => ({ path: p, ...v })),
302 // Changed before and clean after: put back to HEAD, or an untracked file deleted.
303 ...[...before].filter(([p]) => !after.has(p)).map(([p, v]) => ({ path: p, ...v, added: 0, removed: 0, isNew: false })),
304]
305
306// ─── Memory: .codex-bridge/ at the project root ─────────────────────────────
307
308
309const projectRoot = async ($: EngineInterface, cwd: string) => {
310 const r = await $.process.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'], { timeoutMs: 10_000 }).catch(() => undefined)
311 return norm(r?.exitCode === 0 && r.stdout.trim() ? r.stdout.trim() : cwd)
312}
313
314/**
315 * Values that go on Codex's command line. Each starts with a letter or digit, so none can be
316 * read as an option (a session id of "--something" would otherwise reach Codex as a flag).
317 */
318const PLAIN_ID = /^[A-Za-z0-9][\w-]{0,127}$/
319const PLAIN_MODEL = /^[A-Za-z0-9][\w.:-]{0,127}$/
320/** A registry key: a plain slug, so a thread's log path stays inside its threads folder. */
321const PLAIN_KEY = /^[a-z0-9][a-z0-9_-]{0,80}$/
322
323/**
324 * The project's thread registry. It is a file in the repository, so it may have come with a
325 * clone: only entries with a plain key and the expected fields are kept, and a session that is
326 * not a plain id is dropped (the thread then starts a fresh conversation).
327 */
328const readRegistry = async ($: EngineInterface, root: string): Promise<Registry> => {
329 let raw: unknown
330 try {
331 raw = JSON.parse(await $.fs.read(`${root}/${MEMORY_DIR}/threads.json`))
332 } catch {
333 return {}
334 }
335 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
336 const out: Registry = {}
337 for (const [key, v] of Object.entries(raw as Record<string, unknown>)) {
338 if (!PLAIN_KEY.test(key) || !v || typeof v !== 'object') continue
339 const t = v as Record<string, unknown>
340 const txt = (x: unknown, n: number) => (typeof x === 'string' ? x.slice(0, n) : '')
341 const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
342 out[key] = {
343 name: txt(t.name, 200) || key,
344 session: PLAIN_ID.test(txt(t.session, 128)) ? txt(t.session, 128) : '',
345 created: num(t.created),
346 updated: num(t.updated),
347 runs: num(t.runs),
348 model: txt(t.model, 128),
349 effort: txt(t.effort, 32),
350 last: txt(t.last, 2000),
351 archived: t.archived === true,
352 }
353 }
354 return out
355}
356
357const writeRegistry = async ($: EngineInterface, root: string, reg: Registry) => {
358 await $.fs.write(`${root}/${MEMORY_DIR}/threads.json`, `${JSON.stringify(reg, null, 2)}\n`).catch(() => undefined)
359 await update($, threads, all => ({ ...all, [root]: reg }))
360}
361
362/** Reads a project's registry into the pane's view of it. */
363const loadThreads = async ($: EngineInterface, root: string) => {
364 if (!root) return
365 const reg = await readRegistry($, root)
366 await update($, threads, all => ({ ...all, [root]: reg }))
367}
368
369/**
370 * The thread changes the pane and the tool both make. Renaming moves the
371 * thread's log; a fresh start forgets the Codex conversation but keeps the
372 * log and notes; archiving hides it until used again; deleting drops it and
373 * its log (the project's NOTES.md stays).
374 */
375const threadAction = async ($: EngineInterface, root: string, key: string, how: 'rename' | 'reset' | 'archive' | 'unarchive' | 'delete', newName = ''): Promise<string> =>
376 withLock(root, async () => {
377 // A running thread writes its registry entry and log when it ends: change it after that.
378 if (starting.has(`${root}::${key}`) || (await read($, runs)).some(r => r.status === 'running' && r.root === root && (r.threadKey ?? slug(r.thread)) === key))
379 return `Thread "${key}" is running right now; try again when its run finishes.`
380 const reg = await readRegistry($, root)
381 const t = reg[key]
382 if (!t) return `No thread "${key}" in ${root}.`
383 const logOf = (k: string) => `${root}/${MEMORY_DIR}/threads/${k}.md`
384 switch (how) {
385 case 'rename': {
386 const name = newName.trim()
387 const k2 = slug(name)
388 if (!name) return 'Give the thread a new name.'
389 if (k2 !== key && reg[k2]) return `There is already a thread "${reg[k2].name}".`
390 const text = await $.fs.read(logOf(key)).catch(() => '')
391 if (k2 !== key) {
392 // The old log goes only once the new one is written.
393 const moved = text ? await $.fs.write(logOf(k2), text.replace(/^# Codex thread: .*$/m, `# Codex thread: ${name}`)).then(() => true, () => false) : true
394 if (!moved) return `Could not write the renamed log, so "${t.name}" was left as it was.`
395 if (text) await removeFile($, logOf(key))
396 delete reg[key]
397 }
398 reg[k2] = { ...t, name }
399 await writeRegistry($, root, reg)
400 return `Renamed thread "${t.name}" to "${name}".`
401 }
402 case 'reset':
403 reg[key] = { ...t, session: '' }
404 await writeRegistry($, root, reg)
405 return `Thread "${t.name}" will start a fresh Codex conversation next time (its log and the project notes are kept).`
406 case 'archive':
407 case 'unarchive':
408 reg[key] = { ...t, archived: how === 'archive' }
409 await writeRegistry($, root, reg)
410 return how === 'archive' ? `Archived thread "${t.name}".` : `Restored thread "${t.name}".`
411 case 'delete':
412 delete reg[key]
413 await removeFile($, logOf(key))
414 await writeRegistry($, root, reg)
415 return `Deleted thread "${t.name}" and its log.`
416 }
417 })
418
419/**
420 * The registry key a thread name means: the entry with that key or that name (any case), else
421 * its slug, made unique when another name already holds it ("Foo Bar" and "foo-bar", or any
422 * two non-Latin names, which both slug to "thread").
423 */
424const keyFor = (reg: Registry, name: string) => {
425 const n = name.trim()
426 if (!n) return ''
427 if (reg[n] && PLAIN_KEY.test(n)) return n
428 const hit = Object.entries(reg).find(([, t]) => t.name.toLowerCase() === n.toLowerCase())
429 if (hit) return hit[0]
430 const base = slug(n)
431 return reg[base] ? `${base}-${hashOf(n).slice(0, 5)}` : base
432}
433
434/** Threads whose run is being set up (`root::key`): not yet in the runs list, already not to be changed. */
435const starting = new Set<string>()
436
437/** Runs `fn` after every earlier `withLock` on the same project has finished: no two read-modify-writes overlap. */
438const locks = new Map<string, Promise<unknown>>()
439const withLock = <T,>(root: string, fn: () => Promise<T>): Promise<T> => {
440 const run = (locks.get(root) ?? Promise.resolve()).then(fn, fn)
441 locks.set(root, run.catch(() => undefined))
442 return run
443}
444
445// ─── Usage: the plan's limits, as Codex last recorded them ─────────────────
446
447/** The text of a file's last lines: through tail/PowerShell for big logs, else read whole. */
448const tailOf = async ($: EngineInterface, path: string, size: number) => {
449 if (size < 2_000_000) return $.fs.read(path).catch(() => '')
450 const win = (await platformOf($)) === 'win'
451 const argv = win
452 ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Get-Content -LiteralPath ${psq(path)} -Tail 400`]
453 : ['tail', '-n', '400', path]
454 const r = await $.process.run(argv, { timeoutMs: 20_000 }).catch(() => undefined)
455 return r?.exitCode === 0 ? r.stdout : ''
456}
457
458/**
459 * Codex's session logs (sessions/YYYY/MM/DD/rollout-*.jsonl under its home)
460 * from the newest few day folders, most recently written first. A resumed
461 * session writes to its first day's file, so the newest file is not always in
462 * the newest folder.
463 */
464const recentRollouts = async ($: EngineInterface, home: string) => {
465 const dirsIn = async (p: string) =>
466 (await $.fs.list(p).catch(() => []))
467 .filter(d => /^\d+$/.test(d.name))
468 .map(d => d.name)
469 .sort((a, b) => Number(b) - Number(a))
470 const base = `${home}/sessions`
471 const days: string[] = []
472 for (const y of (await dirsIn(base)).slice(0, 2))
473 for (const m of await dirsIn(`${base}/${y}`))
474 for (const d of await dirsIn(`${base}/${y}/${m}`)) if (days.length < 4) days.push(`${base}/${y}/${m}/${d}`)
475 const found: { path: string; at: number; size: number }[] = []
476 for (const day of days)
477 for (const f of (await $.fs.list(day).catch(() => [])).filter(x => /^rollout-.*\.jsonl$/.test(x.name))) {
478 const st = await $.fs.stat(`${day}/${f.name}`).catch(() => undefined)
479 if (st) found.push({ path: `${day}/${f.name}`, at: st.mtimeMs, size: st.size })
480 }
481 return found.sort((a, b) => b.at - a.at).slice(0, 6)
482}
483
484/** The last rate_limits object in a log's text, and the time of its line. */
485const lastLimits = (text: string) => {
486 const lines = text.split(/\r?\n/)
487 for (let i = lines.length - 1; i >= 0; i--) {
488 if (!lines[i].includes('"rate_limits"')) continue
489 try {
490 const row = JSON.parse(lines[i]) as { timestamp?: string; payload?: { rate_limits?: Record<string, unknown> } }
491 const rl = row.payload?.rate_limits
492 if (rl) return { rl, at: row.timestamp ? Date.parse(row.timestamp) : 0 }
493 } catch {
494 // A line cut by the tail: keep looking.
495 }
496 }
497 return undefined
498}
499
500/** Reads the plan's usage from Codex's newest session log into the usage atom. */
501const refreshUsage = async ($: EngineInterface) => {
502 const { codexHome } = await findDirs($)
503 const now = Date.now()
504 // The newest log that has recorded limits: a just-started session may not have yet.
505 let found: ReturnType<typeof lastLimits>
506 let log: { path: string; at: number; size: number } | undefined
507 for (const f of codexHome ? await recentRollouts($, codexHome) : []) {
508 found = lastLimits(await tailOf($, f.path, f.size))
509 if (found) {
510 log = f
511 break
512 }
513 }
514 if (!found) {
515 await update($, usage, u => ({ ...u, readAt: now, missing: codexHome ? 'Codex has not recorded any usage yet: it appears after its first run.' : "Codex's home folder was not found." }))
516 return
517 }
518 const { rl, at } = found
519 const win = (w: unknown): Limit | undefined => {
520 const o = w as { used_percent?: number; window_minutes?: number; resets_at?: number; resets_in_seconds?: number } | null
521 if (!o || typeof o.used_percent !== 'number') return undefined
522 const resetsAt = o.resets_at ? o.resets_at * 1000 : o.resets_in_seconds ? (at || log!.at) + o.resets_in_seconds * 1000 : 0
523 return { windowMinutes: o.window_minutes ?? 0, usedPercent: o.used_percent, resetsAt }
524 }
525 const limits = [win(rl.primary), win(rl.secondary)].filter((l): l is Limit => !!l).sort((a, b) => a.windowMinutes - b.windowMinutes)
526 await update($, usage, () => ({ plan: str(rl.plan_type), limits, seenAt: at || log!.at, readAt: now, missing: limits.length ? '' : 'Codex recorded no limits for this plan.' }))
527}
528
529const windowName = (min: number) => (min === 10080 ? 'weekly limit' : min === 300 ? '5-hour limit' : min >= 1440 ? `${Math.round(min / 1440)}-day limit` : `${Math.round(min / 60)}-hour limit`)
530const whenReset = (ms: number, now: number) => {
531 if (!ms) return ''
532 const left = ms - now
533 if (left <= 0) return 'has reset since'
534 if (left < 36 * 3_600_000) return `resets in ${left < 3_600_000 ? `${Math.ceil(left / 60_000)}m` : `${Math.floor(left / 3_600_000)}h ${Math.round((left % 3_600_000) / 60_000)}m`}`
535 return `resets ${new Date(ms).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}`
536}
537/** One line per limit, for the result Claude reads. */
538const usageLine = (u: Usage, now: number) =>
539 u.limits.length ? `Codex plan usage: ${u.limits.map(l => `${windowName(l.windowMinutes)} ${Math.round(l.usedPercent)}% used${l.resetsAt ? ` (${whenReset(l.resetsAt, now)})` : ''}`).join('; ')}.` : ''
540
541/** Keeps the memory folder out of commits, locally: .git/info/exclude, never the repo's .gitignore. */
542const keepOutOfGit = async ($: EngineInterface, root: string) => {
543 // A worktree's .git is a file: git itself says where its exclude file is.
544 const r = await $.process.run(['git', '-C', root, 'rev-parse', '--git-path', 'info/exclude'], { timeoutMs: 10_000 }).catch(() => undefined)
545 const rel = r?.exitCode === 0 ? r.stdout.trim() : ''
546 if (!rel) return
547 const exclude = isAbs(rel) ? norm(rel) : norm(`${root}/${rel}`)
548 const text = await $.fs.read(exclude).catch(() => '')
549 if (!text.split(/\r?\n/).some(l => l.trim() === `${MEMORY_DIR}/`))
550 await $.fs.write(exclude, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${MEMORY_DIR}/\n`).catch(() => undefined)
551}
552
553const memoryBrief = (o: { root: string; thread: string; key: string; resumed: boolean; mode: string; task: string }) =>
554 [
555 `You are Codex, working as a subagent for Claude Code (Anthropic's coding agent) in the project at ${o.root}. Claude reads your final message and acts on it.`,
556 o.thread ? `This is workstream "${o.thread}"${o.resumed ? ', continued from your earlier runs' : ''}.` : '',
557 `Your memory for this project is the folder ${MEMORY_DIR}/ at the project root. It persists across your runs:`,
558 `- ${MEMORY_DIR}/NOTES.md: durable project knowledge you curate (architecture, conventions, useful commands, gotchas, open questions).`,
559 `- ${MEMORY_DIR}/threads/${o.key || 'one-off'}.md: the log of ${o.thread ? "this workstream's" : 'one-off'} earlier tasks and results.`,
560 'Read whichever of these exist before you start, and trust the code over them where they disagree.',
561 o.mode === 'write' ? 'You may edit files in the workspace.' : 'This run is read-only: do not modify any files.',
562 'When you finish, write your answer for Claude: what you found or did, anything Claude should check, and open questions.',
563 'If you learned durable facts about the project, end your message with a line "### NOTES.md" followed by the complete updated NOTES.md (under 150 lines, keeping what is still true from the old one). Leave that section out if nothing durable changed.',
564 '',
565 '--- Task from Claude ---',
566 o.task,
567 ]
568 .filter(Boolean)
569 .join('\n')
570
571/** Splits Codex's final message into its answer for Claude and an updated NOTES.md, if it gave one. */
572const splitNotes = (text: string) => {
573 // The brief asks for the notes last, so the last such heading starts them; an earlier
574 // "## NOTES.md" (say, in a review of the notes) stays part of the answer.
575 const heads = [...text.matchAll(/^###\s*NOTES\.md\s*$/gm)]
576 const at = heads.length ? (heads[heads.length - 1].index ?? -1) : -1
577 if (at < 0) return { answer: text.trim(), notes: '' }
578 let notes = text.slice(at).replace(/^#{1,3}\s*NOTES\.md\s*\n?/, '').trim()
579 const fenced = notes.match(/^```[\w-]*\n([\s\S]*?)\n```$/)
580 if (fenced) notes = fenced[1].trim()
581 return { answer: text.slice(0, at).trim(), notes }
582}
583
584// ─── The tool, as the model reads it ────────────────────────────────────────
585
586// The model reads a tool description up to 2048 characters: the model list lives on the `model` parameter instead.
587const describe = () =>
588 [
589 "Hand a task to OpenAI Codex, a separate coding agent on the user's own Codex subscription, and get its result back: use it like a subagent. It reads the whole project and runs commands; in write mode it edits files.",
590 'Codex cannot see this conversation: write `task` as a complete, self-contained brief (goal, relevant files, constraints, what to report back).',
591 `Memory: Codex keeps notes per project in ${MEMORY_DIR}/ and reads them at the start of every run. For multi-step work give a short \`thread\` name: every call with that name continues the same Codex conversation. action "threads" lists a project's threads.`,
592 `Pick \`model\` and \`effort\` per task (the model parameter lists the choices): luna at low effort for lookups and mechanical edits, sol at medium/high for ordinary coding, astra at high+ for hard design, deep debugging or a critical review; max/ultra only for the hardest problems. Defaults: ${defaults.model || "the user's default"}, ${defaults.effort || 'default'} effort.`,
593 "mode 'read-only' (default) reads and runs read-only commands; 'write' may edit files inside the working directory only: review its changes (git diff) before building on them.",
594 "A run can take minutes. The result gives Codex's answer, the files it changed, the commands it ran, and how to continue.",
595 ].join('\n\n')
596
597const modelHelp = () =>
598 models.length
599 ? `Codex model. Choices: ${models.map(m => `${m.slug} (${(m.description || m.name).replace(/\.$/, '')}; efforts ${m.efforts.join('/')})`).join('; ')}.`
600 : "Codex model id; omit for the user's default."
601
602const schema = () => ({
603 type: 'object',
604 properties: {
605 action: {
606 type: 'string',
607 enum: ['run', 'threads', 'usage', 'rename_thread', 'reset_thread', 'archive_thread', 'restore_thread'],
608 description:
609 "'run' (default) gives Codex a task; 'threads' lists this project's threads; 'usage' shows how much of the Codex plan's limits are used; 'rename_thread' (thread, new_name), 'reset_thread' (thread: next call starts a fresh Codex conversation, log kept) and 'archive_thread' / 'restore_thread' (thread) manage threads.",
610 },
611 new_name: { type: 'string', description: "The thread's new name, for action 'rename_thread'." },
612 task: { type: 'string', description: 'The complete brief for Codex. It sees nothing else of this conversation.' },
613 thread: { type: 'string', description: 'A short name for a workstream. Calls with the same name continue the same Codex conversation and share its log.' },
614 new_thread: { type: 'boolean', description: "Start the named thread over: a fresh Codex conversation (its log and the project's notes are kept)." },
615 mode: { type: 'string', enum: ['read-only', 'write'], description: "'read-only' (default) or 'write' (may edit files in the working directory)." },
616 model: { type: 'string', ...(models.length ? { enum: models.map(m => m.slug) } : {}), description: modelHelp() },
617 effort: { type: 'string', enum: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], description: 'Reasoning effort; must be one the chosen model supports.' },
618 cwd: { type: 'string', description: "Absolute working directory for Codex; defaults to this session's." },
619 session_id: { type: 'string', description: 'A raw Codex session id to continue (threads do this for you).' },
620 timeout_minutes: { type: 'number', minimum: 1, maximum: 120, description: 'Stop Codex after this long (default 30).' },
621 },
622})
623
624// ─── Hooks ──────────────────────────────────────────────────────────────────
625
626export const register: Register = on => {
627 on('session.start', async ($, e, next) => {
628 await loadModels($)
629 cliFound = !!(await findCodex($).catch(() => undefined))
630 await $.tool.register({ name: TOOL, description: describe(), inputSchema: schema() })
631 await $.command.register({ name: 'codex', description: 'Open the Codex pane: what Codex is doing for Claude' })
632 // A reload ends whatever was in flight: say so rather than leave it spinning.
633 await update($, runs, list =>
634 list.map(r =>
635 r.status === 'running'
636 ? { ...r, status: 'stopped' as const, endedAt: Math.max(r.startedAt, ...(r.events ?? []).map(ev => ev.at || 0)), activity: 'Stopped' }
637 : { caller: 'main', root: r.cwd, activity: '', focus: '', touched: [], session: '', thread: '', resumed: false, notesUpdated: false, ...r },
638 ),
639 )
640 await update($, paneUi, () => ({ renaming: '', confirmDelete: '', showArchived: false }))
641 // The session's project and every project a recent run worked in show their threads.
642 const here = await projectRoot($, norm(await $.session.cwd().catch(() => ''))).catch(() => '')
643 sessionRoot = here
644 for (const root of new Set([here, ...(await read($, runs)).map(r => r.root)].filter(Boolean))) await loadThreads($, root)
645 void refreshUsage($).catch(() => undefined)
646 return next(e)
647 })
648
649 on('command.run', { command: 'codex' }, async $ => {
650 await $.ui.open({ id: PANE, title: 'Codex' })
651 return { text: 'Codex pane opened.' }
652 })
653
654 on('tool.call', { tool: `mcp__codex-bridge__${TOOL}` }, async ($, e, next) => {
655 const a = e as unknown as Record<string, unknown>
656 const asked = norm(str(a.cwd) || (await $.session.cwd()))
657 const root = await projectRoot($, asked)
658 // Windows paths ignore case: take the repository's own spelling, so Codex's paths and git's agree.
659 const cwd = asked.toLowerCase().startsWith(root.toLowerCase()) ? root + asked.slice(root.length) : asked
660 const registry = await readRegistry($, root)
661
662 if (a.action === 'threads') {
663 await loadThreads($, root)
664 const list = Object.entries(registry).sort((x, y) => y[1].updated - x[1].updated)
665 if (!list.length) return { result: `No Codex threads yet for ${root}. Pass \`thread\` with a task to start one.` }
666 return {
667 result: [
668 `Codex threads for ${root} (memory in ${MEMORY_DIR}/):`,
669 ...list.map(
670 ([, t]) =>
671 `- "${t.name}"${t.archived ? ' (archived)' : ''}: ${t.runs} run${t.runs === 1 ? '' : 's'}, last ${stamp(t.updated)}, ${t.model} / ${t.effort}${t.session ? '' : ', starts fresh next time'}. Last task: ${trunc(oneLine(t.last), 160)}`,
672 ),
673 ].join('\n'),
674 }
675 }
676 if (a.action === 'usage') {
677 await refreshUsage($)
678 const u = await read($, usage)
679 return { result: usageLine(u, Date.now()) ? `${usageLine(u, Date.now())} (as Codex recorded it at ${stamp(u.seenAt)}; plan: ${u.plan || 'unknown'})` : `No Codex usage numbers: ${u.missing}` }
680 }
681 if (a.action === 'rename_thread' || a.action === 'reset_thread' || a.action === 'archive_thread' || a.action === 'restore_thread') {
682 if (!str(a.thread).trim()) return { result: 'Name the thread (`thread`).' }
683 const key = keyFor(registry, str(a.thread))
684 const how = a.action === 'rename_thread' ? 'rename' : a.action === 'reset_thread' ? 'reset' : a.action === 'restore_thread' ? 'unarchive' : 'archive'
685 return { result: await threadAction($, root, key, how, str(a.new_name)) }
686 }
687
688 const task = str(a.task).trim()
689 if (!task) return { result: 'Codex was not run: `task` is empty.' }
690 const mode = a.mode === 'write' ? ('write' as const) : ('read-only' as const)
691 if (!models.length) await loadModels($)
692 const model = str(a.model)
693 const effort = str(a.effort)
694 // They go on Codex's command line: plain names only.
695 if ((model && !PLAIN_MODEL.test(model)) || (effort && !PLAIN_ID.test(effort)))
696 return { result: 'Codex was not run: model and effort must be plain names that start with a letter or digit.' }
697 const chosen = models.find(m => m.slug === (model || defaults.model))
698 if (model && models.length && !models.some(m => m.slug === model))
699 return { result: `Codex was not run: unknown model "${model}". Available: ${models.map(m => m.slug).join(', ')}.` }
700 if (effort && chosen && chosen.efforts.length && !chosen.efforts.includes(effort))
701 return { result: `Codex was not run: ${chosen.slug} does not support effort "${effort}". It supports: ${chosen.efforts.join(', ')}.` }
702 const thread = str(a.thread).trim()
703 // Two names can share a slug ("Foo Bar", "foo-bar", or any non-Latin name): the second gets its own key.
704 const key = keyFor(registry, thread)
705 const known = key ? registry[key] : undefined
706 // A session id goes on Codex's command line: plain ids only, from the caller or the registry.
707 const askedSession = str(a.session_id)
708 if (askedSession && !PLAIN_ID.test(askedSession)) return { result: 'Codex was not run: session_id must be a plain id that starts with a letter or digit.' }
709 const knownSession = known && a.new_thread !== true && PLAIN_ID.test(known.session) ? known.session : ''
710 const resumeId = askedSession || knownSession
711 // Until the run is in the list, a rename or delete of its thread waits for it too.
712 const startKey = `${root}::${key}`
713 if (key) starting.add(startKey)
714 const minutes = Math.min(120, Math.max(1, Number(a.timeout_minutes) || 30))
715 const caller = e.agentId ?? 'main'
716
717 const exe = await findCodex($)
718 if (!exe) {
719 starting.delete(startKey)
720 return {
721 result: 'Codex was not run: the Codex CLI was not found. Install the Codex desktop app or the CLI (npm i -g @openai/codex) and sign in with `codex login`.',
722 }
723 }
724 // The desktop app's scratch folders live under a redirected AppData path that Codex's
725 // Windows sandbox refuses to write into: say so plainly instead of running and failing.
726 if (mode === 'write' && /\/AppData\/(Roaming|Local)\/Claude\/scratch-workspaces\//i.test(cwd)) {
727 starting.delete(startKey)
728 return {
729 result:
730 "Codex was not run: write mode does not work in this chat's own scratch folder (Codex's sandbox cannot write under AppData). Use a real project folder: pass `cwd` with its absolute path, or open the chat in that folder. Read-only mode works anywhere.",
731 }
732 }
733
734 const id = `run-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e4)}`
735 const startedAt = Date.now()
736 const fresh: Run = {
737 id,
738 task,
739 mode,
740 cwd,
741 root,
742 model: model || defaults.model || 'default',
743 effort: effort || defaults.effort || 'default',
744 status: 'running',
745 startedAt,
746 endedAt: 0,
747 session: resumeId,
748 thread,
749 threadKey: key,
750 resumed: !!resumeId,
751 caller,
752 activity: 'Reading its notes',
753 focus: '',
754 touched: [],
755 events: [],
756 files: [],
757 commands: 0,
758 failedCommands: 0,
759 answer: '',
760 tokens: 0,
761 notesUpdated: false,
762 }
763 await update($, runs, list => [fresh, ...list].filter((r, i) => i < MAX_RUNS || r.status === 'running'))
764 starting.delete(startKey)
765 const running = (await read($, runs)).filter(r => r.status === 'running').length
766 $.ui.status(`Codex: ${running} running`)
767 try {
768
769 const patch = (fn: (r: Run) => Run) => update($, runs, list => list.map(r => (r.id === id ? fn(r) : r)))
770 const push = (ev: Omit<RunEvent, 'at'>) => patch(r => ({ ...r, events: [...r.events, { at: Date.now(), ...ev }].slice(-MAX_EVENTS) }))
771 const touch = (all: string[], kind: Touch['kind'], activity?: string) =>
772 patch(r => {
773 // Its own memory files are not project files.
774 const paths = all.filter(p => !p.includes(`/${MEMORY_DIR}/`))
775 if (!paths.length) return activity ? { ...r, activity } : r
776 const now = Date.now()
777 const keep = r.touched.filter(t => !paths.includes(t.path) || (t.kind === 'edit' && kind === 'look'))
778 const added = paths
779 .filter(p => !(kind === 'look' && r.touched.some(t => t.path === p && t.kind === 'edit')))
780 .map(p => ({ path: p, kind, at: now }))
781 return { ...r, touched: [...keep, ...added].slice(-MAX_TOUCHED), focus: paths[paths.length - 1], ...(activity ? { activity } : {}) }
782 })
783 const fileKinds = new Map<string, string>()
784
785 const handle = async (line: string) => {
786 let ev: Record<string, unknown>
787 try {
788 ev = JSON.parse(line)
789 } catch {
790 return
791 }
792 const item = (ev.item ?? {}) as Record<string, unknown>
793 const itemId = str(item.id)
794 switch (ev.type) {
795 case 'thread.started':
796 sawThread = true
797 await patch(r => ({ ...r, session: str(ev.thread_id) || r.session }))
798 return
799 case 'item.started':
800 if (item.type === 'command_execution') {
801 const cmd = bareCommand(str(item.command))
802 await push({ kind: 'cmd', text: cmd, ok: undefined, ref: itemId })
803 await touch(pathsIn(cmd).map(p => join(cwd, p)), 'look', `Running ${trunc(cmd, 48)}`)
804 }
805 if (item.type === 'reasoning') await patch(r => ({ ...r, activity: 'Thinking' }))
806 return
807 case 'item.completed':
808 switch (item.type) {
809 case 'agent_message': {
810 const text = str(item.text)
811 await patch(r => ({ ...r, answer: text, activity: 'Writing its answer' }))
812 await push({ kind: 'say', text: trunc(oneLine(splitNotes(text).answer || text), 220) })
813 return
814 }
815 case 'reasoning':
816 if (str(item.text)) await push({ kind: 'think', text: trunc(oneLine(str(item.text)), 200) })
817 await patch(r => ({ ...r, activity: 'Thinking' }))
818 return
819 case 'command_execution': {
820 const ok = item.exit_code === 0
821 await patch(r => {
822 const at = r.events.findIndex(x => x.ref === itemId)
823 const events =
824 at >= 0
825 ? r.events.map((x, i) => (i === at ? { ...x, ok } : x))
826 : [...r.events, { at: Date.now(), kind: 'cmd' as const, text: bareCommand(str(item.command)), ok }].slice(-MAX_EVENTS)
827 return { ...r, events, commands: r.commands + 1, failedCommands: r.failedCommands + (ok ? 0 : 1), activity: ok ? 'Thinking' : 'Looking at a failure' }
828 })
829 return
830 }
831 case 'file_change': {
832 const changes = Array.isArray(item.changes) ? (item.changes as Record<string, unknown>[]) : []
833 const inside = (p: string) => p === root || p.startsWith(`${root}/`)
834 const outside = changes.map(ch => join(cwd, str(ch.path))).filter(p => !inside(p))
835 if (outside.length) await push({ kind: 'info', text: `Codex reported a change outside the project, not listed: ${trunc(outside.join(', '), 160)}` })
836 for (const ch of changes) if (inside(join(cwd, str(ch.path)))) fileKinds.set(join(cwd, str(ch.path)), str(ch.kind) || 'update')
837 const paths = changes.map(ch => join(cwd, str(ch.path))).filter(inside)
838 await patch(r => ({ ...r, files: [...fileKinds.keys()] }))
839 await touch(paths, 'edit', paths.length ? `Editing ${baseName(paths[paths.length - 1])}` : undefined)
840 for (const ch of changes) await push({ kind: 'file', text: `${str(ch.kind) || 'update'} ${baseName(str(ch.path))}` })
841 return
842 }
843 case 'todo_list': {
844 const items = Array.isArray(item.items) ? (item.items as Record<string, unknown>[]) : []
845 const done = items.filter(t => t.completed === true).length
846 const doing = items.find(t => t.completed !== true)
847 await push({ kind: 'plan', text: `${done}/${items.length} steps${doing ? ` · next: ${trunc(str(doing.text), 80)}` : ''}` })
848 if (doing) await patch(r => ({ ...r, activity: trunc(str(doing.text), 60) }))
849 return
850 }
851 case 'mcp_tool_call':
852 await push({ kind: 'info', text: `tool ${str(item.server)}.${str(item.tool)}` })
853 return
854 case 'web_search':
855 await push({ kind: 'info', text: `web search: ${trunc(str(item.query), 100)}` })
856 await patch(r => ({ ...r, activity: 'Searching the web' }))
857 return
858 case 'error':
859 await push({ kind: 'error', text: trunc(oneLine(str(item.message)), 220) })
860 return
861 default:
862 return
863 }
864 case 'turn.completed': {
865 const u = (ev.usage ?? {}) as Record<string, unknown>
866 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
867 await patch(r => ({ ...r, tokens: r.tokens + num(u.input_tokens) + num(u.output_tokens) }))
868 return
869 }
870 case 'turn.failed':
871 case 'error': {
872 const err = (ev.error ?? ev) as Record<string, unknown>
873 await push({ kind: 'error', text: trunc(oneLine(str(err.message) || 'Codex reported an error'), 220) })
874 return
875 }
876 default:
877 return
878 }
879 }
880
881 // One Codex process: the brief on stdin, its event stream read as it comes.
882 let stopped = false
883 let sawThread = false
884 // Ends the Codex process now, even while it prints nothing: closing the stream kills the child.
885 let endChild: (() => void) | undefined
886 stops.set(id, () => {
887 stopped = true
888 endChild?.()
889 })
890 const deadline = startedAt + minutes * 60_000
891 const execute = async (resume: string) => {
892 if (stopped || next.signal.aborted) return { code: null as number | null, stderr: '', crashed: '', timedOut: false }
893 const flags = [
894 '--json',
895 '--skip-git-repo-check',
896 '-c',
897 `sandbox_mode="${mode === 'write' ? 'workspace-write' : 'read-only'}"`,
898 ...(model ? ['-m', model] : []),
899 ...(effort ? ['-c', `model_reasoning_effort="${effort}"`] : []),
900 ]
901 const argv = resume ? [...codexArgv(exe), 'exec', 'resume', ...flags, resume, '-'] : [...codexArgv(exe), 'exec', ...flags, '-']
902 const input = memoryBrief({ root, thread, key, resumed: !!resume, mode, task })
903 let buffer = ''
904 let stderr = ''
905 let code: number | null = null
906 let crashed = ''
907 let timedOut = false
908 const child = $.process.spawn({ argv, cwd, input })
909 endChild = () => void (child as unknown as { return?: () => Promise<unknown> }).return?.().catch(() => undefined)
910 const timer = $.clock.after(Math.max(1000, deadline - Date.now()), () => {
911 timedOut = true
912 endChild?.()
913 }) as unknown as { cancel?: () => void } | undefined
914 ;(next.signal as unknown as { addEventListener?: (t: string, f: () => void, o?: unknown) => void }).addEventListener?.('abort', () => endChild?.(), { once: true })
915 try {
916 for await (const piece of child) {
917 if (piece.stream === 'stdout') {
918 buffer += piece.text
919 // One event is never this long: a line with no end is dropped rather than held forever.
920 if (buffer.length > 4_000_000 && buffer.indexOf('\n') < 0) buffer = ''
921 let nl: number
922 while ((nl = buffer.indexOf('\n')) >= 0) {
923 const line = buffer.slice(0, nl).trim()
924 buffer = buffer.slice(nl + 1)
925 if (line) await handle(line)
926 }
927 } else stderr = (stderr + piece.text).slice(-8000)
928 if (Date.now() > deadline) timedOut = true
929 if (stopped || timedOut || next.signal.aborted) break
930 }
931 if (buffer.trim()) await handle(buffer.trim())
932 if (!stopped && !timedOut && !next.signal.aborted) code = (await child.result).code
933 } catch (err) {
934 crashed = err instanceof Error ? err.message : String(err)
935 } finally {
936 timer?.cancel?.()
937 endChild = undefined
938 }
939 return { code, stderr, crashed, timedOut }
940 }
941
942 await keepOutOfGit($, root)
943 const before = await snapshot($, root)
944 let outcome = await execute(resumeId)
945 // A thread whose Codex session is gone (cleared, other machine) starts over rather than failing.
946 // It "ran nothing" if all it said was errors or notes: no command, file, reasoning or answer.
947 const ranNothing = ((await read($, runs)).find(r => r.id === id)?.events ?? []).every(ev => ev.kind === 'error' || ev.kind === 'info')
948 let freshStart = a.new_thread === true
949 if (resumeId && !sawThread && outcome.code !== 0 && !outcome.crashed && !stopped && !outcome.timedOut && ranNothing) {
950 await push({ kind: 'info', text: 'the earlier Codex session could not be resumed: starting a fresh one' })
951 await patch(r => ({ ...r, resumed: false, session: '' }))
952 freshStart = true
953 outcome = await execute('')
954 }
955 stops.delete(id)
956
957 const after = before ? await snapshot($, root) : undefined
958 // A read-only run changes nothing: whatever git saw change meanwhile was someone else.
959 const viaGit = before && after && mode === 'write' ? changedBetween(before, after) : []
960 for (const t of viaGit) {
961 const abs = join(root, t.path)
962 if (!fileKinds.has(abs)) fileKinds.set(abs, t.isNew ? 'add' : 'update')
963 }
964 if (viaGit.length) {
965 await patch(r => ({ ...r, files: [...fileKinds.keys()] }))
966 await touch(viaGit.map(t => join(root, t.path)), 'edit')
967 for (const t of viaGit.slice(0, 30)) await push({ kind: 'file', text: `${t.isNew ? 'add' : 'update'} ${baseName(t.path)}${t.isNew ? '' : ` +${t.added} −${t.removed}`}` })
968 }
969 const counts = new Map(viaGit.map(t => [join(root, t.path), t]))
970
971 // Codex's notes and the thread's log.
972 const finalRun = (await read($, runs)).find(r => r.id === id) ?? fresh
973 const { answer, notes } = splitNotes(finalRun.answer)
974 if (notes.length > 20 && notes.length < 60_000) {
975 await $.fs.write(`${root}/${MEMORY_DIR}/NOTES.md`, `${notes}\n`).catch(() => undefined)
976 await patch(r => ({ ...r, notesUpdated: true }))
977 }
978 const endedAt = Date.now()
979 const status = stopped || outcome.timedOut || next.signal.aborted ? 'stopped' : outcome.code === 0 ? 'done' : 'failed'
980 const logPath = `${root}/${MEMORY_DIR}/threads/${key || 'one-off'}.md`
981 const asker = caller === 'main' ? 'Claude (main session)' : `a Claude subagent (${caller})`
982 const entry = [
983 '',
984 `## ${stamp(startedAt)} · ${fresh.model} / ${fresh.effort} · ${mode} · ${elapsed(endedAt - startedAt)} · ${status}`,
985 `**Asked by:** ${asker}`,
986 `**Task:** ${trunc(task, 1500)}`,
987 `**Result:** ${trunc(answer || '(no answer)', 3000)}`,
988 ...(fileKinds.size ? [`**Files:** ${[...fileKinds].map(([p, k]) => `${p.startsWith(`${root}/`) ? p.slice(root.length + 1) : p} (${k})`).join(', ')}`] : []),
989 '',
990 ].join('\n')
991 const session = (await read($, runs)).find(r => r.id === id)?.session ?? ''
992 // The log and the registry are read and written whole: one change at a time per project.
993 await withLock(root, async () => {
994 const oldLog = await $.fs.read(logPath).catch(() => (thread ? `# Codex thread: ${thread}\n` : '# Codex one-off runs\n'))
995 await $.fs.write(logPath, oldLog + entry).catch(() => undefined)
996 })
997 if (thread) await withLock(root, async () => {
998 const reg = await readRegistry($, root)
999 const prev = reg[key]
1000 reg[key] = {
1001 name: thread,
1002 // A fresh start keeps its own session, even none, rather than going back to the old one.
1003 session: freshStart ? session : session || prev?.session || '',
1004 created: prev?.created ?? startedAt,
1005 updated: endedAt,
1006 runs: (prev?.runs ?? 0) + 1,
1007 model: fresh.model,
1008 effort: fresh.effort,
1009 last: task,
1010 // Using an archived thread brings it back.
1011 archived: false,
1012 }
1013 await writeRegistry($, root, reg)
1014 })
1015 // The run moved the plan's usage: read it again for the pane and the result.
1016 await refreshUsage($).catch(() => undefined)
1017
1018 await patch(r => ({ ...r, status, endedAt, activity: status === 'done' ? 'Done' : status === 'failed' ? 'Failed' : 'Stopped' }))
1019 const left = (await read($, runs)).filter(r => r.status === 'running').length
1020 $.ui.status(left ? `Codex: ${left} running` : undefined)
1021 void $.ui.toast(`Codex ${status === 'done' ? 'finished' : status}${thread ? ` · ${thread}` : ''} · ${elapsed(endedAt - startedAt)}`)
1022
1023 // What Claude reads back.
1024 const run = (await read($, runs)).find(r => r.id === id) ?? fresh
1025 const how = [mode, run.model, run.effort !== 'default' ? `${run.effort} effort` : ''].filter(Boolean).join(', ')
1026 const word = status === 'done' ? 'finished' : status === 'failed' ? 'failed' : outcome.timedOut ? `was stopped after ${minutes} min` : 'was stopped'
1027 const out: string[] = [`Codex ${word} in ${elapsed(endedAt - startedAt)} (${how})${thread ? ` on thread "${thread}"${run.resumed ? ', continuing it' : ''}` : ''}.`, '']
1028 // Claude reads this whole: a very long answer is cut (the full text is in the run's card and the thread log).
1029 out.push(answer ? (answer.length > 12_000 ? `${answer.slice(0, 12_000)}\n… (answer cut at 12,000 characters)` : answer) : '(Codex gave no final message.)')
1030 if (fileKinds.size) {
1031 out.push('', 'Files Codex changed:')
1032 for (const [f, k] of [...fileKinds].slice(0, 60)) {
1033 const g = counts.get(f)
1034 out.push(`- ${f} (${k}${g && !g.isNew ? `, +${g.added} −${g.removed} vs HEAD` : ''})`)
1035 }
1036 if (fileKinds.size > 60) out.push(`- …and ${fileKinds.size - 60} more`)
1037 // Files found through git are whatever changed meanwhile, whoever changed them.
1038 const overlapped = (await read($, runs)).some(r => r.id !== id && r.root === root && r.startedAt < endedAt && (r.status === 'running' || r.endedAt > startedAt))
1039 if (overlapped && viaGit.length)
1040 out.push('(Another Codex run worked in this project at the same time: some of these may be its changes, or yours. Check with git diff.)')
1041 }
1042 if (run.commands) out.push('', `Commands Codex ran: ${run.commands}${run.failedCommands ? ` (${run.failedCommands} failed)` : ''}.`)
1043 if (/writable root|sandbox (cannot|could not|refused)|not permitted by the sandbox/i.test(answer))
1044 out.push(
1045 '',
1046 "Codex's sandbox refused to work in this folder. That happens in folders Windows redirects (anything under AppData) and on some network drives: run the task in a normal project folder, or use read-only mode.",
1047 )
1048 if (status === 'failed' || outcome.crashed) {
1049 const noise = /rmcp::transport|worker quit with fatal/
1050 const tail = outcome.stderr
1051 .split(/\r?\n/)
1052 .filter(l => l.trim() && !noise.test(l))
1053 .slice(-12)
1054 .join('\n')
1055 if (outcome.crashed) out.push('', `The Codex process could not run: ${outcome.crashed}`)
1056 if (tail) out.push('', 'Codex stderr (last lines):', tail)
1057 }
1058 out.push('')
1059 if (run.notesUpdated) out.push(`Codex updated its project notes: ${root}/${MEMORY_DIR}/NOTES.md`)
1060 out.push(
1061 thread
1062 ? `Continue this work by calling codex again with thread "${thread}". Its log: ${logPath}`
1063 : run.session
1064 ? `session_id: ${run.session} (pass it as session_id, or start a named thread, to continue this conversation)`
1065 : '',
1066 )
1067 if (mode === 'write' && fileKinds.size) out.push('Codex edited files directly: review the changes (e.g. git diff) before relying on them.')
1068 const plan = usageLine(await read($, usage), Date.now())
1069 if (plan) out.push(plan)
1070 if (mode === 'write' && !before) out.push('This folder is not a git repository, so files Codex changed through shell commands are not listed: check the folder yourself.')
1071 return { result: out.filter((l, i, all) => l || all[i - 1]).join('\n').trim() }
1072 } catch (err) {
1073 const why = err instanceof Error ? err.message : String(err)
1074 await update($, runs, list => list.map(r => (r.id === id && r.status === 'running' ? { ...r, status: 'failed' as const, endedAt: Date.now(), activity: 'Failed' } : r))).catch(() => undefined)
1075 return { result: `Codex failed inside the bridge: ${trunc(why, 300)}` }
1076 } finally {
1077 stops.delete(id)
1078 }
1079 })
1080
1081 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1082 const { Box, Text, Button, Input } = $.ui.resolve(e) as unknown as Record<string, (props: unknown) => unknown>
1083 const list = await read($, runs)
1084 const now = Date.now()
1085 const cols = Math.max(40, e.props?.bodyColumns ?? e.viewport?.columns ?? 80)
1086 const u = await read($, usage)
1087 const ui = await read($, paneUi)
1088 const allThreads = await read($, threads)
1089
1090 // Usage: each limit window as a bar, coloured by how close it is to full.
1091 const barW = Math.max(10, Math.min(30, cols - 52))
1092 const PLAN: Record<string, string> = { prolite: 'Pro Lite', pro: 'Pro', plus: 'Plus', team: 'Team', business: 'Business', enterprise: 'Enterprise', edu: 'Edu', free: 'Free' }
1093 const usageBox = (
1094 <Box key="usage" flexDirection="column" borderStyle="round" borderColor="#30363D" paddingX={1}>
1095 <Box flexDirection="row">
1096 <Text bold color="#10A37F">{`Codex plan${u.plan ? `: ${PLAN[u.plan] ?? u.plan}` : ''}`}</Text>
1097 <Box flexGrow={1} />
1098 {u.seenAt ? <Text dimColor>{`as of ${stamp(u.seenAt)} `}</Text> : null}
1099 <Button key="usage-refresh" plain onPress={() => void refreshUsage($).catch(() => undefined)}>
1100 {'↻ refresh'}
1101 </Button>
1102 </Box>
1103 {u.limits.length === 0 ? <Text dimColor wrap="wrap">{u.missing || 'Reading usage…'}</Text> : null}
1104 {u.limits.map(l => {
1105 const pct = Math.max(0, Math.min(100, l.usedPercent))
1106 const fill = Math.round((pct / 100) * barW)
1107 const tone = pct >= 90 ? '#F85149' : pct >= 70 ? '#E3B341' : '#3FB950'
1108 return (
1109 <Box flexDirection="row">
1110 <Text>{windowName(l.windowMinutes).padEnd(13)}</Text>
1111 <Text color={tone}>{'█'.repeat(fill)}</Text>
1112 <Text dimColor>{'░'.repeat(barW - fill)}</Text>
1113 <Text bold color={tone}>{` ${String(Math.round(pct)).padStart(3)}% used`}</Text>
1114 <Text dimColor wrap="truncate">{l.resetsAt ? ` · ${whenReset(l.resetsAt, now)}` : ''}</Text>
1115 </Box>
1116 )
1117 })}
1118 {list.length ? (
1119 <Text dimColor wrap="truncate">{`this session: ${list.length} run${list.length === 1 ? '' : 's'} · ${fmtK(list.reduce((n, r) => n + r.tokens, 0))} tokens`}</Text>
1120 ) : null}
1121 </Box>
1122 )
1123
1124 // Threads: per project, newest first, each with its controls.
1125 const roots = [...new Set([sessionRoot, ...Object.keys(allThreads)].filter(Boolean))].filter(r => r === sessionRoot || Object.keys(allThreads[r] ?? {}).length)
1126 const threadRow = (root: string, key: string) => {
1127 const t = allThreads[root]?.[key]
1128 if (!t) return null
1129 const id = `${root}::${key}`
1130 const busy = list.some(r => r.status === 'running' && r.root === root && (r.threadKey ?? slug(r.thread)) === key)
1131 return (
1132 <Box flexDirection="column">
1133 <Box flexDirection="row">
1134 <Text bold color={t.archived ? '#8B949E' : '#10A37F'}>{`${t.archived ? '▫' : '▪'} ${t.name}`}</Text>
1135 <Text dimColor wrap="truncate">{` ${t.runs} run${t.runs === 1 ? '' : 's'} · ${stamp(t.updated)} · ${t.model} / ${t.effort}${t.session ? '' : ' · starts fresh'}${busy ? ' · running now' : ''}`}</Text>
1136 </Box>
1137 <Text dimColor wrap="wrap">{` last: ${trunc(oneLine(t.last), Math.max(40, cols * 2))}`}</Text>
1138 {ui.renaming === id && Input ? (
1139 <Input
1140 key={`rename-input:${id}`}
1141 label=" new name "
1142 value={t.name}
1143 autoFocus
1144 submitLabel="rename"
1145 onSubmit={(v: string) => {
1146 void threadAction($, root, key, 'rename', v).then(msg => $.ui.toast(msg)).catch(() => undefined)
1147 void update($, paneUi, s => ({ ...s, renaming: '' }))
1148 }}
1149 />
1150 ) : null}
1151 <Box flexDirection="row" flexWrap="wrap">
1152 <Text>{' '}</Text>
1153 <Button key={`log:${id}`} plain onPress={() => void openInEditor($, `${root}/${MEMORY_DIR}/threads/${key}.md`, root, false)}>
1154 {'open log'}
1155 </Button>
1156 <Text dimColor>{' · '}</Text>
1157 <Button key={`rename:${id}`} plain onPress={() => void update($, paneUi, s => ({ ...s, renaming: s.renaming === id ? '' : id, confirmDelete: '' }))}>
1158 {ui.renaming === id ? 'cancel rename' : 'rename'}
1159 </Button>
1160 <Text dimColor>{' · '}</Text>
1161 <Button key={`reset:${id}`} plain onPress={() => void threadAction($, root, key, 'reset').then(msg => $.ui.toast(msg)).catch(() => undefined)}>
1162 {'fresh start'}
1163 </Button>
1164 <Text dimColor>{' · '}</Text>
1165 <Button key={`archive:${id}`} plain onPress={() => void threadAction($, root, key, t.archived ? 'unarchive' : 'archive').then(msg => $.ui.toast(msg)).catch(() => undefined)}>
1166 {t.archived ? 'restore' : 'archive'}
1167 </Button>
1168 <Text dimColor>{' · '}</Text>
1169 {ui.confirmDelete === id ? (
1170 <Button
1171 key={`delete-yes:${id}`}
1172 plain
1173 onPress={() => {
1174 void threadAction($, root, key, 'delete').then(msg => $.ui.toast(msg)).catch(() => undefined)
1175 void update($, paneUi, s => ({ ...s, confirmDelete: '' }))
1176 }}
1177 >
1178 {'⚠ press again to delete it and its log'}
1179 </Button>
1180 ) : (
1181 <Button key={`delete:${id}`} plain onPress={() => void update($, paneUi, s => ({ ...s, confirmDelete: id, renaming: '' }))}>
1182 {'delete'}
1183 </Button>
1184 )}
1185 </Box>
1186 </Box>
1187 )
1188 }
1189 const threadsBox = (
1190 <Box key="threads" flexDirection="column" borderStyle="round" borderColor="#30363D" paddingX={1}>
1191 <Box flexDirection="row">
1192 <Text bold color="#10A37F">{'Threads'}</Text>
1193 <Text dimColor wrap="truncate">{' resumable conversations'}</Text>
1194 <Box flexGrow={1} />
1195 <Button key="show-archived" plain onPress={() => void update($, paneUi, s => ({ ...s, showArchived: !s.showArchived }))}>
1196 {ui.showArchived ? 'hide archived' : 'show archived'}
1197 </Button>
1198 </Box>
1199 {roots.length === 0 ? <Text dimColor>{'no project yet'}</Text> : null}
1200 {roots.map(root => {types/index.d.ts 102 lines1export type RunStatus = 'running' | 'done' | 'failed' | 'stopped'
2
3/** One line of what a Codex run did, newest last. */
4export type RunEvent = {
5 at: number
6 kind: 'say' | 'think' | 'cmd' | 'file' | 'plan' | 'info' | 'error'
7 text: string
8 /** A command's outcome: undefined while it runs. */
9 ok?: boolean
10 /** The Codex item this line came from, so a command's line can be updated when it ends. */
11 ref?: string
12}
13
14/** A file a run read or ran something on ('look'), or changed ('edit'). Absolute, forward slashes. */
15export type Touch = { path: string; kind: 'look' | 'edit'; at: number }
16
17export type Run = {
18 id: string
19 task: string
20 mode: 'read-only' | 'write'
21 cwd: string
22 /** The project root the memory folder lives in (the git top level, else cwd). */
23 root: string
24 model: string
25 effort: string
26 status: RunStatus
27 startedAt: number
28 endedAt: number
29 /** Codex's own session id: what continues this conversation. */
30 session: string
31 /** The named workstream this run belongs to; '' for a one-off. */
32 thread: string
33 /** The thread's key in threads.json (its name as a slug, made unique). */
34 threadKey?: string
35 /** True when this run continued an earlier Codex session. */
36 resumed: boolean
37 /** Who asked: 'main' or a subagent's id (the loop that called the tool). */
38 caller: string
39 /** What Codex is doing right now, in a few words. */
40 activity: string
41 /** The file it is on right now, if any (absolute, forward slashes). */
42 focus: string
43 touched: Touch[]
44 events: RunEvent[]
45 files: string[]
46 commands: number
47 failedCommands: number
48 answer: string
49 tokens: number
50 /** True once Codex rewrote its NOTES.md during this run. */
51 notesUpdated: boolean
52}
53
54/** One rate-limit window of the Codex plan, as Codex last reported it. */
55export type Limit = {
56 /** Minutes the window spans: 300 is the 5-hour limit, 10080 the weekly one. */
57 windowMinutes: number
58 usedPercent: number
59 /** When the window resets, ms since the epoch (0 if not said). */
60 resetsAt: number
61}
62
63/** The Codex plan's usage, read from Codex's own session logs. */
64export type Usage = {
65 plan: string
66 limits: Limit[]
67 /** When Codex wrote these numbers (the log's time), and when they were read. */
68 seenAt: number
69 readAt: number
70 /** Why there are no numbers, when there are none. */
71 missing: string
72}
73
74/** A named Codex workstream in a project's .codex-bridge/threads.json. */
75export type ThreadInfo = {
76 name: string
77 session: string
78 created: number
79 updated: number
80 runs: number
81 model: string
82 effort: string
83 last: string
84 archived?: boolean
85}
86export type Registry = Record<string, ThreadInfo>
87
88/** The pane's own controls: which thread is being renamed, which delete awaits a second press. */
89export type PaneUi = { renaming: string; confirmDelete: string; showArchived: boolean }
90
91declare module 'claude-code' {
92 interface PluginState {
93 'codex-bridge': {
94 runs: Run[]
95 usage: Usage
96 /** Each project's thread registry, by project root. */
97 threads: Record<string, Registry>
98 ui: PaneUi
99 }
100 }
101}
102