SLOPSHOPPER

codex-pane

Follow your Codex CLI session live in a Claude Code pane; relay the current exchange to Claude, or Claude's answer to Codex, one keypress and one confirmation…

newpanecommandtoastprocesstimer
v1.1.0MITupdated 2026-10-07ManuelWarland/claude-codex-pane/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-pane
│ ┃ Codex ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Codex Pane │ ┃ cannot read the session: Python 3 not fou… ⏺ Read(src/auth.ts) │ ┃ reading the Codex session… ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /codex │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Codex
◆ Codex Pane cannot read the session: Python 3 not found (tried python… reading the Codex session…
README

claude-codex-pane

Français | English

A side pane in Claude Code that follows your Codex CLI session live. On request, it sends the current exchange to Claude, and it queues Claude's answer in Codex only after you confirm.

The Codex pane on the right of Claude Code: Codex's exchange relayed to Claude, and Claude's review with a codex block

You run Codex in one terminal and Claude Code in another. Instead of copying answers from one window to the other, you open /codex in Claude Code:

  • the pane shows what Codex is doing right now: working or idle, the last command it ran or file it changed, and the latest messages;
  • Send to Claude puts the current exchange (your last message to Codex and every reply since) into Claude's conversation;
  • Send to Codex queues Claude's latest answer into your Codex session, after you confirm. Only the `codex blocks Claude wrote go, so the instructions meant for you stay out. An answer without such a block sends nothing.

Nothing is relayed automatically. The two agents never talk to each other behind your back.

You confirm every send to CodexCodex receives Claude's message and acts on it
The confirmation question before sending to CodexThe Codex terminal receiving the message relayed from Claude

Why another Claude/Codex bridge?

There are many good projects that connect the two, most of them built so that one agent drives the other: Claude delegates a task to Codex, or the reverse. This one starts from the opposite idea: you drive both agents, each in its own terminal, and the pane is only a window and a relay. If you want automatic delegation, look at careless10/claude-codex-bridge, codex-flow or OpenAI's codex-plugin-cc.

Requirements

  • Claude Code 2.1.287 or later (mods support).
  • Codex CLI with the codex queue command (check with codex queue --help; tested with 0.160.0). codex must be on the PATH of the terminal you start Claude Code from, or Send to Codex fails. If you set CODEX_HOME, set it there too.
  • Python 3.8 or later, reachable as python3, python or py.

Install

In Claude Code:

/plugin marketplace add ManuelWarland/claude-codex-pane
/plugin install codex-pane@codex-pane

Then type /codex to open the pane.

Use

KeyButtonWhat it does
cSend to ClaudeSends the current exchange with Codex to Claude. If Claude is busy, it goes as soon as Claude is free.
xSend to CodexSends the `codex blocks of Claude's latest answer to the Codex session shown, through codex queue. You confirm first. If Codex is working, the message waits for the end of its task.
sSwitch sessionFollow the most recently active open Codex session (automatic), or pin one of the open sessions (up to 3, shown with their folder). Closed sessions are not offered.
rRefreshReads the session again (it is read every 5 seconds anyway).

/codex-to-claude does the same as Send to Claude from the prompt. A notification tells you when Codex finishes a task, even with the pane closed.

To make Claude address Codex directly, ask it to put the part meant for Codex in a `codex block.

Options

In /config, under codex-pane:

  • Language: auto (follows your Claude Code language setting), en or fr.
  • Your name: how relayed messages refer to you. Empty means "the user".

The other direction, from Codex

The pane already sends Claude's answers to Codex. If you would rather have Codex fetch them itself, copy codex/read_claude.py somewhere and add this to your ~/.codex/AGENTS.md:

When I say "read Claude", run `python /path/to/read_claude.py` and use its output:
it prints Claude Code's latest answer.

Privacy

The plugin itself opens no network connection and sends nothing anywhere. It reads Codex's session files ($CODEX_HOME/sessions, by default ~/.codex/sessions), and read_claude.py reads Claude Code's transcripts (~/.claude/projects), both read only. The only write is codex queue, when you confirm a send. To survive a reload of the plugin, the pane also keeps the `codex blocks of Claude's latest answer in Claude Code's session state: only those blocks, never the rest of the answer, and nothing when the answer has none.

What you relay does leave your machine, like anything you type to an agent: an exchange sent to Claude goes to Anthropic, an answer sent to Codex goes to OpenAI.

Limits

  • Tested on Windows 11 only so far. macOS and Linux should work (only Python and standard paths are used); reports are welcome.
  • Only Codex sessions opened in a terminal are followed, with or without the shared background app server: subagents, codex exec runs, the Codex app and editor integrations are left out.
  • Claude's latest answer is everything Claude wrote during its last turn, including the text written before a tool call. Its `codex blocks survive a reload of the plugin (see Privacy).
  • Codex's session files are not a documented format. A Codex update can break the reading; plugin/hooks/read_codex.py is the one file to fix.

Development

claude plugin validate plugin
claude plugin test plugin
python -m unittest discover -s plugin/tests

License

MIT, by Manuel Warland, written with Claude Code.

Source 2 files
hooks/codex-pane.mjs 706 lines
1// codex-pane: a bridge between your Codex CLI session and Claude Code, without copy-paste.
2//
3// - /codex opens or closes a pane that follows the most recent Codex session live:
4//   state (working / idle), latest command or file changed, the latest messages,
5//   and the current exchange.
6// - "Send to Claude" (or /codex-to-claude) relays the current exchange as is: your
7//   last message to Codex and every Codex reply since, saying whether Codex is still
8//   working. (Not the last "final" answer: a long task can run for many minutes and
9//   that answer would be stale.)
10// - "Send to Codex" sends Claude's latest answer to the session shown in the pane,
11//   through `codex queue`, after you confirm. Only its ```codex blocks go; an
12//   answer without one sends nothing. The answer is every text of Claude's last turn (a block written
13//   before a tool call counts), kept across reloads of the plugin.
14// - "Switch session" follows the most recent session (automatic) or one you pick.
15// - A toast says when Codex finishes a task.
16//
17// Reading is done by hooks/read_codex.py ($CODEX_HOME/sessions/.../rollout-*.jsonl).
18// The pane never writes into a Codex session by itself: you approve every relay,
19// in both directions. There is no automatic dialogue between the two AIs.
20
21const PANE = 'codex-pane'
22const POLL_OPEN_MS = 5 * 1000
23const POLL_CLOSED_MS = 20 * 1000
24const MAX_MESSAGE_CHARS = 4000
25const HISTORY_MESSAGES = 20
26const WHEEL_STEP = 3 // lines per wheel notch
27const MAX_TO_CODEX_CHARS = 20000
28const PYTHONS = ['python3', 'python', 'py']
29// Kept by the host across reloads of the plugin (see types/index.d.ts).
30const LAST_ANSWER = { plugin: 'codex-pane', key: 'lastAnswer' }
31const SENT_TO_CODEX_AT = { plugin: 'codex-pane', key: 'sentToCodexAt' }
32
33// ---- Strings -----------------------------------------------------------------
34
35const STRINGS = {
36  en: {
37    title: '◆ Codex Pane',
38    you: 'You',
39    someone: 'the user',
40    reading: 'reading the Codex session…',
41    session: (name, pinned) => 'session ' + name + (pinned ? ' · pinned' : ' · automatic'),
42    closed: ' · closed',
43    busy: '⏳ Codex is working',
44    idle: '✔ Codex is idle',
45    kind: { command: 'command', file: 'file' },
46    exchange: (since, n, done, sent) =>
47      'Current exchange (since ' + since + ', ' + n + ' repl' + (n === 1 ? 'y' : 'ies') + ')' +
48      (done ? ' · finished' : ' · Codex is working') + (sent ? ' · ✔ sent' : ''),
49    latest: 'Latest messages',
50    scrolled: (n) => ' · scrolled up ' + n + ' lines (wheel down to return)',
51    keys: { toClaude: 'c', toCodex: 'x', session: 's', refresh: 'r' },
52    toClaude: 'Send to Claude',
53    toClaudeBusy: 'Sending…',
54    toCodex: 'Send to Codex',
55    toCodexBusy: 'Sending to Codex…',
56    switchSession: 'Switch session',
57    refresh: 'Refresh',
58    escClose: 'Esc: close',
59    errors: {
60      no_root: (d) => 'Codex sessions folder not found: ' + d,
61      no_file: (d) => 'chosen session not found: ' + d,
62      no_session: () => 'no Codex terminal session found',
63    },
64    readFailed: (m) => 'cannot read the session: ' + m,
65    noPython: 'Python 3 not found (tried python3, python, py)',
66    toastDone: 'Codex finished a task · /codex-to-claude to send it to me',
67    nothingToSend: 'No exchange with Codex to send.',
68    alreadySending: 'Already sending.',
69    alreadySent: 'This exchange with Codex was already sent (nothing new since).',
70    scheduled: 'Sending to Claude as soon as Claude is free',
71    sent: 'Exchange with Codex sent to Claude',
72    sendFailed: 'Could not send to Claude',
73    relayHead: (who, done) =>
74      'Exchange with Codex, relayed by ' + who + ' from the codex-pane pane (full text). ' +
75      (done ? 'Codex has finished its task.' : 'Codex is still working: these are only its messages so far.'),
76    relayUser: (Who, at) => '### ' + Who + ' to Codex (' + at + ')',
77    relayCodex: (at) => '### Codex (' + at + ')',
78    relayClaude: (at) => '### Claude to Codex, through the pane (' + at + ')',
79    noReplyYet: '(no reply yet)',
80    listFailed: 'Cannot list the Codex sessions',
81    noUserMessage: '(no message)',
82    auto: 'Automatic: the most recently active open session',
83    askSession: 'Which open Codex session should the pane follow?',
84    askSessionHeader: 'Session',
85    pinnedTo: (label) => 'Pane pinned to the session ' + label,
86    autoMode: 'Pane in automatic mode',
87    sessionLabel: (date, time) => date + ' ' + time,
88    alreadyToCodex: 'Already sending to Codex',
89    noThread: 'No Codex session to send to',
90    noAnswer: 'No answer from Claude to send since the pane was loaded',
91    answerAlreadySent: 'This answer from Claude was already sent to Codex',
92    noCodexBlock: "No ```codex block in Claude's latest answer: nothing sent",
93    askToCodex: (session, busy, partial, chars, excerpt) =>
94      'Send to Codex (session ' + session + (busy ? ', working: the message will wait for the end of its task' : ', idle') + ') ' +
95      (partial ? 'the ```codex blocks of Claude\'s latest answer' : 'Claude\'s whole latest answer (it has no ```codex block)') +
96      ', ' + chars + ' characters: "' + excerpt + '"?',
97    askToCodexHeader: 'To Codex',
98    send: 'Send',
99    cancel: 'Cancel',
100    cancelled: 'Sending to Codex cancelled',
101    sentToCodex: 'Claude\'s answer sent to Codex',
102    queueFailed: (detail) => 'codex queue failed: ' + detail,
103    codexPathHint: ' (is `codex` on the PATH Claude Code was started with?)',
104    toCodexHead: (who, partial) =>
105      'Message from Claude Code, relayed by ' + who + ' from the codex-pane pane (' +
106      (partial ? 'the part of its answer written for you' : 'its whole answer') + '):',
107    cmdPane: 'Open or close the Codex session pane',
108    cmdToClaude: 'Send Claude the current exchange with Codex (your last message and its replies)',
109  },
110  fr: {
111    title: '◆ Pont Codex',
112    you: 'Toi',
113    someone: "l'utilisateur",
114    reading: 'lecture de la session Codex…',
115    session: (name, pinned) => 'session ' + name + (pinned ? ' · fixée' : ' · automatique'),
116    closed: ' · fermée',
117    busy: '⏳ Codex travaille',
118    idle: '✔ Codex au repos',
119    kind: { command: 'commande', file: 'fichier' },
120    exchange: (since, n, done, sent) =>
121      'Échange en cours (depuis ' + since + ', ' + n + ' réponse' + (n > 1 ? 's' : '') + ')' +
122      (done ? ' · terminé' : ' · Codex travaille') + (sent ? ' · ✔ transmis' : ''),
123    latest: 'Derniers échanges',
124    scrolled: (n) => ' · remonté de ' + n + ' lignes (molette vers le bas pour revenir)',
125    keys: { toClaude: 't', toCodex: 'e', session: 'c', refresh: 'r' },
126    toClaude: 'Transmettre à Claude',
127    toClaudeBusy: 'Envoi…',
128    toCodex: 'Envoyer à Codex',
129    toCodexBusy: 'Envoi à Codex…',
130    switchSession: 'Changer de session',
131    refresh: 'Rafraîchir',
132    escClose: 'Échap : fermer',
133    errors: {
134      no_root: (d) => 'dossier des sessions Codex introuvable : ' + d,
135      no_file: (d) => 'session choisie introuvable : ' + d,
136      no_session: () => 'aucune session Codex de terminal trouvée',
137    },
138    readFailed: (m) => 'lecture impossible : ' + m,
139    noPython: 'Python 3 introuvable (essayé : python3, python, py)',
140    toastDone: 'Codex a terminé une tâche · /codex-to-claude pour me l’envoyer',
141    nothingToSend: 'Aucun échange avec Codex à transmettre.',
142    alreadySending: 'Transmission déjà en cours.',
143    alreadySent: 'Cet échange avec Codex a déjà été transmis (rien de nouveau depuis).',
144    scheduled: 'Envoi à Claude programmé (il part dès que Claude est libre)',
145    sent: 'Échange avec Codex transmis à Claude',
146    sendFailed: 'Échec de la transmission à Claude',
147    relayHead: (who, done) =>
148      'Échange avec Codex transmis par ' + who + ' depuis le panneau codex-pane (textes intégraux). ' +
149      (done ? 'Codex a terminé sa tâche.' : 'Codex travaille encore : ce ne sont que ses messages en cours.'),
150    relayUser: (Who, at) => '### ' + Who + ' à Codex (' + at + ')',
151    relayCodex: (at) => '### Codex (' + at + ')',
152    relayClaude: (at) => '### Claude à Codex, via le panneau (' + at + ')',
153    noReplyYet: '(pas encore de réponse)',
154    listFailed: 'Impossible de lister les sessions Codex',
155    noUserMessage: '(pas de message)',
156    auto: 'Automatique : la session ouverte la plus récemment active',
157    askSession: 'Quelle session Codex ouverte suivre dans le panneau ?',
158    askSessionHeader: 'Session',
159    pinnedTo: (label) => 'Panneau fixé sur la session ' + label,
160    autoMode: 'Panneau en mode automatique',
161    sessionLabel: (date, time) => date.split('-').slice(1).reverse().join('/') + ' ' + time.replace(':', 'h'),
162    alreadyToCodex: 'Envoi à Codex déjà en cours',
163    noThread: 'Aucune session Codex à qui envoyer',
164    noAnswer: 'Aucune réponse de Claude à envoyer depuis le chargement du panneau',
165    answerAlreadySent: 'Cette réponse de Claude a déjà été envoyée à Codex',
166    noCodexBlock: "Aucun bloc ```codex dans la dernière réponse de Claude : rien n'est envoyé",
167    askToCodex: (session, busy, partial, chars, excerpt) =>
168      'Envoyer à Codex (session ' + session + (busy ? ', il travaille : le message attendra sa fin' : ', au repos') + ') ' +
169      (partial ? 'les blocs ```codex de la dernière réponse de Claude' : 'la dernière réponse de Claude entière (aucun bloc ```codex)') +
170      ', ' + chars + ' caractères : « ' + excerpt + ' » ?',
171    askToCodexHeader: 'Vers Codex',
172    send: 'Envoyer',
173    cancel: 'Annuler',
174    cancelled: 'Envoi à Codex annulé',
175    sentToCodex: 'Réponse de Claude envoyée à Codex',
176    queueFailed: (detail) => 'échec de codex queue : ' + detail,
177    codexPathHint: ' (la commande `codex` est-elle dans le PATH de Claude Code ?)',
178    toCodexHead: (who, partial) =>
179      'Message de Claude Code, transmis par ' + who + ' depuis le panneau codex-pane (' +
180      (partial ? 'partie de sa réponse écrite pour toi' : 'sa réponse entière') + ') :',
181    cmdPane: 'Ouvrir ou fermer le panneau de la session Codex',
182    cmdToClaude: "Envoyer à Claude l'échange en cours avec Codex (ton dernier message et ses réponses)",
183  },
184}
185
186// ---- Pure functions ------------------------------------------------------------
187
188// Language of the pane: the `language` option, or with `auto` the Claude Code
189// `language` setting ("Français", "french", "fr-BE"…). English otherwise.
190export function pickLanguage(option, setting) {
191  if (option === 'en' || option === 'fr') return option
192  return /^\s*(fr|fran)/i.test(String(setting ?? '')) ? 'fr' : 'en'
193}
194
195// Everything the texts depend on: the strings and how to name the user.
196export function makeLocale(lang, userName) {
197  const s = STRINGS[lang] || STRINGS.en
198  const name = String(userName ?? '').trim()
199  const who = name || s.someone
200  return { lang: STRINGS[lang] ? lang : 'en', s, who, Who: who.charAt(0).toUpperCase() + who.slice(1) }
201}
202
203export function clip(text, max) {
204  const t = String(text ?? '').trim()
205  return t.length > max ? t.slice(0, max) + '…' : t
206}
207
208// Local HH:MM of a message: computed by read_codex.py, which knows the machine's
209// time zone; the UTC time as a fallback.
210export function hm(m) {
211  if (m && m.hm) return m.hm
212  return String((m && m.ts) ?? '').slice(11, 16)
213}
214
215// A message the pane itself queued into Codex on Claude's behalf (see
216// toCodexMessage), in either language.
217export function isFromClaude(text) {
218  return /^(Message from Claude Code|Message de Claude Code)\b/.test(String(text ?? ''))
219}
220
221export function transmission(exchange, L) {
222  const head = isFromClaude(exchange.user.text) ? L.s.relayClaude(hm(exchange.user)) : L.s.relayUser(L.Who, hm(exchange.user))
223  const parts = [L.s.relayHead(L.who, exchange.done), '', head, exchange.user.text]
224  if (exchange.codex.length === 0) parts.push('', '### Codex', L.s.noReplyYet)
225  for (const reply of exchange.codex) parts.push('', L.s.relayCodex(hm(reply)), reply.text)
226  return parts.join('\n')
227}
228
229// Splits a text into lines of at most `width` characters (whole words when
230// possible). The pane draws one line per element: that is what keeps the header
231// fixed and the messages anchored at the bottom.
232export function wrapLines(text, width) {
233  const w = Math.max(10, width)
234  const out = []
235  for (const paragraph of String(text ?? '').split('\n')) {
236    let line = ''
237    for (const word of paragraph.split(' ')) {
238      let rest = word
239      while (rest.length > w) {
240        if (line) {
241          out.push(line)
242          line = ''
243        }
244        out.push(rest.slice(0, w))
245        rest = rest.slice(w)
246      }
247      if (!line) line = rest
248      else if (line.length + 1 + rest.length <= w) line += ' ' + rest
249      else {
250        out.push(line)
251        line = rest
252      }
253    }
254    out.push(line)
255  }
256  return out
257}
258
259// Lines of all messages, oldest first, separated by an empty line.
260export function messageLines(messages, width, L) {
261  const lines = []
262  for (const m of messages) {
263    const who = m.role !== 'user' ? 'Codex' : isFromClaude(m.text) ? 'Claude' : L.s.you
264    const text = clip(String(m.text ?? '').replace(/\n{2,}/g, '\n'), MAX_MESSAGE_CHARS)
265    if (lines.length) lines.push({ text: '', color: undefined })
266    for (const t of wrapLines(hm(m) + ' ' + who + ' : ' + text, width)) {
267      lines.push({ text: t, color: m.role === 'user' ? 'cyan' : undefined })
268    }
269  }
270  return lines
271}
272
273// Window of `avail` lines, anchored at the bottom, scrolled up by `back` lines.
274export function windowOf(lines, avail, back) {
275  const maxBack = Math.max(0, lines.length - avail)
276  const b = Math.min(Math.max(0, back), maxBack)
277  const end = lines.length - b
278  return { rows: lines.slice(Math.max(0, end - avail), end), back: b, maxBack }
279}
280
281// Last folder of a working directory, Windows or POSIX ("D:\work\my-app" → "my-app").
282export function folderName(cwd) {
283  const parts = String(cwd ?? '').split(/[\\/]+/).filter(Boolean)
284  return parts.length ? parts[parts.length - 1] : ''
285}
286
287// "my-app · 05/10 12h55": the session's folder and start time (session file
288// names start with the local date and time: 2026-10-05T12-55-34-<id>).
289export function sessionName(s, L) {
290  const date = s.session.slice(0, 10)
291  const time = s.session.slice(11, 16).replace('-', ':')
292  const folder = folderName(s.cwd)
293  return (folder ? folder + ' · ' : '') + L.s.sessionLabel(date, time)
294}
295
296export function sessionLabel(s, L) {
297  const excerpt = clip(String(s.last_user || L.s.noUserMessage).replace(/\s+/g, ' '), 40)
298  return sessionName(s, L) + ' · ' + excerpt
299}
300
301// The part of Claude's answer meant for Codex: the code blocks labelled `codex`
302// (```codex … ```, or ````codex for a block that itself holds code), joined.
303// Without such a block, `partial` is false and "Send to Codex" sends nothing:
304// that answer is meant for the user.
305export function extractForCodex(answer) {
306  const text = String(answer ?? '').replace(/\r\n?/g, '\n')
307  const blocks = []
308  // A fence may be indented by up to 3 spaces, as in Markdown.
309  const re = /^ {0,3}(`{3,})codex[^\n]*\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm
310  let m
311  while ((m = re.exec(text)) !== null) {
312    if (m[2].trim()) blocks.push(m[2].trim())
313  }
314  return blocks.length ? { text: blocks.join('\n\n'), partial: true } : { text: text.trim(), partial: false }
315}
316
317// Every text Claude wrote during the last turn, oldest first. `turn.complete`
318// only carries the final text: a ```codex block written before a tool call
319// would be lost. The turn starts after the last user message that is a prompt
320// (a user message holding tool results belongs to the turn, even with text: a
321// message typed while Claude works rides along with the tool results). Without
322// such a prompt in sight (a compacted conversation), only the final answer
323// counts, so that an older turn never leaks in. The final answer is added if
324// the transcript does not hold it yet.
325export function turnText(messages, finalAnswer) {
326  const texts = []
327  const list = Array.isArray(messages) ? messages : []
328  let bounded = false
329  for (let i = list.length - 1; i >= 0; i--) {
330    const m = list[i]
331    if (!m) continue
332    if (m.role === 'user') {
333      if (m.toolResults && m.toolResults.length) continue
334      bounded = true
335      break
336    }
337    if (m.role === 'assistant' && String(m.text ?? '').trim()) texts.unshift(String(m.text).trim())
338  }
339  const final = String(finalAnswer ?? '').trim()
340  if (!bounded) return final
341  if (final && texts[texts.length - 1] !== final) texts.push(final)
342  return texts.join('\n\n')
343}
344
345export function toCodexMessage(answer, partial, L) {
346  return L.s.toCodexHead(L.who, partial) + '\n\n' + clip(answer, MAX_TO_CODEX_CHARS)
347}
348
349// ---- State -------------------------------------------------------------------
350
351const state = {
352  L: makeLocale('en', ''),
353  data: null, // latest read_codex.py output
354  error: '',
355  open: false,
356  lastPollMs: 0,
357  seenFinal: null, // turn_id of the latest final answer already seen
358  sentKey: null, // last_ts of the exchange already sent to Claude
359  sending: false,
360  back: 0, // lines scrolled up from the bottom (0 = follows the last message)
361  maxBack: 0, // limit computed at the last drawing
362  pinned: null, // file of the session the user picked; null = the most recent
363  lastAnswer: null, // the ```codex blocks of Claude's latest answer (main thread): { codex, at }; codex is '' without a block
364  sentToCodexAt: null, // `at` of the latest answer already sent to Codex
365  sendingToCodex: false,
366  python: null, // the Python command that worked
367}
368
369// ---- Reading -----------------------------------------------------------------
370
371async function readCodex($, args) {
372  const script = $.plugin.root + '/hooks/read_codex.py'
373  const candidates = state.python ? [state.python] : PYTHONS
374  for (const python of candidates) {
375    try {
376      const run = await $.process.run([python, script, ...args], { timeoutMs: 20000 })
377      if (run.exitCode !== 0) continue
378      const data = JSON.parse(run.stdout.trim())
379      state.python = python
380      return data
381    } catch {
382      // not installed, or not Python 3: try the next one
383    }
384  }
385  state.python = null
386  throw new Error(state.L.s.noPython)
387}
388
389async function poll($) {
390  const s = state.L.s
391  try {
392    const args = ['--limit', String(HISTORY_MESSAGES)]
393    if (state.pinned) args.push('--file', state.pinned)
394    const data = await readCodex($, args)
395    if (data.error) {
396      const describe = s.errors[data.error]
397      state.error = describe ? describe(data.detail) : String(data.error)
398      state.data = null
399    } else {
400      state.error = ''
401      const previous = state.data
402      state.data = data
403      const final = data.final
404      if (final && previous && final.turn_id !== state.seenFinal) $.ui.toast(s.toastDone)
405      if (final) state.seenFinal = final.turn_id
406    }
407  } catch (error) {
408    state.error = s.readFailed(String(error && error.message ? error.message : error).slice(0, 120))
409  }
410  state.lastPollMs = await $.clock.now()
411  $.ui.invalidate('ui.render')
412}
413
414// ---- To Claude ---------------------------------------------------------------
415
416async function sendExchange($) {
417  const s = state.L.s
418  const exchange = state.data && state.data.exchange
419  if (!exchange) return s.nothingToSend
420  if (state.sending) return s.alreadySending
421  if (state.sentKey === exchange.last_ts) return s.alreadySent
422  state.sending = true
423  state.sentKey = exchange.last_ts
424  $.ui.invalidate('ui.render')
425  // $.prompt.submit resolves once Claude is free. A call left pending at the end
426  // of a command is rejected, so it runs from a timer, outside any event: the
427  // message goes as soon as Claude has finished its current answer.
428  $.clock.after(0, () => submitExchange($, exchange))
429  return ''
430}
431
432async function pressSend($) {
433  await poll($)
434  const problem = await sendExchange($)
435  $.ui.toast(problem || state.L.s.scheduled)
436}
437
438async function submitExchange($, exchange) {
439  try {
440    await $.prompt.submit({ text: transmission(exchange, state.L) })
441    $.ui.toast(state.L.s.sent)
442  } catch {
443    state.sentKey = null
444    $.ui.toast(state.L.s.sendFailed)
445  } finally {
446    state.sending = false
447    $.ui.invalidate('ui.render')
448  }
449}
450
451// ---- Session picker ----------------------------------------------------------
452
453async function chooseSession($) {
454  const s = state.L.s
455  let sessions = []
456  try {
457    sessions = (await readCodex($, ['--list', '3'])).sessions || []
458  } catch {
459    $.ui.toast(s.listFailed)
460    return
461  }
462  const labels = sessions.map((x) => sessionLabel(x, state.L))
463  let answer
464  try {
465    answer = await $.ui.ask(s.askSession, { header: s.askSessionHeader, options: [s.auto, ...labels] })
466  } catch {
467    return // question closed: nothing changes
468  }
469  const index = labels.indexOf(answer)
470  state.pinned = index >= 0 ? sessions[index].file : null
471  state.back = 0
472  $.ui.toast(index >= 0 ? s.pinnedTo(sessionName(sessions[index], state.L)) : s.autoMode)
473  await poll($)
474}
475
476// ---- To Codex ----------------------------------------------------------------
477
478// Sends Claude's latest answer to the session shown, with `codex queue` (the
479// message waits for the end of Codex's current task). The user confirms each
480// send: the question names the session and quotes the start of the text, so
481// topics do not get mixed between sessions.
482async function sendToCodex($) {
483  const s = state.L.s
484  if (state.sendingToCodex) return $.ui.toast(s.alreadyToCodex)
485  await poll($)
486  const d = state.data
487  const answer = state.lastAnswer
488  if (!d || !d.thread_id) return $.ui.toast(s.noThread)
489  if (!answer) return $.ui.toast(s.noAnswer)
490  if (state.sentToCodexAt === answer.at) return $.ui.toast(s.answerAlreadySent)
491  // An answer without a ```codex block is meant for the user, not for Codex.
492  if (!answer.codex) return $.ui.toast(s.noCodexBlock)
493  const part = { text: answer.codex, partial: true }
494  const question = s.askToCodex(sessionName(d, state.L), d.busy, part.partial, part.text.length, clip(part.text.replace(/\s+/g, ' '), 90))
495  let choice
496  try {
497    choice = await $.ui.ask(question, { header: s.askToCodexHeader, options: [s.send, s.cancel] })
498  } catch {
499    return
500  }
501  if (choice !== s.send) return $.ui.toast(s.cancelled)
502  state.sendingToCodex = true
503  $.ui.invalidate('ui.render')
504  try {
505    const run = await $.process.run(['codex', 'queue', '--thread', d.thread_id, '--message', toCodexMessage(part.text, part.partial, state.L)], { timeoutMs: 60000 })
506    if (run.exitCode === 0) {
507      state.sentToCodexAt = answer.at
508      try {
509        await $.state.set(SENT_TO_CODEX_AT, answer.at)
510      } catch {
511        // kept in memory only
512      }
513      $.ui.toast(s.sentToCodex)
514    } else {
515      $.ui.toast(s.queueFailed('code ' + run.exitCode + ', ' + clip((run.stderr || run.stdout || '').trim(), 160)))
516    }
517  } catch (error) {
518    // Usually: `codex` is not on the PATH Claude Code was started with.
519    $.ui.toast(s.queueFailed(clip(String(error && error.message ? error.message : error), 120)) + s.codexPathHint)
520  } finally {
521    state.sendingToCodex = false
522    $.ui.invalidate('ui.render')
523  }
524}
525
526// ---- Drawing -----------------------------------------------------------------
527
528// Three blocks: a fixed header (title, session, state, activity, current
529// exchange), a window on the latest messages anchored on the last one, and fixed
530// buttons at the bottom. The pane draws exactly its height, line by line, and
531// handles the wheel itself (ui.scroll hook), so the header never scrolls away.
532function draw($, e) {
533  const { Box, Text, Button } = $.ui.resolve(e)
534  const s = state.L.s
535  const line = (text, props = {}) => Text({ ...props, children: [text || ' '], wrap: 'truncate-end' })
536  const header = []
537  const d = state.data
538
539  header.push(line(s.title, { bold: true, color: 'magenta' }))
540  if (state.error) header.push(line('  ' + state.error, { color: 'red' }))
541  if (!d) {
542    header.push(line('  ' + s.reading, { dimColor: true }))
543    return Box({ flexDirection: 'column', children: header })
544  }
545
546  header.push(line('  ' + s.session(sessionName(d, state.L), !!state.pinned) + (d.live === false ? s.closed : ''), { dimColor: true }))
547  header.push(line('  ' + (d.busy ? s.busy : s.idle), { color: d.busy ? 'yellow' : 'green' }))
548  if (d.activity) {
549    header.push(line('  ' + hm(d.activity) + ' ' + (s.kind[d.activity.kind] || d.activity.kind) + ' : ' + d.activity.text, { dimColor: true }))
550  }
551  if (d.exchange) {
552    const x = d.exchange
553    header.push(line('  ' + s.exchange(hm(x.user), x.codex.length, x.done, state.sentKey === x.last_ts), { dimColor: true }))
554  }
555  header.push(line(' '))
556
557  const width = Math.max(20, (e.props && e.props.bodyColumns) || 60)
558  const all = messageLines(d.messages, width, state.L)
559  const footerRows = 3 // empty line + two rows of buttons
560  const bodyRows = e.props && e.props.scroll && e.props.scroll.bodyRows
561  // Docked pane: known height, filled exactly. Above the prompt the pane grows
562  // with its content, so it is capped at 20 lines.
563  const avail = e.props && e.props.placement === 'dock' && bodyRows
564    ? Math.max(3, bodyRows - header.length - 1 - footerRows)
565    : Math.min(all.length, 20)
566  const win = windowOf(all, avail, state.back)
567  state.back = win.back
568  state.maxBack = win.maxBack
569
570  header.push(line(s.latest + (win.back > 0 ? s.scrolled(win.back) : ''), { bold: true }))
571  const rows = [...header]
572  for (const r of win.rows) rows.push(line(r.text, { color: r.color }))
573  for (let i = win.rows.length; i < avail; i++) rows.push(line(' '))
574  rows.push(line(' '))
575
576  rows.push(
577    Box({
578      flexDirection: 'row',
579      columnGap: 2,
580      children: [
581        // The button always says what it did, even when there is nothing to send.
582        Button({ key: 'send', label: state.sending ? s.toClaudeBusy : s.toClaude, hotkey: s.keys.toClaude, plain: true, onPress: () => pressSend($) }),
583        Button({ key: 'to-codex', label: state.sendingToCodex ? s.toCodexBusy : s.toCodex, hotkey: s.keys.toCodex, plain: true, onPress: () => sendToCodex($) }),
584      ],
585    }),
586  )
587  rows.push(
588    Box({
589      flexDirection: 'row',
590      columnGap: 2,
591      children: [
592        Button({ key: 'session', label: s.switchSession, hotkey: s.keys.session, plain: true, onPress: () => chooseSession($) }),
593        Button({ key: 'refresh', label: s.refresh, hotkey: s.keys.refresh, plain: true, onPress: () => poll($) }),
594        Text({ dimColor: true, children: [s.escClose] }),
595      ],
596    }),
597  )
598  return Box({ flexDirection: 'column', children: rows })
599}
600
601// ---- Hooks -------------------------------------------------------------------
602
603export function register(on, options) {
604  const option = options && options.language
605  const userName = options && options.userName
606  state.L = makeLocale(pickLanguage(option, ''), userName)
607
608  on('session.start', async ($, e, next) => {
609    const result = await next(e)
610    if (option !== 'en' && option !== 'fr') {
611      try {
612        const settings = await $.settings.read()
613        state.L = makeLocale(pickLanguage(option, settings && settings.language), userName)
614      } catch {
615        // settings unreadable: English stays
616      }
617    }
618    // Claude's latest answer and the last one sent survive a reload.
619    try {
620      const kept = await $.state.get(LAST_ANSWER)
621      if (kept.value && typeof kept.value.codex === 'string' && !state.lastAnswer) state.lastAnswer = kept.value
622      const sent = await $.state.get(SENT_TO_CODEX_AT)
623      if (typeof sent.value === 'number' && state.sentToCodexAt === null) state.sentToCodexAt = sent.value
624    } catch {
625      // nothing kept
626    }
627    // Frequent reads while the pane is open, sparser while closed (for the toast).
628    $.clock.after(0, () => poll($))
629    $.clock.every(POLL_OPEN_MS, async () => {
630      const now = await $.clock.now()
631      if (state.open || now - state.lastPollMs >= POLL_CLOSED_MS) await poll($)
632    })
633    try {
634      await $.command.register({ name: 'codex', description: state.L.s.cmdPane, immediate: true })
635      await $.command.register({ name: 'codex-to-claude', description: state.L.s.cmdToClaude, immediate: true })
636    } catch {
637      // Name already taken: the pane stays reachable through the other command.
638    }
639    return result
640  })
641
642  on('command.run', { command: 'codex' }, async ($) => {
643    const panes = await $.ui.panes()
644    if (panes.some((p) => p.id === PANE)) {
645      await $.ui.close({ id: PANE })
646      state.open = false
647      return {}
648    }
649    await $.ui.open({ id: PANE, title: 'Codex', focus: true, closeOnEscape: true })
650    state.open = true
651    state.back = 0
652    await poll($)
653    return {}
654  })
655
656  on('command.run', { command: 'codex-to-claude' }, async ($) => {
657    await poll($)
658    const problem = await sendExchange($)
659    return problem ? { text: problem } : {}
660  })
661
662  on('ui.close', async ($, e, next) => {
663    if (e.id === PANE) state.open = false
664    return next(e)
665  })
666
667  // Keeps the ```codex blocks of Claude's latest turn (main thread), from every
668  // text of the turn, for "Send to Codex". Nothing else of the answer is kept.
669  // They are held in the session's state so that they survive a reload.
670  on('turn.complete', async ($, e, next) => {
671    const result = await next(e)
672    if (!e.agentId && !e.isAborted) {
673      let messages = []
674      try {
675        messages = await $.session.messages()
676      } catch {
677        // transcript unreadable: the final answer alone
678      }
679      const part = extractForCodex(turnText(messages, e.answer))
680      // Also kept when empty: an answer without a block replaces an older one.
681      state.lastAnswer = { codex: part.partial ? part.text : '', at: await $.clock.now() }
682      try {
683        await $.state.set(LAST_ANSWER, state.lastAnswer)
684      } catch {
685        // kept in memory only
686      }
687    }
688    return result
689  })
690
691  // Wheel or arrows on the pane: the pane moves its own message window (the
692  // header stays in place) and does not let the engine scroll.
693  on('ui.scroll', async ($, e, next) => {
694    if (e.requestId !== PANE) return next(e)
695    const step = e.origin && e.origin.kind === 'person' && Math.abs(e.by) === 1 ? e.by * WHEEL_STEP : e.by
696    state.back = Math.min(Math.max(0, state.back - step), state.maxBack)
697    $.ui.invalidate('ui.render')
698    return {}
699  })
700
701  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
702    if (e.requestId !== PANE) return next(e)
703    return draw($, e)
704  })
705}
706
types/index.d.ts 14 lines
1// Values codex-pane keeps in the session's state, so that a reload of the
2// plugin does not lose Claude's latest answer kept for "Send to Codex".
3// Only the ```codex blocks are kept ('' when the answer had none).
4export type KeptAnswer = { codex: string; at: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'codex-pane': {
9      lastAnswer: KeptAnswer
10      sentToCodexAt: number
11    }
12  }
13}
14