SLOPSHOPPER

codex-bridge

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…

newpaneguardcommandtoaststatus
v1.2.2MITupdated 2026-10-19D3K1D/claude-crew-mods/codex-bridge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-bridge
│ ┃ Codex ✕ › fix the failing auth test and add an audit log call │ ┃ ◎ CODEX v1.2.2 │ 0 running · 0 recent runs │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ Codex plan↻ refresh │ ⎿ Read 6 lines │ ┃ │ Codex has not recorded any usage yet: it │ ⏺ Update(src/auth.ts) │ ┃ │ appears after its first run. │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ No Codex runs yet │ │ ┃ │ The Codex CLI was not found. Install the │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Codex app, or the CLI with `npm i -g │ │ ┃ │ @openai/codex`, then sign in with `codex │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ login` and restart Claude. │ │ ┃ │ Ask Claude to hand work to Codex, for │ › /codex │ ┃ │ example "have Codex review the renderer" │ ⎿ codex-bridge: Codex pane opened. │ ┃ │ or "give Codex a thread called │ │ ┃ │ chunk-loader and make chunk loading │ │ ┃ │ async". Claude picks the model and │ │ ┃ │ effort, Codex works in your project, and │ │ ┃ │ the result comes back here and to │ │ ┃ │ Claude. │ │ ┃ │ Codex keeps notes per project in │ │ ┃ │ .codex-bridge/ (NOTES.md plus a log per │ │ ┃ │ thread), so a named thread can be picked │ │ ┃ │ up again later. Runs use your Codex │ │ ┃ │ subscription, not Claude's. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Codex
◎ CODEX v1.2.2 │ 0 running · 0 recent runs ╭──────────────────────────────────────────────────────────╮ │ Codex plan↻ refresh │ │ Codex has not recorded any usage yet: it appears after │ │ its first run. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ No Codex runs yet │ │ The Codex CLI was not found. Install the Codex app, or │ │ the CLI with `npm i -g @openai/codex`, then sign in with │ │ `codex login` and restart Claude. │ │ Ask Claude to hand work to Codex, for example "have │ │ Codex review the renderer" or "give Codex a thread │ │ called chunk-loader and make chunk loading async". │ │ Claude picks the model and effort, Codex works in your │ │ project, and the result comes back here and to Claude. │ │ Codex keeps notes per project in .codex-bridge/ │ │ (NOTES.md plus a log per thread), so a named thread can │ │ be picked up again later. Runs use your Codex │ │ subscription, not Claude's. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Threads resumable conversationsshow archived │ │ app │ │ no threads: ask Claude to give Codex a task "on a │ │ thread called …" to start one │ ╰──────────────────────────────────────────────────────────╯
README

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

How to use it

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:

  • the fast model at low effort, for lookups and mechanical edits
  • the workhorse at medium or high, for ordinary coding
  • the frontier model at high or above, for hard design work or a critical review

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.

Threads

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:

  • open log: opens the thread's log in your editor.
  • rename: type a new name. The log file moves with it.
  • fresh start: the next run starts a new Codex conversation, keeping the log and notes.
  • archive / restore: hides the thread until it's used again. show archived lists hidden threads.
  • delete: needs a second press. It removes the thread and its log; the project's NOTES.md stays.

Claude can manage threads too: the tool has threads, rename_thread, reset_thread and archive_thread actions.

Usage

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.

The pane

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.

Limits

  • On Windows, write mode doesn't work in folders under AppData, including a chat's own scratch folder. The tool says so and asks for a normal project folder.
  • Stop takes effect at Codex's next event, so a long-running command finishes first.
  • Outside a git repository, files Codex changes through shell commands can't be listed.
  • The usage bars show what Codex last wrote down, not live numbers.

Where it looks for Codex

  • Windows: the newest CLI inside the Codex desktop app (%LOCALAPPDATA%\OpenAI\Codex\bin\…), then where codex (npm's codex.cmd works too).
  • macOS: Codex.app, Homebrew, /usr/local/bin, ~/.local/bin, then your login shell's PATH.
  • Linux: /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.

Source 2 files
hooks/register.tsx 1311 lines
1import { 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 lines
1export 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