← reports

#1984 · Archiving a codex thread leaves the session in Codex Desktop's list forever

Bug Priority: Low Effort: n/a providers provider-codex open on GitHub 2026-08-20 · base c7c66423d

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 issueStatusEvidence
bb archives the Codex session: rollout moves to archived_sessions/, threads.archived flips to 1 in state_5.sqliteVerifiedLive 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 thereVerifiedbridge.ts#L1723-L1735handleThreadMaintenance; launch command codex app-server.
Codex Desktop's own app-server never hears about it; no thread/archived notification reaches itVerified (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 columnVerifiedINSERT 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/deletedapplyAuthoritativeRemoval, or a full reconciliationVerifiedasar excerpts: handleNotificationapplyAuthoritativeRemoval (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 againVerifiedVerbatim 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})Verifiedfunction 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 archiveVerifiedSocket 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 sUnverified live / Plausible from codeReceiver path: router forwards to every registered client except sender → main-process client → webview handleThreadArchivedevictConversation(…,"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 specVerifiedWire 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"UnverifiedReporter's machine data; consistent with the mechanism.

3. Environment

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.)

  1. 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
  2. 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-…
  3. 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 &
  4. 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): a thread/archived notification. 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.
  5. 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
  6. 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"}
  7. 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 archiveThreadWithLifecycleEffectsdispatchSettledArchivedThreadProviderArchiveCommand → runtime archiveOrUnarchiveThread → provider bridge case "thread/archive", which issues thread/archive on a codex app-server child bb owns (handleThreadMaintenancewithChildForThread/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

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"

Files