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…

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.

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:
`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 Codex | Codex receives Claude's message and acts on it |
|---|---|
![]() | ![]() |
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.
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.python3, python or py.In Claude Code:
/plugin marketplace add ManuelWarland/claude-codex-pane
/plugin install codex-pane@codex-pane
Then type /codex to open the pane.
| Key | Button | What it does |
|---|---|---|
c | Send to Claude | Sends the current exchange with Codex to Claude. If Claude is busy, it goes as soon as Claude is free. |
x | Send to Codex | Sends 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. |
s | Switch session | Follow 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. |
r | Refresh | Reads 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.
In /config, under codex-pane:
auto (follows your Claude Code language setting), en or fr.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.
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.
codex exec runs, the Codex app and editor integrations are left out. `codex blocks survive a reload of the plugin (see Privacy).plugin/hooks/read_codex.py is the one file to fix.claude plugin validate plugin
claude plugin test plugin
python -m unittest discover -s plugin/tests
MIT, by Manuel Warland, written with Claude Code.
hooks/codex-pane.mjs 706 lines1// 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}
706types/index.d.ts 14 lines1// 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