Long conversations cannot be opened at all after updating the OpenAI Windows desktop app, urgent fix needed!

Resolved 💬 22 comments Opened May 16, 2026 by oyasumiaiko Closed May 16, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of the Codex App are you using (From “About Codex” dialog)?

26.513.31313

What subscription do you have?

APIKEY

What platform is your computer?

Microsoft Windows NT 10.0.19045.0 x64"Microsoft Windows NT 10.0.19045.0 x64

What issue are you seeing?

<img width="674" height="451" alt="Image" src="https://github.com/user-attachments/assets/a4a59e03-ddfd-4c89-ac5b-dbe3fe129a1b" />

After seeing an update prompt in the top-left corner of the Codex desktop app, I clicked update and restarted. Afterward, the long conversations I had been working on would no longer load. I have several ongoing conversations with rollout files around 150M-170M, and I can't open any of them. When I try to open them, the error message shown in the image above appears, and clicking the retry button or restarting the app has no effect.

Before this update, my conversations were working completely normally.

After the update, my shorter conversations can still be opened as usual, but these long conversations repeatedly show this error no matter what I do. Please resolve this as soon as possible!!

What steps can reproduce the bug?

Feedback ID: 019de1dc-b9f5-7b81-871c-c252412e1832

What is the expected behavior?

Able to open very long conversations normally

Additional information

_No response_

View original on GitHub ↗

22 Comments

github-actions[bot] contributor · 2 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #22004
  • #21948
  • #22603

Powered by Codex Action

ningjiaen123 · 2 months ago

Me too!!!

ningjiaen123 · 2 months ago

This started right after I updated the Windows Codex app, and now almost all of my conversations won’t open.

SeanD3v · 2 months ago

After clicking “Update,” I encountered the same issue and couldn't resume my previous session.

<img width="706" height="431" alt="Image" src="https://github.com/user-attachments/assets/34402c21-e85b-4223-af7e-70b31f15facb" />

ningjiaen123 · 2 months ago

This has basically made the codex app unusable. Even newly created conversations can randomly hit this bug, and I’m not sure what triggers it yet.

Caxtrol · 2 months ago

Mismo error ya no puedo abrir los chats en los que estaba trabajando, esto paso despues de actualizar la aplicacion de escritorio en windows el dia de hoy.

<img width="725" height="426" alt="Image" src="https://github.com/user-attachments/assets/ad47eb65-521c-454f-b105-24a891879224" />

Arash-san · 2 months ago

Me too. Right after I updated my codex app, one of the project that I was working on does not load and says: Oops, an error has occurred

<img width="583" height="305" alt="Image" src="https://github.com/user-attachments/assets/117f17b0-7310-491c-a0a1-67105c0d065c" />

luisnucg · 2 months ago

Same here!

<img width="304" height="210" alt="Image" src="https://github.com/user-attachments/assets/3a2f3c7a-3d30-484f-b9a2-32839bdccd4f" />

hastwrkasauasksauk7-ux · 2 months ago

me too,codex can not work

zcpu954861 · 2 months ago

I can reproduce this on Windows Codex Desktop 26.513.31313. New conversations work, but older resumed conversations show the error page. This started immediately after updating Codex Desktop.

jiejie-dev · 2 months ago

Code Apps logs show the error like bellow, i think it is about markdown and windows like path
I can work now, please fix it now !

2026-05-16T06:00:41.706Z error [electron-message-handler] error boundary componentStack="\n at Br (app://-/assets/markdown-D32gobzN.js:8:5425)\n at div (<anonymous>)\n at uy (app://-/assets/local-conversation-thread-BKrlVhAv.js:32:229104)\n at HS (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:69831)\n at div (<anonymous>)\n at div (<anonymous>)\n at div (<anonymous>)\n at Rw (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:151035)\n at app://-/assets/local-conversation-thread-BKrlVhAv.js:34:148809\n at Xw (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:167685)\n at div (<anonymous>)\n at app://-/assets/local-conversation-thread-BKrlVhAv.js:34:175039\n at div (<anonymous>)\n at div (<anonymous>)\n at rT (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:169305)\n at div (<anonymous>)\n at a (app://-/assets/proxy-BjzPZxH5.js:1:9079)\n at nE (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:200109)\n at div (<anonymous>)\n at div (<anonymous>)\n at a (app://-/assets/proxy-BjzPZxH5.js:1:9079)\n at div (<anonymous>)\n at div (<anonymous>)\n at f (app://-/assets/thread-scroll-layout-Cxloffmz.js:1:743)\n at div (<anonymous>)\n at div (<anonymous>)\n at div (<anonymous>)\n at div (<anonymous>)\n at s (app://-/assets/thread-layout-Chou_aJz.js:1:243)\n at fT (app://-/assets/apps-C0n7YO22.js:76:5536)\n at XT (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:192696)\n at qT (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:190451)\n at WT (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:188762)\n at div (<anonymous>)\n at div (<anonymous>)\n at rn (app://-/assets/local-conversation-page-Vm2E5OJ5.js:2:30978)\n at pn (app://-/assets/local-conversation-page-Vm2E5OJ5.js:2:38605)\n at Re (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:4057)\n at tt (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:8269)\n at Suspense (<anonymous>)\n at Zn (app://-/assets/app-shell-D5Aq-LSR.js:1:52215)\n at div (<anonymous>)\n at div (<anonymous>)\n at div (<anonymous>)\n at div (<anonymous>)\n at div (<anonymous>)\n at main (<anonymous>)\n at div (<anonymous>)\n at div (<anonymous>)\n at a (app://-/assets/proxy-BjzPZxH5.js:1:9079)\n at jn (app://-/assets/app-shell-D5Aq-LSR.js:1:44586)\n at $n (app://-/assets/app-shell-D5Aq-LSR.js:1:52694)\n at Hw (app://-/assets/app-main-1fJkJDdR.js:2:297978)\n at Uw (app://-/assets/app-main-1fJkJDdR.js:2:300152)\n at Vw (app://-/assets/app-main-1fJkJDdR.js:2:297651)\n at Ww (app://-/assets/app-main-1fJkJDdR.js:2:300261)\n at Re (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:4057)\n at tt (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:8269)\n at fD (app://-/assets/app-main-1fJkJDdR.js:4:34174)\n at Re (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:4057)\n at it (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:9374)\n at Suspense (<anonymous>)\n at Dn (app://-/assets/vscode-api-BEuchX0L.js:1:50266)\n at UI (app://-/assets/app-main-1fJkJDdR.js:60:16708)\n at Dn (app://-/assets/vscode-api-BEuchX0L.js:1:50266)\n at BI (app://-/assets/app-main-1fJkJDdR.js:60:14900)\n at UM (app://-/assets/app-main-1fJkJDdR.js:4:215494)\n at VM (app://-/assets/app-main-1fJkJDdR.js:4:213673)\n at fT (app://-/assets/apps-C0n7YO22.js:76:5536)\n at OM (app://-/assets/app-main-1fJkJDdR.js:4:207866)\n at IO (app://-/assets/app-main-1fJkJDdR.js:4:99126)\n at Qu (app://-/assets/WorkerPoolContext-9g15HyMX.js:168:29293)\n at x (app://-/assets/shiki-highlight-provider-DgU-gB_m.js:1:671)\n at Suspense (<anonymous>)\n at qI (app://-/assets/app-main-1fJkJDdR.js:60:18059)\n at kF (app://-/assets/app-main-1fJkJDdR.js:7:50846)\n at w (app://-/assets/tooltip-q1BAjA5f.js:1:1084)\n at sM (app://-/assets/app-main-1fJkJDdR.js:4:189572)\n at YO (app://-/assets/app-main-1fJkJDdR.js:4:103251)\n at t (app://-/assets/global-settings-DxHTPSP4.js:26:8362)\n at KM (app://-/assets/app-main-1fJkJDdR.js:4:216031)\n at p (app://-/assets/statsig-C9zAUV2g.js:1:3388)\n at l (app://-/assets/statsig-C9zAUV2g.js:1:2720)\n at iA (app://-/assets/app-main-1fJkJDdR.js:4:135367)\n at rA (app://-/assets/app-main-1fJkJDdR.js:4:134435)\n at yP (app://-/assets/app-main-1fJkJDdR.js:7:6138)\n at $O (app://-/assets/app-main-1fJkJDdR.js:4:107727)\n at HM (app://-/assets/app-main-1fJkJDdR.js:4:214129)\n at rt (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:8456)\n at $e (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:7211)\n at RI (app://-/assets/app-main-1fJkJDdR.js:60:14401)\n at Dn (app://-/assets/vscode-api-BEuchX0L.js:1:50266)\n at En (app://-/assets/vscode-api-BEuchX0L.js:1:50104)\n at jF (app://-/assets/app-main-1fJkJDdR.js:7:51590)\n at Mt (app://-/assets/app-server-manager-signals-BEaGjuc8.js:2:1681)\n at Vn (app://-/assets/vscode-api-BEuchX0L.js:1:54178)\n at xI (app://-/assets/app-main-1fJkJDdR.js:60:4414)\n at ek (app://-/assets/app-main-1fJkJDdR.js:4:109316)\n at cI (app://-/assets/app-main-1fJkJDdR.js:60:1331)\n at LI (app://-/assets/app-main-1fJkJDdR.js:60:9643)" errorMessage="invalid syntax at line 1 col 5:\n\n1 cwd=\"C:\\Users\\my_project_path\"\n ^" errorName=Error errorStack="Error: invalid syntax at line 1 col 5:\n\n1 cwd=\"C:\\Users\\my_project_path\"\n ^\n at app://-/assets/vscode-api-BEuchX0L.js:1:58787\n at ur (app://-/assets/vscode-api-BEuchX0L.js:1:56867)\n at fr (app://-/assets/vscode-api-BEuchX0L.js:1:57070)\n at Lu (app://-/assets/review-file-source-tab-BmlwHNat.js:9:117380)\n at _u (app://-/assets/review-file-source-tab-BmlwHNat.js:9:112540)\n at gu (app://-/assets/review-file-source-tab-BmlwHNat.js:9:112046)\n at ld (app://-/assets/review-file-source-tab-BmlwHNat.js:9:123129)\n at app://-/assets/review-file-source-tab-BmlwHNat.js:9:123182" name=LocalConversationPage

jiejie-dev · 2 months ago
Code Apps logs show the error like bellow, i think it is about markdown and windows like path I can work now, please fix it now ! 2026-05-16T06:00:41.706Z error [electron-message-handler] error boundary componentStack="\n at Br (app://-/assets/markdown-D32gobzN.js:8:5425)\n at div ()\n at uy (app://-/assets/local-conversation-thread-BKrlVhAv.js:32:229104)\n at HS (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:69831)\n at div ()\n at div ()\n at div ()\n at Rw (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:151035)\n at app://-/assets/local-conversation-thread-BKrlVhAv.js:34:148809\n at Xw (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:167685)\n at div ()\n at app://-/assets/local-conversation-thread-BKrlVhAv.js:34:175039\n at div ()\n at div ()\n at rT (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:169305)\n at div ()\n at a (app://-/assets/proxy-BjzPZxH5.js:1:9079)\n at nE (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:200109)\n at div ()\n at div ()\n at a (app://-/assets/proxy-BjzPZxH5.js:1:9079)\n at div ()\n at div ()\n at f (app://-/assets/thread-scroll-layout-Cxloffmz.js:1:743)\n at div ()\n at div ()\n at div ()\n at div ()\n at s (app://-/assets/thread-layout-Chou_aJz.js:1:243)\n at fT (app://-/assets/apps-C0n7YO22.js:76:5536)\n at XT (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:192696)\n at qT (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:190451)\n at WT (app://-/assets/local-conversation-thread-BKrlVhAv.js:34:188762)\n at div ()\n at div ()\n at rn (app://-/assets/local-conversation-page-Vm2E5OJ5.js:2:30978)\n at pn (app://-/assets/local-conversation-page-Vm2E5OJ5.js:2:38605)\n at Re (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:4057)\n at tt (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:8269)\n at Suspense ()\n at Zn (app://-/assets/app-shell-D5Aq-LSR.js:1:52215)\n at div ()\n at div ()\n at div ()\n at div ()\n at div ()\n at main ()\n at div ()\n at div ()\n at a (app://-/assets/proxy-BjzPZxH5.js:1:9079)\n at jn (app://-/assets/app-shell-D5Aq-LSR.js:1:44586)\n at $n (app://-/assets/app-shell-D5Aq-LSR.js:1:52694)\n at Hw (app://-/assets/app-main-1fJkJDdR.js:2:297978)\n at Uw (app://-/assets/app-main-1fJkJDdR.js:2:300152)\n at Vw (app://-/assets/app-main-1fJkJDdR.js:2:297651)\n at Ww (app://-/assets/app-main-1fJkJDdR.js:2:300261)\n at Re (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:4057)\n at tt (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:8269)\n at fD (app://-/assets/app-main-1fJkJDdR.js:4:34174)\n at Re (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:4057)\n at it (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:9374)\n at Suspense ()\n at Dn (app://-/assets/vscode-api-BEuchX0L.js:1:50266)\n at UI (app://-/assets/app-main-1fJkJDdR.js:60:16708)\n at Dn (app://-/assets/vscode-api-BEuchX0L.js:1:50266)\n at BI (app://-/assets/app-main-1fJkJDdR.js:60:14900)\n at UM (app://-/assets/app-main-1fJkJDdR.js:4:215494)\n at VM (app://-/assets/app-main-1fJkJDdR.js:4:213673)\n at fT (app://-/assets/apps-C0n7YO22.js:76:5536)\n at OM (app://-/assets/app-main-1fJkJDdR.js:4:207866)\n at IO (app://-/assets/app-main-1fJkJDdR.js:4:99126)\n at Qu (app://-/assets/WorkerPoolContext-9g15HyMX.js:168:29293)\n at x (app://-/assets/shiki-highlight-provider-DgU-gB_m.js:1:671)\n at Suspense ()\n at qI (app://-/assets/app-main-1fJkJDdR.js:60:18059)\n at kF (app://-/assets/app-main-1fJkJDdR.js:7:50846)\n at w (app://-/assets/tooltip-q1BAjA5f.js:1:1084)\n at sM (app://-/assets/app-main-1fJkJDdR.js:4:189572)\n at YO (app://-/assets/app-main-1fJkJDdR.js:4:103251)\n at t (app://-/assets/global-settings-DxHTPSP4.js:26:8362)\n at KM (app://-/assets/app-main-1fJkJDdR.js:4:216031)\n at p (app://-/assets/statsig-C9zAUV2g.js:1:3388)\n at l (app://-/assets/statsig-C9zAUV2g.js:1:2720)\n at iA (app://-/assets/app-main-1fJkJDdR.js:4:135367)\n at rA (app://-/assets/app-main-1fJkJDdR.js:4:134435)\n at yP (app://-/assets/app-main-1fJkJDdR.js:7:6138)\n at $O (app://-/assets/app-main-1fJkJDdR.js:4:107727)\n at HM (app://-/assets/app-main-1fJkJDdR.js:4:214129)\n at rt (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:8456)\n at $e (app://-/assets/chunk-LFPYN7LY-DRAAscIQ.js:3:7211)\n at RI (app://-/assets/app-main-1fJkJDdR.js:60:14401)\n at Dn (app://-/assets/vscode-api-BEuchX0L.js:1:50266)\n at En (app://-/assets/vscode-api-BEuchX0L.js:1:50104)\n at jF (app://-/assets/app-main-1fJkJDdR.js:7:51590)\n at Mt (app://-/assets/app-server-manager-signals-BEaGjuc8.js:2:1681)\n at Vn (app://-/assets/vscode-api-BEuchX0L.js:1:54178)\n at xI (app://-/assets/app-main-1fJkJDdR.js:60:4414)\n at ek (app://-/assets/app-main-1fJkJDdR.js:4:109316)\n at cI (app://-/assets/app-main-1fJkJDdR.js:60:1331)\n at LI (app://-/assets/app-main-1fJkJDdR.js:60:9643)" errorMessage="invalid syntax at line 1 col 5:\n\n1 cwd="C:\Users\my_project_path"\n ^" errorName=Error errorStack="Error: invalid syntax at line 1 col 5:\n\n1 cwd="C:\Users\my_project_path"\n ^\n at app://-/assets/vscode-api-BEuchX0L.js:1:58787\n at ur (app://-/assets/vscode-api-BEuchX0L.js:1:56867)\n at fr (app://-/assets/vscode-api-BEuchX0L.js:1:57070)\n at Lu (app://-/assets/review-file-source-tab-BmlwHNat.js:9:117380)\n at _u (app://-/assets/review-file-source-tab-BmlwHNat.js:9:112540)\n at gu (app://-/assets/review-file-source-tab-BmlwHNat.js:9:112046)\n at ld (app://-/assets/review-file-source-tab-BmlwHNat.js:9:123129)\n at app://-/assets/review-file-source-tab-BmlwHNat.js:9:123182" name=LocalConversationPage

github comment my handle the path bad, the real log content like this
<img width="3014" height="56" alt="Image" src="https://github.com/user-attachments/assets/d4a930ca-1756-4fe2-8e79-03c2662a8f9e" />

thelionsdenapps · 2 months ago

Same issue. This is my understanding/post in reddit:

So I updated (DUMB) and I keep getting errors when trying to push to git that causes threads to stop being able to be pulled up.

Doing a lot of debugging and work arounds but it's miserable. This is codex's most recent assessment:

It keeps happening when a thread does a successful git stage/commit/push and Codex writes its special UI directives into the final assistant message.

For ((removed by me)), it is not that our previous fix failed. That thread had been fixed earlier, then later continued working and pushed a new backend commit:

((removed by me)) Add recipe embedding text builder

After that push, Codex appended new lines like:

::git-stage{cwd=((removed by me))
::git-commit{cwd=((removed by me))
::git-push{cwd=((removed by me))
Those are fresh, at the very end of the thread. Same kind of thing happened to the banana thread after it pushed aa25e32, and removing the fresh lines fixed it again.

So the pattern is:

Old/new thread is clean.

Thread runs git stage/commit/push.

Codex Desktop appends ::git-*{cwd="C:\..."} directives into saved assistant-visible messages.

Current Windows renderer tries to parse them.

Renderer crashes on raw C:\... syntax.

The underlying cause is the app bug: Codex Desktop is still generating or re-rendering directives with Windows paths in a syntax its own parser cannot handle. Until OpenAI patches that, any thread that does a git action may re-break after the final response.

Best practical workaround: don’t do commits/pushes inside old important threads for now. Use a fresh disposable “git push” thread, or I can do git work but avoid emitting the final Codex git directives where possible. For the already-broken Prepare AI recipe matching thread, the same 9-line sanitizer should restore it.

SweetLuvianto · 2 months ago

Adding another Windows repro from a separate machine/account.

Environment:

  • Codex Desktop package running from WindowsApps: OpenAI.Codex_26.513.3673.0
  • Codex local version.json reports latest version check: 0.130.0
  • OS: Windows, Codex Desktop app
  • Approx start time after update/check: 2026-05-16 around 10:49 local time (Asia/Jakarta)

Observed behavior:

  • A previously active long project thread now opens to the generic Desktop error page: Oops, an error has occurred.
  • Try again does not recover the thread.
  • The underlying project workspace is still usable; files modified by the affected session are present.
  • The affected transcript file is still present and parseable as JSONL.

Local transcript diagnostics:

  • Affected session id: 019e2e25-aeee-78a1-80d8-3e4ab536b566
  • Transcript size: about 8.5 MB
  • Transcript line count: 4,558 JSONL lines
  • JSONL validation: all lines parse successfully
  • The final transcript entries are tool/function call outputs, not a final assistant message, so the session appears to have been interrupted mid-turn.
  • Recent tool outputs before the UI failure show normal successful work, including ruff check . passing and pytest passing (53 passed).

Relevant local log signals around the same time window:

  • Plugin sync/discovery requests to chatgpt.com/backend-api/plugins/... returned 403 Forbidden with a Cloudflare/JavaScript challenge page.
  • Curated plugin cache refresh also logged: failed to back up plugin cache entry: Access is denied. (os error 5).

My read: this does not look like repository corruption or invalid JSONL. It looks like a Desktop renderer/session-resume regression after the Windows app update, affecting long or interrupted local threads. The session file is valid but the Desktop UI cannot render/resume it.

I intentionally omitted full local filesystem paths and full log payloads from this public comment, but can provide more sanitized diagnostics if useful.

andkondev · 2 months ago

Same here! Using the $100/m sub, not API on Windows 10.

(Codex summarized the troubleshooting we did below:)

Adding another Windows repro with a useful comparison: not every older/longer thread crashes after this update.

Environment:

  • Codex About dialog: 26.513.31313
  • Windows Store package: OpenAI.Codex_26.513.3673.0_x64__2p2nqsd0c76g0
  • Windows desktop app
  • The error is the generic Oops, an error has occurred page.
  • In my case, some affected threads open initially, but the app errors when scrolling back up through history.

Important detail: total rollout size alone does not explain it.

I compared three local rollout files:

| Thread behavior | Rollout size | Max single JSONL record | Compacted records | Inline image refs | session_meta records | cli_version |
|---|---:|---:|---:|---:|---:|---|
| Works: I can scroll entire thread | 11.68 MB | 0.66 MB | 3 | 184 | 1 | 0.123.0-alpha.5 |
| Fails/errors while scrolling | 13.37 MB | 2.37 MB | 2 | 44 | 67 | 0.130.0-alpha.5 |
| Fails/errors while scrolling | 61.09 MB | 9.77 MB | 6 | 94 | 94 | 0.130.0-alpha.5 |

So this does not look like a simple “long thread over X MB” threshold. One working thread is almost the same total size as one failing thread, and it actually has more inline image references overall.

The pattern I’m seeing locally is that the failing newer sessions have:

  • much larger single compacted records
  • many repeated session_meta records
  • newer session format / cli_version around 0.130.0-alpha.5

The working older thread has many images too, but the records are smaller and it only has one session_meta.

This may be related to renderer hydration / markdown rendering / compacted-history rendering rather than just raw rollout size. It would be useful if the app could tolerate large individual records or skip/render-placeholder for problematic compacted records instead of sending the whole thread to the generic error page.

Feedback ID: f2fafff7-ee83-4b17-8260-34fd61ca9f41

<img width="605" height="396" alt="Image" src="https://github.com/user-attachments/assets/e00e1c4f-3672-4941-adf2-890d2dd9459b" />

7000x · 2 months ago

一样

yagasoft · 2 months ago
Same issue. This is my understanding/post in reddit: So I updated (DUMB) and I keep getting errors when trying to push to git that causes threads to stop being able to be pulled up. Doing a lot of debugging and work arounds but it's miserable. This is codex's most recent assessment: It keeps happening when a thread does a successful git stage/commit/push and Codex writes its special UI directives into the final assistant message. For ((removed by me)), it is not that our previous fix failed. That thread had been fixed earlier, then later continued working and pushed a new backend commit: ((removed by me)) Add recipe embedding text builder After that push, Codex appended new lines like: ::git-stage{cwd=((removed by me)) ::git-commit{cwd=((removed by me)) ::git-push{cwd=((removed by me)) Those are fresh, at the very end of the thread. Same kind of thing happened to the banana thread after it pushed aa25e32, and removing the fresh lines fixed it again. So the pattern is: Old/new thread is clean. Thread runs git stage/commit/push. Codex Desktop appends ::git-*{cwd="C:..."} directives into saved assistant-visible messages. Current Windows renderer tries to parse them. Renderer crashes on raw C:... syntax. The underlying cause is the app bug: Codex Desktop is still generating or re-rendering directives with Windows paths in a syntax its own parser cannot handle. Until OpenAI patches that, any thread that does a git action may re-break after the final response. Best practical workaround: don’t do commits/pushes inside old important threads for now. Use a fresh disposable “git push” thread, or I can do git work but avoid emitting the final Codex git directives where possible. For the already-broken Prepare AI recipe matching thread, the same 9-line sanitizer should restore it.

This solved it for me.

I made Codex investigate the issue and create a fix based on your comment, and it fixed it ... kind of!

The script below takes a thread GUID and sanitises it. It can be used for recovering an important thread.

(below this line is generated by Codex to publicly share here)

In my case, there were two separate problems:

  1. Some thread DB rows used normal Windows drive-letter rollout paths while other parts of Codex expected extended paths.
  2. Some saved renderable text contained Codex git UI directive lines with raw Windows drive-letter paths. Those lines appeared to crash the Desktop renderer when the thread, or a work-detail section, was expanded.

The script below creates a renderer-safe recovery clone of a selected thread. It does not modify the original rollout file. It can also scan a thread and report only counts/field paths, without printing the risky matched text.

Usage examples:

# List large rollout files
powershell -ExecutionPolicy Bypass -File .\recover-codex-long-thread.ps1 -ListLargeThreads

# Scan one thread for renderer risk without printing matched text
powershell -ExecutionPolicy Bypass -File .\recover-codex-long-thread.ps1 -ThreadId <THREAD_ID> -ScanRendererCrashRisk

# Dry-run a recovery clone
powershell -ExecutionPolicy Bypass -File .\recover-codex-long-thread.ps1 -ThreadId <THREAD_ID> -WhatIfOnly

# Apply recovery after closing Codex Desktop
powershell -ExecutionPolicy Bypass -File .\recover-codex-long-thread.ps1 -ThreadId <THREAD_ID>

Important notes:

  • Run from a normal PowerShell window outside Codex Desktop.
  • Close Codex Desktop first.
  • The script may stop only Codex / codex processes.
  • It creates backups of state_5.sqlite and session_index.jsonl.
  • It writes a new recovery thread and leaves the original thread untouched.

<details>
<summary>recover-codex-long-thread.ps1</summary>

[CmdletBinding()]
param(
    [switch] $ListLargeThreads,
    [string] $ThreadId,
    [int] $MaxCloneMegabytes = 25,
    [switch] $ScanRendererCrashRisk,
    [switch] $NoProcessStop,
    [switch] $WhatIfOnly
)

$ErrorActionPreference = 'Stop'

if ($ListLargeThreads -and $ScanRendererCrashRisk) {
    throw "Use either -ListLargeThreads or -ScanRendererCrashRisk, not both."
}

if ($ListLargeThreads -and -not [string]::IsNullOrWhiteSpace($ThreadId)) {
    throw "Use either -ListLargeThreads or -ThreadId, not both."
}

if (-not $ListLargeThreads -and [string]::IsNullOrWhiteSpace($ThreadId)) {
    throw "Specify -ListLargeThreads or -ThreadId <thread-id>."
}

if ($MaxCloneMegabytes -lt 1) {
    throw "-MaxCloneMegabytes must be 1 or greater."
}

$codexHome = Join-Path $env:USERPROFILE '.codex'
$dbPath = Join-Path $codexHome 'state_5.sqlite'
$sessionsPath = Join-Path $codexHome 'sessions'
$sessionIndexPath = Join-Path $codexHome 'session_index.jsonl'
$python = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe'

if (-not (Test-Path -LiteralPath $dbPath)) {
    throw "Codex state DB not found: $dbPath"
}
if (-not (Test-Path -LiteralPath $sessionsPath)) {
    throw "Codex sessions folder not found: $sessionsPath"
}
if (-not (Test-Path -LiteralPath $python)) {
    throw "Bundled Python not found: $python"
}

$mode = if ($ListLargeThreads) {
    'list'
} elseif ($ScanRendererCrashRisk) {
    'scan'
} elseif ($WhatIfOnly) {
    'whatif'
} else {
    'recover'
}

if ($mode -eq 'recover' -and -not $NoProcessStop) {
    Write-Host "Stopping Codex Desktop/native runtime processes..."
    Get-Process |
        Where-Object { $_.ProcessName -in @('Codex', 'codex') } |
        Stop-Process -Force -ErrorAction SilentlyContinue

    Start-Sleep -Seconds 3

    $remaining = Get-Process | Where-Object { $_.ProcessName -in @('Codex', 'codex') }
    if ($remaining) {
        $remaining | Format-Table Id, ProcessName, Path -AutoSize
        throw "Some Codex processes are still running. Close them and rerun."
    }
} elseif ($mode -eq 'recover' -and $NoProcessStop) {
    Write-Warning "Running recovery without stopping Codex. This is only safe if every Codex Desktop window is already closed."
}

$py = @'
import copy
import datetime as dt
import json
import os
import random
import re
import secrets
import shutil
import sqlite3
import sys
import time

db_path = sys.argv[1]
codex_home = sys.argv[2]
sessions_path = sys.argv[3]
session_index_path = sys.argv[4]
mode = sys.argv[5]
thread_id = sys.argv[6].strip()
max_clone_mb = int(sys.argv[7])
max_clone_bytes = max_clone_mb * 1024 * 1024

def extend_path(path):
    if not path:
        return path
    if path.startswith("\\\\?\\"):
        return path
    if re.match(r"^[A-Za-z]:\\", path):
        return "\\\\?\\" + path
    return path

def unextend_path(path):
    if path and path.startswith("\\\\?\\"):
        return path[4:]
    return path

def path_style(path):
    if not path:
        return "missing"
    if path.startswith("\\\\?\\"):
        return "extended"
    if re.match(r"^[A-Za-z]:\\", path):
        return "normal_drive"
    return "other"

def path_key(path):
    if not path:
        return ""
    return os.path.normcase(os.path.normpath(unextend_path(path)))

def display_title(title, max_chars=220):
    if title is None:
        return None
    text = " ".join(str(title).split())
    if len(text) <= max_chars:
        return text
    return text[:max_chars - 3] + "..."

def recovery_title(base_title, strategy):
    title = str(base_title or thread_id).strip() or thread_id
    suffix = " (renderer-safe recovery)"
    if title.endswith(" (renderer-safe recovery)"):
        return title
    return title + suffix

def qident(name):
    return '"' + name.replace('"', '""') + '"'

def load_threads(readonly=True):
    uri = f"file:{db_path}?mode=ro" if readonly else db_path
    con = sqlite3.connect(uri, uri=readonly)
    con.row_factory = sqlite3.Row
    try:
        cols = [row[1] for row in con.execute("PRAGMA table_info(threads)").fetchall()]
        rows = con.execute("SELECT * FROM threads").fetchall()
        return cols, rows
    finally:
        con.close()

def find_thread_row(target_id):
    cols, rows = load_threads(readonly=True)
    for row in rows:
        if row["id"] == target_id:
            return cols, dict(row)
    raise SystemExit(f"Thread not found in state DB: {target_id}")

def load_session_index_name(target_id):
    if not os.path.exists(session_index_path):
        return None

    name = None
    with open(session_index_path, "r", encoding="utf-8") as handle:
        for line in handle:
            try:
                item = json.loads(line)
            except Exception:
                continue
            if item.get("id") == target_id and item.get("thread_name"):
                name = item["thread_name"]
    return name

def extract_thread_id_from_filename(path):
    match = re.search(
        r"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$",
        os.path.basename(path),
    )
    return match.group(1).lower() if match else None

def resolve_rollout_path(row):
    candidates = []
    if row.get("rollout_path"):
        candidates.append(row["rollout_path"])
        candidates.append(unextend_path(row["rollout_path"]))
        candidates.append(extend_path(row["rollout_path"]))

    for candidate in candidates:
        if candidate and os.path.exists(candidate):
            return candidate

    suffix = f"{row['id']}.jsonl".lower()
    for dirpath, _dirnames, filenames in os.walk(sessions_path):
        for filename in filenames:
            if filename.lower().endswith(suffix):
                return os.path.join(dirpath, filename)

    raise SystemExit(f"Rollout JSONL not found for thread {row['id']}: {row.get('rollout_path')}")

def list_large_threads():
    _cols, rows = load_threads(readonly=True)
    rows_by_id = {row["id"]: dict(row) for row in rows}
    rows_by_path = {path_key(row["rollout_path"]): dict(row) for row in rows}

    items = []
    for dirpath, _dirnames, filenames in os.walk(sessions_path):
        for filename in filenames:
            if not filename.startswith("rollout-") or not filename.endswith(".jsonl"):
                continue
            path = os.path.join(dirpath, filename)
            try:
                size = os.path.getsize(path)
            except OSError:
                continue

            tid = extract_thread_id_from_filename(path)
            row = rows_by_id.get(tid) if tid else None
            if row is None:
                row = rows_by_path.get(path_key(path))

            items.append({
                "size_mb": round(size / 1024 / 1024, 2),
                "thread_id": row["id"] if row else tid,
                "title": display_title(row["title"]) if row else None,
                "updated_at": row["updated_at"] if row else None,
                "db_path_style": path_style(row["rollout_path"]) if row else "missing_db_row",
                "path": path,
            })

    items.sort(key=lambda item: item["size_mb"], reverse=True)
    print(json.dumps({
        "mode": "list",
        "db": db_path,
        "sessions": sessions_path,
        "thread_count": len(rows),
        "largest_rollouts": items[:20],
    }, indent=2, ensure_ascii=False))

def uuid7_like():
    ts_ms = int(time.time() * 1000) & ((1 << 48) - 1)
    rand_a = secrets.randbits(12)
    rand_b = secrets.randbits(62)
    time_low = (ts_ms >> 16) & 0xFFFFFFFF
    time_mid = ts_ms & 0xFFFF
    time_hi_and_version = 0x7000 | rand_a
    clock_seq = 0x8000 | ((rand_b >> 48) & 0x3FFF)
    node = rand_b & 0xFFFFFFFFFFFF
    return f"{time_low:08x}-{time_mid:04x}-{time_hi_and_version:04x}-{clock_seq:04x}-{node:012x}"

def replace_thread_id(value, old_id, new_id):
    if isinstance(value, str):
        return value.replace(old_id, new_id)
    if isinstance(value, list):
        return [replace_thread_id(item, old_id, new_id) for item in value]
    if isinstance(value, dict):
        return {key: replace_thread_id(item, old_id, new_id) for key, item in value.items()}
    return value

GIT_DIRECTIVE_PREFIX = ":" + ":git-"
GIT_RENDERER_RISK_RE = re.compile(
    re.escape(GIT_DIRECTIVE_PREFIX)
    + r"(?:stage|commit|push|create-branch|create-pr)\{[^\r\n}]*[A-Za-z]:\\"
)

def make_sanitize_stats():
    return {
        "renderer_risk_before": 0,
        "renderer_risk_after": 0,
        "sanitized_lines_removed": 0,
        "sanitized_fields": {},
        "field_risk_before": {},
    }

def add_counter(target, key, amount):
    target[key] = target.get(key, 0) + amount

def merge_sanitize_stats(*stats_items):
    merged = make_sanitize_stats()
    for stats in stats_items:
        if not stats:
            continue
        merged["renderer_risk_before"] += stats.get("renderer_risk_before", 0)
        merged["renderer_risk_after"] += stats.get("renderer_risk_after", 0)
        merged["sanitized_lines_removed"] += stats.get("sanitized_lines_removed", 0)
        for key, value in stats.get("sanitized_fields", {}).items():
            add_counter(merged["sanitized_fields"], key, value)
        for key, value in stats.get("field_risk_before", {}).items():
            add_counter(merged["field_risk_before"], key, value)
    return merged

def count_renderer_risk(text):
    if not isinstance(text, str):
        return 0
    return len(GIT_RENDERER_RISK_RE.findall(text))

def sanitize_text(text, field_path, stats):
    before = count_renderer_risk(text)
    stats["renderer_risk_before"] += before
    if before:
        add_counter(stats["field_risk_before"], field_path, before)

    if not before:
        return text

    kept = []
    removed = 0
    for line in text.splitlines(keepends=True):
        if GIT_RENDERER_RISK_RE.search(line):
            removed += 1
            continue
        kept.append(line)

    sanitized = "".join(kept)
    if removed:
        stats["sanitized_lines_removed"] += removed
        add_counter(stats["sanitized_fields"], field_path, removed)

    stats["renderer_risk_after"] += count_renderer_risk(sanitized)
    return sanitized

def sanitize_value(value, field_path, stats):
    if isinstance(value, str):
        return sanitize_text(value, field_path, stats)
    if isinstance(value, list):
        return [
            sanitize_value(item, f"{field_path}[]", stats)
            for item in value
        ]
    if isinstance(value, dict):
        return {
            key: sanitize_value(item, f"{field_path}.{key}", stats)
            for key, item in value.items()
        }
    return value

def encode_record_spec(line_no, record_type, record, old_id, new_id):
    stats = make_sanitize_stats()
    cloned = replace_thread_id(copy.deepcopy(record), old_id, new_id)
    sanitized = sanitize_value(cloned, record_type, stats)
    encoded = (json.dumps(sanitized, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
    return (line_no, record_type, encoded), stats

def encode_record_specs(specs, old_id, new_id):
    encoded = []
    stats_items = []
    for line_no, record_type, record in specs:
        item, stats = encode_record_spec(line_no, record_type, record, old_id, new_id)
        encoded.append(item)
        stats_items.append(stats)
    return encoded, merge_sanitize_stats(*stats_items)

def parse_rollout(source_path):
    first_meta = None
    first_meta_line = None
    last_compacted = None
    full_records = []
    duplicate_session_meta_dropped = 0
    type_counts = {}
    parse_errors = []
    total_lines = 0

    with open(source_path, "rb") as handle:
        for line_no, raw in enumerate(handle, 1):
            total_lines = line_no
            try:
                record = json.loads(raw)
            except Exception as exc:
                if len(parse_errors) < 5:
                    parse_errors.append({"line": line_no, "error": str(exc)})
                continue

            record_type = record.get("type")
            type_counts[record_type or "<missing>"] = type_counts.get(record_type or "<missing>", 0) + 1

            if record_type == "session_meta" and first_meta is None:
                first_meta = record
                first_meta_line = line_no
            elif record_type == "session_meta":
                duplicate_session_meta_dropped += 1
            elif record_type == "compacted":
                last_compacted = (line_no, record, len(raw))
                full_records.append((line_no, record, len(raw)))
            else:
                full_records.append((line_no, record, len(raw)))

    if first_meta is None:
        raise SystemExit(f"No session_meta record found in rollout: {source_path}")

    if last_compacted:
        tail_start = last_compacted[0] + 1
    else:
        tail_start = first_meta_line + 1

    tail_records = []
    with open(source_path, "rb") as handle:
        for line_no, raw in enumerate(handle, 1):
            if line_no < tail_start:
                continue
            try:
                record = json.loads(raw)
            except Exception:
                continue
            if record.get("type") == "session_meta":
                continue
            tail_records.append((line_no, record, len(raw)))

    return {
        "total_lines": total_lines,
        "type_counts": type_counts,
        "parse_errors": parse_errors,
        "first_meta_line": first_meta_line,
        "first_meta": first_meta,
        "full_records": full_records,
        "duplicate_session_meta_dropped": duplicate_session_meta_dropped,
        "last_compacted_line": last_compacted[0] if last_compacted else None,
        "last_compacted_bytes": last_compacted[2] if last_compacted else None,
        "last_compacted": last_compacted[1] if last_compacted else None,
        "tail_records": tail_records,
    }

def choose_clone_records(parsed, old_id, new_id):
    full_specs = [(parsed["first_meta_line"], "session_meta", parsed["first_meta"])] + [
        (line_no, record.get("type") or "<missing>", record)
        for line_no, record, _raw_size in parsed["full_records"]
    ]
    encoded_full, full_stats = encode_record_specs(full_specs, old_id, new_id)
    full_sanitize_size = sum(len(item[2]) for item in encoded_full)

    if full_sanitize_size <= max_clone_bytes:
        return {
            "strategy": "full_sanitize",
            "encoded": encoded_full,
            "base_size": len(encoded_full[0][2]),
            "full_size": full_sanitize_size,
            "full_sanitize_size": full_sanitize_size,
            "clone_size": full_sanitize_size,
            "kept_tail_count": None,
            "dropped_tail_count": None,
            "base_exceeds_limit": False,
            "kept_line_numbers": [item[0] for item in encoded_full],
            "duplicate_session_meta_dropped": parsed["duplicate_session_meta_dropped"],
            "sanitize_stats": full_stats,
        }

    base = [(parsed["first_meta_line"], "session_meta", parsed["first_meta"])]
    if parsed["last_compacted"] is not None:
        base.append((parsed["last_compacted_line"], "compacted", parsed["last_compacted"]))

    encoded_base, base_stats = encode_record_specs(base, old_id, new_id)
    base_size = sum(len(item[2]) for item in encoded_base)

    encoded_tail = []
    encoded_tail_stats = []
    for line_no, record, _raw_size in parsed["tail_records"]:
        encoded_item, item_stats = encode_record_specs(
            [(line_no, record.get("type") or "<missing>", record)],
            old_id,
            new_id,
        )
        encoded_tail.extend(encoded_item)
        encoded_tail_stats.append(item_stats)
    full_size = base_size + sum(len(item[2]) for item in encoded_tail)

    if full_size <= max_clone_bytes:
        kept_tail = encoded_tail
        dropped_tail = []
        kept_stats = encoded_tail_stats
    else:
        remaining = max_clone_bytes - base_size
        kept_reversed = []
        kept_stats_reversed = []
        dropped_reversed = []
        for item, item_stats in reversed(list(zip(encoded_tail, encoded_tail_stats))):
            item_size = len(item[2])
            if remaining >= item_size:
                kept_reversed.append(item)
                kept_stats_reversed.append(item_stats)
                remaining -= item_size
            else:
                dropped_reversed.append(item)
        kept_tail = list(reversed(kept_reversed))
        kept_stats = list(reversed(kept_stats_reversed))
        dropped_tail = list(reversed(dropped_reversed))

    encoded = encoded_base + kept_tail
    selected_stats = merge_sanitize_stats(base_stats, *kept_stats)
    return {
        "strategy": "compacted_tail",
        "encoded": encoded,
        "base_size": base_size,
        "full_size": full_size,
        "full_sanitize_size": full_sanitize_size,
        "clone_size": sum(len(item[2]) for item in encoded),
        "kept_tail_count": len(kept_tail),
        "dropped_tail_count": len(dropped_tail),
        "base_exceeds_limit": base_size > max_clone_bytes,
        "kept_line_numbers": [item[0] for item in encoded],
        "duplicate_session_meta_dropped": parsed["duplicate_session_meta_dropped"],
        "sanitize_stats": selected_stats,
    }

def make_unique_clone_path(existing_ids):
    now = dt.datetime.now()
    date_dir = os.path.join(sessions_path, now.strftime("%Y"), now.strftime("%m"), now.strftime("%d"))
    for _attempt in range(100):
        new_id = uuid7_like()
        if new_id in existing_ids:
            continue
        filename = f"rollout-{now.strftime('%Y-%m-%dT%H-%M-%S')}-{new_id}.jsonl"
        path = os.path.join(date_dir, filename)
        if not os.path.exists(path):
            return new_id, path
    raise SystemExit("Could not generate a unique recovery thread id.")

def verify_jsonl(path):
    count = 0
    with open(path, "rb") as handle:
        for count, raw in enumerate(handle, 1):
            json.loads(raw)
    return count

def scan_renderer_crash_risk():
    _cols, source_row = find_thread_row(thread_id)
    source_path = resolve_rollout_path(source_row)
    source_size = os.path.getsize(source_path)
    stats_items = []
    parse_errors = []
    total_lines = 0
    type_counts = {}

    with open(source_path, "rb") as handle:
        for line_no, raw in enumerate(handle, 1):
            total_lines = line_no
            try:
                record = json.loads(raw)
            except Exception as exc:
                if len(parse_errors) < 5:
                    parse_errors.append({"line": line_no, "error": str(exc)})
                continue

            record_type = record.get("type") or "<missing>"
            type_counts[record_type] = type_counts.get(record_type, 0) + 1
            stats = make_sanitize_stats()
            sanitize_value(copy.deepcopy(record), record_type, stats)
            stats_items.append(stats)

    stats = merge_sanitize_stats(*stats_items)
    print(json.dumps({
        "mode": "scan",
        "thread_id": thread_id,
        "title": display_title(load_session_index_name(thread_id) or source_row.get("title")),
        "rollout_path": source_path,
        "rollout_size_mb": round(source_size / 1024 / 1024, 3),
        "total_lines": total_lines,
        "type_counts": type_counts,
        "parse_errors_sample": parse_errors,
        "renderer_risk_before": stats["renderer_risk_before"],
        "renderer_risk_after_if_sanitized": stats["renderer_risk_after"],
        "sanitized_lines_removed_if_sanitized": stats["sanitized_lines_removed"],
        "sanitized_fields": stats["sanitized_fields"],
        "field_risk_before": stats["field_risk_before"],
        "note": "Counts only; matched text is intentionally not printed.",
    }, indent=2, ensure_ascii=False))

def recover_thread():
    cols, source_row = find_thread_row(thread_id)
    source_path = resolve_rollout_path(source_row)
    source_size = os.path.getsize(source_path)
    parsed = parse_rollout(source_path)

    _all_cols, all_rows = load_threads(readonly=True)
    existing_ids = {row["id"] for row in all_rows}
    new_id, new_path = make_unique_clone_path(existing_ids)
    clone = choose_clone_records(parsed, thread_id, new_id)
    source_sidebar_name = load_session_index_name(thread_id)
    source_display_name = source_sidebar_name or source_row.get("title") or thread_id
    new_title = recovery_title(source_display_name, clone["strategy"])
    sanitize_stats = clone["sanitize_stats"]

    preview = {
        "mode": mode,
        "strategy": clone["strategy"],
        "duplicate_session_meta_dropped": parsed["duplicate_session_meta_dropped"],
        "source_sidebar_name": display_title(source_sidebar_name),
        "clone_sidebar_name": display_title(new_title),
        "renderer_risk_before": sanitize_stats["renderer_risk_before"],
        "renderer_risk_after": sanitize_stats["renderer_risk_after"],
        "sanitized_fields": sanitize_stats["sanitized_fields"],
        "sanitized_lines_removed": sanitize_stats["sanitized_lines_removed"],
        "source": {
            "thread_id": thread_id,
            "title": display_title(source_row.get("title")),
            "source_sidebar_name": display_title(source_sidebar_name),
            "rollout_path": source_path,
            "rollout_size_mb": round(source_size / 1024 / 1024, 2),
            "db_path_style": path_style(source_row.get("rollout_path")),
            "total_lines": parsed["total_lines"],
            "type_counts": parsed["type_counts"],
            "parse_errors_sample": parsed["parse_errors"],
            "duplicate_session_meta_dropped": parsed["duplicate_session_meta_dropped"],
            "latest_compaction_line": parsed["last_compacted_line"],
            "latest_compaction_mb": round((parsed["last_compacted_bytes"] or 0) / 1024 / 1024, 3),
            "tail_records_after_latest_compaction": len(parsed["tail_records"]),
        },
        "clone": {
            "thread_id": new_id,
            "title": display_title(new_title),
            "clone_sidebar_name": display_title(new_title),
            "rollout_path": new_path,
            "db_rollout_path": extend_path(new_path),
            "max_clone_mb": max_clone_mb,
            "predicted_clone_mb": round(clone["clone_size"] / 1024 / 1024, 2),
            "base_records_mb": round(clone["base_size"] / 1024 / 1024, 2),
            "full_sanitize_mb": round(clone["full_sanitize_size"] / 1024 / 1024, 2),
            "full_compaction_tail_mb": round(clone["full_size"] / 1024 / 1024, 2),
            "records_to_write": len(clone["encoded"]),
            "tail_records_kept": clone["kept_tail_count"],
            "tail_records_dropped": clone["dropped_tail_count"],
            "base_exceeds_limit": clone["base_exceeds_limit"],
            "duplicate_session_meta_dropped": clone["duplicate_session_meta_dropped"],
        },
    }

    if mode == "whatif":
        print(json.dumps(preview, indent=2, ensure_ascii=False))
        return

    stamp = time.strftime("%Y%m%d-%H%M%S")
    db_backup_path = f"{db_path}.bak-longthread-{stamp}"
    index_backup_path = f"{session_index_path}.bak-longthread-{stamp}" if os.path.exists(session_index_path) else None
    now_sec = int(time.time())
    now_ms = int(time.time() * 1000)

    os.makedirs(os.path.dirname(new_path), exist_ok=True)
    temp_path = new_path + ".tmp"
    with open(temp_path, "wb") as handle:
        for _line_no, _record_type, encoded in clone["encoded"]:
            handle.write(encoded)
    verify_count = verify_jsonl(temp_path)
    os.replace(temp_path, new_path)

    con = sqlite3.connect(db_path)
    try:
        backup = sqlite3.connect(db_backup_path)
        try:
            con.backup(backup)
        finally:
            backup.close()

        if index_backup_path:
            shutil.copy2(session_index_path, index_backup_path)

        new_row = dict(source_row)
        new_row["id"] = new_id
        new_row["rollout_path"] = extend_path(new_path)
        new_row["created_at"] = now_sec
        new_row["updated_at"] = now_sec
        new_row["title"] = new_title
        if "archived" in new_row:
            new_row["archived"] = 0
        if "archived_at" in new_row:
            new_row["archived_at"] = None
        if "created_at_ms" in new_row:
            new_row["created_at_ms"] = now_ms
        if "updated_at_ms" in new_row:
            new_row["updated_at_ms"] = now_ms
        if "preview" in new_row:
            new_row["preview"] = f"Renderer-safe recovery clone of {thread_id} using {clone['strategy']}"

        column_sql = ", ".join(qident(col) for col in cols)
        placeholder_sql = ", ".join("?" for _ in cols)
        values = [new_row.get(col) for col in cols]

        with con:
            con.execute(
                f"INSERT INTO threads ({column_sql}) VALUES ({placeholder_sql})",
                values,
            )

        inserted = con.execute("SELECT COUNT(*) FROM threads WHERE id = ?", (new_id,)).fetchone()[0]
    finally:
        con.close()

    index_entry = {
        "id": new_id,
        "thread_name": new_title,
        "updated_at": now_sec,
    }
    with open(session_index_path, "a", encoding="utf-8", newline="\n") as handle:
        handle.write(json.dumps(index_entry, ensure_ascii=False, separators=(",", ":")) + "\n")

    preview["actions"] = {
        "db_backup": db_backup_path,
        "session_index_backup": index_backup_path,
        "jsonl_lines_written": verify_count,
        "db_rows_inserted": inserted,
        "session_index_appended": True,
    }
    print(json.dumps(preview, indent=2, ensure_ascii=False))

if mode == "list":
    list_large_threads()
elif mode == "scan":
    scan_renderer_crash_risk()
else:
    recover_thread()
'@

$tmp = Join-Path $env:TEMP "codex-long-thread-recover-$([guid]::NewGuid()).py"
Set-Content -LiteralPath $tmp -Value $py -Encoding UTF8

$threadArg = if ([string]::IsNullOrWhiteSpace($ThreadId)) { '-' } else { $ThreadId }

try {
    & $python $tmp $dbPath $codexHome $sessionsPath $sessionIndexPath $mode $threadArg $MaxCloneMegabytes
    if ($LASTEXITCODE -ne 0) {
        throw "Recovery script failed with exit code $LASTEXITCODE"
    }
}
finally {
    Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}

Write-Host ""
if ($mode -eq 'list') {
    Write-Host "Use -ThreadId <id> -WhatIfOnly to preview a renderer-safe recovery clone."
} elseif ($mode -eq 'scan') {
    Write-Host "Scan only. No files or database rows were changed."
} elseif ($mode -eq 'whatif') {
    Write-Host "Dry run only. No files or database rows were changed."
    Write-Host "Run again without -WhatIfOnly from a normal PowerShell window after closing Codex Desktop."
} else {
    Write-Host "Done. Reopen Codex Desktop and open the new '(renderer-safe recovery)' thread."
    Write-Host "If it still fails, rerun with a smaller -MaxCloneMegabytes value."
}

</details>

Machine-wide directive I added locally to avoid creating new bad saved text:

# Machine-wide Codex Instructions

## Renderer-safe output on Windows

Codex Desktop on Windows can fail when saved or rendered text contains Codex git UI directive lines with raw drive-letter paths. To avoid breaking existing or future threads:
- Do not emit Codex git UI directive lines in final answers, commentary, generated handoff prompts, or summaries of tool output.
- When reporting git stage, commit, push, branch, or PR work, use normal prose instead of app UI directive syntax.
- Do not print raw rollout, log, or database text that contains Codex git UI directive syntax. Report counts, record types, field names, and line numbers only; redact the directive text itself.
- If a command may return saved text containing Codex git UI directives, sanitize or summarize the output before showing it.
- Prefer a fresh disposable thread for git-only work on older important conversations until the Windows renderer bug is fixed upstream.
TriS1x · 2 months ago

I encountered the same issue and was able to temporarily resolve it by opening a new Codex conversation, using it to troubleshoot the affected sessions, applying a local fix, and then restarting Codex Desktop.

However, the issue may recur if a later conversation triggers Git-related commands again. Below is my current understanding of the problem and workaround, in case it helps others.

Symptoms

When opening certain historical conversations in Codex Desktop, the session page crashes and enters an error boundary. The UI may show messages such as:

  • Oops, an error has occurred
  • Check for updates
  • Try again

In my case, the affected sessions were not actually corrupted.

Root Cause

This does not appear to be a project code issue, nor does it seem to be caused by a corrupted session index.

The likely root cause is that Codex Desktop's frontend fails while rendering Markdown from historical session messages.

More specifically, some historical messages contained Codex Git inline directives with Windows-style paths, for example directives that included a cwd parameter like:

C:\Users\...\project

The current Codex Desktop renderer appears to throw a syntax error when parsing certain Windows backslash paths inside these Git inline directives. As a result, the entire session page crashes during frontend rendering.

Investigation Process

The logs showed that both thread/read and thread/resume completed successfully for the affected sessions. This suggests that the backend was able to read and resume the sessions correctly.

The crash happened after maybe_resume_success, during the frontend Markdown rendering phase. The error looked like this:

invalid syntax at line 1 col 5

I also validated the affected JSONL files line by line. The JSONL files themselves did not contain JSON formatting errors.

Based on this, the issue appears to be:

The frontend fails to render certain historical message content, rather than the session file itself being corrupted.

Temporary Workaround

The workaround I used was to convert the executable Git inline directives in the historical messages into plain text.

The goal was to preserve useful information such as:

  • paths
  • branch names
  • command context
  • readable historical content

while preventing Codex Desktop from parsing those lines as Git action buttons or executable inline directives.

The following were not modified:

  • project source code
  • Git repository
  • session index

Only the affected historical session messages were adjusted.

After the modification, I revalidated the JSONL files and confirmed that they could still be parsed correctly. Then I restarted Codex Desktop, and the affected sessions opened normally again.

Impact of the Workaround

So far, this workaround has had no obvious negative impact on normal usage.

The only affected content is the small number of historical message lines that may previously have been rendered as Git operation buttons. After the fix, those lines are preserved as plain text instead.

The following content remains intact:

  • conversation body
  • tool outputs
  • analysis content
  • paths
  • branch information

Remaining Risk

This is only a workaround for the affected historical sessions. It does not fix the underlying Codex Desktop frontend rendering issue.

If a future session writes another similar Git inline directive containing a Windows backslash path, the same version of Codex Desktop may still trigger the same rendering error again.

If this happens, the same workaround can be applied:

  1. Back up the affected JSONL session file.
  2. Locate the Git inline directives containing Windows-style paths.
  3. Convert those directives into plain text.
  4. Validate that the JSONL file is still parseable.
  5. Restart Codex Desktop.

I hope this helps others who run into the same issue.

oyasumiaiko · 2 months ago

https://github.com/openai/codex/issues/22984#issuecomment-4466400904
This solution worked for me. After giving the prompt to Codex to execute and restarting the Codex app, it fixed all the sessions I couldn't open before.

Still hoping the Codex team can release a real fix for this bug so it doesn't happen again.

SegaraRai · 2 months ago

For anyone blocked by this right now, here’s a workaround prompt you can paste into Codex CLI. It does not patch the WindowsApps installation in place. It makes a writable local copy of the Codex app and patches that copy, because editing C:\Program Files\WindowsApps directly is painfully locked down on Windows.

Likely root cause for maintainers: the directive attribute parser’s quoted-string regex appears to mishandle Windows paths like cwd="C:\...". A backslash escape followed by normal characters can fail tokenization, which surfaces as invalid syntax at line 1 col 5.

Prompt:

`````md
Fix the Codex Desktop Windows render crash caused by old Git directives.

Context:
Old conversations may contain directives like:

::git-push{cwd="C:\path\to\repo" branch="some-branch"}

The current webview bundle can crash while parsing those Windows backslashes with:

invalid syntax at line 1 col 5

Known original ASAR integrity hash for this build:

efd67a8840678a2a20fca07283df1dcb5246ce9d2f095ce442e19309edf0b4b7

Task:

  1. Locate the installed Codex Desktop package under:
C:\Program Files\WindowsApps\OpenAI.Codex_26.513.3673.0_x64__*\app
  1. Copy the whole app directory to a writable patch location, for example:
%LOCALAPPDATA%\CodexDesktopPatched\app

Do not modify C:\Program Files\WindowsApps directly.

  1. Extract the copied app’s ASAR:
resources\app.asar

to a temporary working directory.

  1. Patch only this extracted file:
webview\assets\app-server-manager-signals-BEaGjuc8.js

Use this exact-string replacement script. It writes to a separate output file and refuses in-place overwrite:

const fs = require("fs");
const path = require("path");

const [inputFile, outputFile] = process.argv.slice(2);

if (inputFile == null || outputFile == null) {
  console.error("Usage: node patch-directive-regex-to-file.js <input.js> <output.js>");
  process.exit(2);
}

const inputPath = path.resolve(inputFile);
const outputPath = path.resolve(outputFile);

if (inputPath === outputPath) {
  throw new Error("Refusing to overwrite input file; choose a separate output path.");
}

const before = ",xy=RegExp(`(?<==)'(?!.*&[0-9a-zA-Z]+;)[^'\\\\\\\\]*(?:\\\\\\\\.|\\\\\\\\n[^\"\\\\\\\\]*|&[^0-9a-zA-Z;]*)*'`),Sy=RegExp(`(?<==)\"(?!.*&[0-9a-zA-Z]+;)[^\"\\\\\\\\]*(?:\\\\\\\\.|\\\\\\\\n[^\"\\\\\\\\]*|&[^0-9a-zA-Z;]*)*\"`),Cy=RegExp(`(?<==)[^\"\\\\s'\\`=<>\\\\x00]+`)";
const after = ",xy=RegExp(`(?<==)'(?!.*&[0-9a-zA-Z]+;)(?:[^'\\\\\\n\\r]|\\\\.)*'`),Sy=RegExp(`(?<==)\"(?!.*&[0-9a-zA-Z]+;)(?:[^\"\\\\\\n\\r]|\\\\.)*\"`),Cy=RegExp(`(?<==)[^\"\\\\s'\\`=<>\\\\x00]+`)";

const text = fs.readFileSync(inputPath, "utf8");
const occurrences = text.split(before).length - 1;

if (occurrences !== 1) {
  throw new Error(`Expected exactly one directive regex block, found ${occurrences}.`);
}

fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, text.replace(before, after));
console.log(`patched: ${inputPath} -> ${outputPath}`);

Then replace the extracted bundle file with the generated output file.

Verify syntax:

node --check <extracted>\webview\assets\app-server-manager-signals-BEaGjuc8.js
  1. Repack the extracted working directory back into the copied app:
%LOCALAPPDATA%\CodexDesktopPatched\app\resources\app.asar

Do not use app.asar.unpacked for this fix.

  1. Start the copied Codex.exe once. It should fail with an ASAR integrity error like:
Integrity check failed for asar archive (old_hash vs new_hash)

For this build, old_hash should be:

efd67a8840678a2a20fca07283df1dcb5246ce9d2f095ce442e19309edf0b4b7

Patch only the copied Codex.exe by replacing old_hash with new_hash using this binary-safe script:

const fs = require("fs");

const [exe, oldHash, newHash] = process.argv.slice(2);
const oldBytes = Buffer.from(oldHash, "ascii");
const newBytes = Buffer.from(newHash, "ascii");

if (oldBytes.length !== newBytes.length) throw new Error("hash length mismatch");

const buf = fs.readFileSync(exe);
let count = 0;
let offset = -1;

for (let pos = 0; (pos = buf.indexOf(oldBytes, pos)) !== -1; pos += oldBytes.length) {
  count++;
  offset = pos;
}

if (count !== 1) throw new Error(`Expected one embedded hash, found ${count}`);

newBytes.copy(buf, offset);
fs.writeFileSync(exe, buf);
  1. Start the copied patched Codex app and verify that previously crashing conversations open without:
invalid syntax at line 1 col 5

`````

etraut-openai contributor · 2 months ago

We've issued a hotfix to address this. Please update to the latest version.

PrzemyslawKlys · 2 months ago

Thank you! Can confirm it works!