#1984 · Archiving a codex thread leaves the session in Codex Desktop's list forever
Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high
Why "partially": the visible symptom lives in Codex Desktop, a macOS/Windows/Linux Electron app that is not installed on this Linux machine and needs an OpenAI login to run; I did not drive its sidebar. I did reproduce the mechanism end to end against bb at c7c66423d (bb's archive reaches only bb's own codex app-server child; a second app-server process standing in for Codex Desktop receives no thread/archived notification) and I verified every claim about Codex Desktop internals by extracting and reading the app's own JavaScript (app.asar) from the official Linux build 26.818.21641.
1. TL;DR
When you archive a codex-backed thread in bb, bb correctly asks Codex to archive the session: it sends thread/archive to the codex app-server process that bb itself spawned. Codex then moves the rollout file to ~/.codex/archived_sessions/ and sets archived = 1 in ~/.codex/state_5.sqlite. That is the whole of what bb does, and it is correct as far as Codex's public API goes.
Codex Desktop, however, does not render its sidebar from those files. It keeps a private catalogue (local_thread_catalog in ~/.codex/sqlite/codex-dev.db) that is pruned only when its own app-server child emits a thread/archived notification, or when a "full reconciliation" scan runs — and that scan runs only once, ever (isFullReconciliationDue returns true only while the catalogue is incomplete or has never been fully reconciled). The incremental scans list archived:false threads, so they can never learn that a thread vanished. Codex app-server notifications are per-process: bb's child archiving a thread tells nobody else. The row therefore stays in Codex Desktop's sidebar forever. bb is not doing anything wrong; this is a Codex Desktop design gap that bb could paper over by also broadcasting thread-archived on Codex's private cross-client IPC socket, which is what the reporter proposes.
2. Claims vs findings
| Claim from the issue | Status | Evidence |
|---|---|---|
bb archives the Codex session: rollout moves to archived_sessions/, threads.archived flips to 1 in state_5.sqlite | Verified | Live repro (section 4, step 5): after bb thread archive, find $CODEX_HOME -name 'rollout-*' shows the file under archived_sessions/ and SELECT id, archived FROM threads returns …|1. Note updated_at is not bumped by the archive (stays at last-turn time), so even a watermark-based incremental scan could not see it. |
bb spawns its own codex app-server and calls thread/archive there | Verified | bridge.ts#L1723-L1735 → handleThreadMaintenance; launch command codex app-server. |
Codex Desktop's own app-server never hears about it; no thread/archived notification reaches it | Verified (mechanism) | Repro step 4: an independent codex app-server process subscribed for 45 s across the bb archive received 0 thread/archived notifications. Control: the same process doing its own thread/archive does receive one (same-process.log). |
Codex Desktop keeps a catalogue local_thread_catalog in ~/.codex/sqlite/codex-dev.db with no archived column | Verified | INSERT INTO local_thread_catalog (host_id, thread_id, display_title, … missing_candidate) in main-Cwjv9Ibf.js; no archived column in the column list. (DB file name not checked; table and schema are.) |
A row leaves the catalogue only via handleNotification on thread/archived/thread/deleted → applyAuthoritativeRemoval, or a full reconciliation | Verified | asar excerpts: handleNotification → applyAuthoritativeRemoval (DELETE FROM local_thread_catalog WHERE host_id = ? AND thread_id = ?); full scans mark unseen rows missing_candidate = 1. One extra path exists: refreshThread removes a row when thread/read returns no entry, but that is only triggered by name-update / import notifications on the Desktop's own app-server. |
isFullReconciliationDue() is !isComplete || lastFullReconciliationAt == null, so the full scan never runs again | Verified | Verbatim in the bundle: isFullReconciliationDue(e){return!e.isComplete||e.lastFullReconciliationAt==null}. The only caller that can request a full run is requestStartupSync, gated by that function. |
The incremental scan calls listThreads({archived:false}) | Verified | function d8(e,t,r){… r.listThreads({archived:!1,cursor:t,limit:i,parentThreadId:null,sortKey:`updated_at`,… useStateDbOnly:!0} …). |
Codex has a cross-client bus at ~/.codex/ipc/ipc.sock; its clients broadcast thread-archived on archive | Verified | Socket path: f9() = join(codexHome,"ipc","ipc.sock") (Windows: \\.\pipe\codex-ipc; legacy $TMPDIR/codex-ipc/ipc-<uid>.sock). The same paths are hard-coded in codex-rs (codex-rs/tui/src/ide_context/ipc.rs). Archive flow in the webview calls broadcastThreadArchived({hostId, conversationId, cwd}). |
| Sending that broadcast after bb's archive removes the row from the sidebar within ~1 s | Unverified live / Plausible from code | Receiver path: router forwards to every registered client except sender → main-process client → webview handleThreadArchived → evictConversation(…,"archive-notification") → threadStore.suppressArchivedConversation + removeThreadSummary. I could not run Codex Desktop here. Caveat not stated in the issue: suppressedArchivedConversationIds is an in-memory Set and the catalogue row is untouched (the reporter says so too), so after a Desktop restart the row is expected to come back unless something else prunes it. The reporter only claims immediate visual removal. |
| Exact payload shape is an observation from app.asar, not a spec | Verified | Wire frame is u32 LE length + JSON; message {type:"broadcast", method:"thread-archived", sourceClientId, params:{hostId:"local", conversationId, cwd}, version:2}. The receiver drops any broadcast whose version differs from its table (dce: (e.version??0)===v_(e.method)), so a Codex bump to version 3 silently disables a bb-side broadcast. |
| "63 in-app archives absent from catalogue, 8/8 bb archives still listed" | Unverified | Reporter's machine data; consistent with the mechanism. |
3. Environment
- bb
c7c66423d55c320bab9103218f0ffef1a8191331(main, 2026-08-20); no later commit onorigin/maintouches the codex archive path (git log c7c66423d..origin/main -- plugins/provider-codex packages/agent-runtime/src/runtime.ts apps/server/src/services/threads/thread-archive.tsis empty). - Linux 7.0.0-29-generic x86_64 (Ubuntu 26.4), node v24.18.0,
codex-cli 0.148.0(the issue reports Codex.app 26.814 with embedded codex-cli 0.148.0-alpha.15; my Desktop source read is the official Linux debchatgpt_26.818.21641_amd64.deb). - Dev instance from my worktree: App
http://localhost:17270, Serverhttp://localhost:25270, host daemon127.0.0.1:33270, data dir~/.bb-dev/projects-bb-.claude-worktrees-wf_926b3193-f6c-15-2af1debe7c2c(deleted at cleanup). IsolatedCODEX_HOME=/tmp/bb1984-codex-home(onlyauth.jsoncopied in; deleted at cleanup) so the real~/.codexwas never written.
4. Minimal reproduction
Goal: show that bb's archive only informs bb's own app-server process. A second codex app-server plays Codex Desktop. (On a Mac with Codex Desktop you would instead watch the sidebar; the reporter's steps 1–3 are the visual form of the same thing.)
- Start a dev bb with an isolated Codex home and create a project on a scratch repo:
export CODEX_HOME=/tmp/bb1984-codex-home; mkdir -p $CODEX_HOME; cp ~/.codex/auth.json $CODEX_HOME/ scripts/bb-dev-app current # prints Server: http://localhost:25270 etc. eval "$(scripts/bb-dev-app env)" mkdir -p /tmp/bb1984-qa-repo && (cd /tmp/bb1984-qa-repo && git init -q && echo hi > README.md && git add -A && git commit -qm init) curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \ -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/bb1984-qa-repo","hostId":"host_2heij6qwhf"}}' # -> proj_pfjpq6n9sf - Spawn one codex thread and let it finish a tiny turn:
pnpm bb:dev thread spawn --project proj_pfjpq6n9sf --provider codex --permission-mode accept-edits \ --title "1984 repro" --prompt "Reply only with ok." --json # -> thr_aybwzhurtt # a minute later: ls $CODEX_HOME/sessions/2026/08/20/ rollout-2026-08-20T14-45-24-01a01fa2-483c-7bb1-b27d-c699b1032f26.jsonl # codex thread id 01a01fa2-…
- Start the "Codex Desktop stand-in": a separate app-server that lists unarchived threads and then listens 45 s (second-app-server-observer.mjs):
CODEX_HOME=/tmp/bb1984-codex-home node second-app-server-observer.mjs 01a01fa2-483c-7bb1-b27d-c699b1032f26 45 &
- While it listens, archive the thread from bb:
$ pnpm bb:dev thread archive thr_aybwzhurtt Thread thr_aybwzhurtt archived
Observer output (observer.log):thread/list archived:false BEFORE -> 1 threads; contains 01a01fa2-483c-7bb1-b27d-c699b1032f26: true listening 45s for notifications... archive the thread from bb now thread/archived notifications received by THIS app-server: 0 all notification methods received: ["remoteControl/status/changed"] thread/list archived:false AFTER -> 0 threads; contains 01a01fa2-483c-7bb1-b27d-c699b1032f26: false thread/list archived:true AFTER contains 01a01fa2-483c-7bb1-b27d-c699b1032f26: true
Expected (if Codex Desktop's catalogue were to be pruned): athread/archivednotification. Actual: none. The shared state DB does know (re-listing shows the thread gone / archived), which is exactly what Codex Desktop never re-reads after its one full reconciliation. - On-disk side effects of bb's archive (bb did its part):
$ find $CODEX_HOME -name 'rollout-*' /tmp/bb1984-codex-home/archived_sessions/rollout-2026-08-20T14-45-24-01a01fa2-483c-7bb1-b27d-c699b1032f26.jsonl $ sqlite3 $CODEX_HOME/state_5.sqlite "select id, archived, datetime(updated_at,'unixepoch') from threads;" 01a01fa2-483c-7bb1-b27d-c699b1032f26|1|2026-08-20 14:45:28 # archive happened at 14:46:20; updated_at not bumped
- Control — same process gets the notification (same-process-archive.mjs, log):
thread/unarchive -> {"thread":{"id":"01a01fa2-…", …}} notification thread/unarchived {"threadId":"01a01fa2-483c-7bb1-b27d-c699b1032f26"} thread/archive -> {} notification thread/archived {"threadId":"01a01fa2-483c-7bb1-b27d-c699b1032f26"} - Codex Desktop internals (no Mac needed): download the official Linux build, extract
app.asar, and run extract-asar-excerpts.py; output saved as codex-desktop-26.818.21641-asar-excerpts.txt.curl -sLO https://persistent.oaistatic.com/codex-app-prod/linux/deb/pool/main/c/chatgpt/chatgpt_26.818.21641_amd64.deb ar x chatgpt_26.818.21641_amd64.deb && tar -xf data.tar.xz ./usr/lib/chatgpt/resources/app.asar npx --yes @electron/asar extract usr/lib/chatgpt/resources/app.asar app && cd app && python3 extract-asar-excerpts.py
Repro files: 1984/repro/. No screenshots: the visual symptom is in Codex Desktop, which I could not run here.
5. Root cause
bb side (correct, but blind to Codex Desktop). Archiving a thread in bb runs archiveThreadWithLifecycleEffects → dispatchSettledArchivedThreadProviderArchiveCommand → runtime archiveOrUnarchiveThread → provider bridge case "thread/archive", which issues thread/archive on a codex app-server child bb owns (handleThreadMaintenance → withChildForThread/maintenance child). Codex app-server's thread/archived notification is delivered only to clients of that process. Nothing in bb touches Codex's IPC socket (grep -rn "ipc.sock\|codex-ipc" over the repo is empty).
Codex Desktop side (the actual defect). From the extracted bundle (main-Cwjv9Ibf.js, src-PzwkD6WC.js, webview/assets/app-initial-*.js):
// catalogue row removal
handleNotification(e){… if((e.method===`thread/archived`||e.method===`thread/deleted`)&&t!=null){… this.publishMutation(this.store.applyAuthoritativeRemoval(t),[]);return} …}
applyAuthoritativeRemoval(e){… this.db.prepare(`DELETE FROM local_thread_catalog WHERE host_id = ? AND thread_id = ? RETURNING missing_candidate`) …}
// when a full scan happens
isFullReconciliationDue(e){return!e.isComplete||e.lastFullReconciliationAt==null}
requestStartupSync(){… this.requestRun(this.fullReconciliationDue?`full`:`incremental`, …)}
// what incremental scans ask for
r.listThreads({archived:!1,cursor:t,limit:i,parentThreadId:null,sortKey:`updated_at`,sortDirection:`desc`,sourceKinds:n.ki,useStateDbOnly:!0}, …)
So the catalogue is authoritative for the sidebar and is only ever corrected by (a) notifications from the Desktop's own app-server child or (b) a one-time full scan. An archive performed by any other app-server process (bb, the CLI's codex resume picker, scripts, bb archive-codex-tmp-bb-sessions) satisfies neither. Because the state DB's updated_at is not bumped on archive, not even a watermark-based incremental would catch it. The reporter's "secondary, upstream" point is right: a missed notification is unrecoverable until the catalogue is rebuilt.
The IPC bus. Codex clients (Desktop windows, IDE extensions, TUI /ide) share a router on <codexHome>/ipc/ipc.sock. The webview's archive flow ends with broadcastThreadArchived({hostId:"local", conversationId, cwd}); the router forwards the frame to every other registered client; the Desktop main process forwards thread-archived to the webview, whose handleThreadArchived suppresses the conversation in memory and removes the thread summary. That is why the reporter's manual broadcast makes the row vanish. It is a UI-session effect; the catalogue row remains.
6. Proposed fix (first principles)
Where the bug really belongs: Codex Desktop. Its catalogue should reconcile against the shared state DB (e.g. periodic or on-focus full reconciliation, or an incremental pass over archived:true threads since the watermark), and/or Codex app-server should fan thread/archived out across processes. Worth filing upstream (none of the existing openai/codex issues I found covers "archive from another app-server process is invisible to Desktop").
What bb could do (best-effort mitigation, as the reporter asks). In the codex provider bridge only (it is provider translation, the daemon/server boundary is untouched and no wire shape changes, so no HOST_DAEMON_PROTOCOL_VERSION bump): after a successful thread/archive/thread/unarchive in handleThreadMaintenance (and the thread/discard → archive mapping), connect to join(codexHome,"ipc","ipc.sock") (Windows \\.\pipe\codex-ipc), where codexHome comes from the app-server initialize result (result.codexHome — see the observer log) or resolveCodexHome() in @bb/config; write one frame u32LE(len) + JSON({type:"broadcast", method:"thread-archived"|"thread-unarchived", sourceClientId:"bb", params:{hostId:"local", conversationId:<codex thread id>, cwd}, version:2|1}); close. Fire-and-forget with a short timeout; never let it fail or delay the archive result. Keep the version numbers in one constant with a comment that they must match Codex's table ("thread-archived":2, "thread-unarchived":1 in 26.818).
What could go wrong: (1) the protocol is private and version-checked exactly — a Codex bump silently disables it (acceptable for best-effort, but add a note/test so nobody believes it is guaranteed); (2) only the running Desktop session is updated; after restart the catalogue row likely reappears, so users may still see stale rows and the issue's title is not fully fixed by this; (3) hostId must be the Desktop's id for the local host ("local"); remote/SSH hosts would need their Desktop host id, which bb does not know — skip non-local; (4) bb also archives codex threads from the server-side script archive-codex-tmp-bb-sessions and for environment cleanup — route them through the same helper or accept they stay silent; (5) the unix socket dir is chmod 700 by the Desktop; bb runs as the same user so connect works, but do not create the dir/socket if absent (that would make bb the "router"). Cheapest honest alternative: document the limitation and point users at a Desktop-side rebuild (sign out / clear catalogue) while pushing the upstream fix.
7. PR review
No open PR is linked to this issue.
8. Related issues
- openai/codex #23851 "Desktop: thread.archived silently reset to 0 on next app cycle for any thread whose sessions/<rollout>.jsonl exists" — the mirror problem (Desktop re-deriving archive state from disk in a different way).
- openai/codex #27159 "Codex Desktop hides active local threads from sidebar/search while state_5.sqlite still marks them unarchived" — another catalogue-vs-state-DB divergence.
- get-bb/bb #1924 "Archiving a thread fails with 409 once its environment row has been pruned" — bb-side archive path, different failure.
packages/scripts/src/commands/archive-codex-tmp-bb-sessions.ts— bb's bulk archiver uses the samethread/archivecall and has the same Desktop-visibility gap.
9. Appendix
Commands run (abridged)
gh issue view 1984 --repo get-bb/bb --comments git checkout c7c66423d && pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build export CODEX_HOME=/tmp/bb1984-codex-home; scripts/bb-dev-app current curl -s -X POST http://localhost:25270/api/v1/projects … (project proj_pfjpq6n9sf on /tmp/bb1984-qa-repo, host host_2heij6qwhf) pnpm bb:dev thread spawn --project proj_pfjpq6n9sf --provider codex --permission-mode accept-edits --title "1984 repro" --prompt "Reply only with ok." --json node second-app-server-observer.mjs 01a01fa2-483c-7bb1-b27d-c699b1032f26 45 # background pnpm bb:dev thread archive thr_aybwzhurtt find $CODEX_HOME -name 'rollout-*'; sqlite3 $CODEX_HOME/state_5.sqlite "select id, archived, datetime(updated_at,'unixepoch'), rollout_path from threads;" node same-process-archive.mjs 01a01fa2-483c-7bb1-b27d-c699b1032f26 curl -sLO …/chatgpt_26.818.21641_amd64.deb; ar x …; tar -xf data.tar.xz ./usr/lib/chatgpt/resources/app.asar; npx @electron/asar extract … ; python3 extract-asar-excerpts.py curl -sL https://raw.githubusercontent.com/openai/codex/main/codex-rs/tui/src/ide_context/ipc.rs # socket paths + framing cross-check git fetch origin main; git log c7c66423d..origin/main --oneline -- plugins/provider-codex packages/agent-runtime/src/runtime.ts apps/server/src/services/threads/thread-archive.ts # empty pnpm dev:stop; rm -rf ~/.bb-dev/projects-bb-.claude-worktrees-wf_926b3193-f6c-15-2af1debe7c2c /tmp/bb1984-codex-home /tmp/bb1984-qa-repo /tmp/bb1984-deb
bb thread row after archive (dev bb.db)
sqlite3 …/bb.db "select id, archived_at, provider_id from threads where id='thr_aybwzhurtt';" thr_aybwzhurtt|1787237181137|codex
codex-rs cross-check of the IPC transport (public source)
// codex-rs/tui/src/ide_context/ipc.rs
fn primary_ipc_socket_path(codex_home: &Path) -> PathBuf { codex_home.join("ipc").join("ipc.sock") }
fn legacy_ipc_socket_paths(temp_dir, uid) … temp_dir.join("codex-ipc").join(format!("ipc-{uid}.sock"))
fn default_ipc_socket_path() -> PathBuf { PathBuf::from(r"\\.\pipe\codex-ipc") } // windows
fn write_frame(stream, message) { … stream.write_all(&payload_len.to_le_bytes())?; stream.write_all(&payload)?; }
// message types seen: "request", "response", "broadcast", "client-discovery-request/response"