TermAl

Feature Brief: Shared Codex App-Server

This document describes how TermAl talks to Codex, and the identity model that everything in this area depends on. It is the hardest subsystem in the codebase to reason about; most of the bugs here have been subtle races or wrong invariants, so this brief leads with the mental model and the pitfalls rather than a call graph.

Primary implementation:

Related briefs: agent-delegation-sessions.md, sqlite-session-storage.md. Architecture overview: ../architecture.md.

The one fact everything follows from

Codex does not run one process per chat. One long-lived Codex process hosts every local Codex session in a TermAl backend at once (the “shared app-server”; remote-proxied sessions run on their own remote backend’s app-server, and the handle lives on that backend’s AppState). TermAl attaches many logical sessions to it and multiplexes JSON-RPC over a single stdio pipe: thread/start / thread/resume open a conversation thread, turn/start runs a turn.

Because the process is shared and long-lived, its identity cannot answer per-session questions. That single fact is the source of nearly every bug in this area.

Two identities: process vs attachment

There are two different “who is this?” questions, and conflating them is the classic mistake:

Why it matters

A response or event that belongs to a torn-down attachment can arrive late, after the session has re-attached. Guarded only by the process id, it still “matches” and acts on the live session — e.g. a stale thread-setup waiter overwrites the live attachment’s persisted thread id. The record then claims thread A while the runtime runs thread B; on restart TermAl resumes the wrong thread and rediscovers B as a duplicate. That is the session leak reappearing through a side door.

The attachment epoch (parked)

The fix is a generation stamp: CodexAttachment { runtime_id, generation }, minted per attachment in spawn_codex_runtime and threaded through every per-session guard so the weak process token is unreachable on those paths. This work is parked (git stash; spec in beads tm-d22) because the reproducible leak is already fixed and the remaining failures need a detach at an exact instant. Do not restart it casually — it took many rounds and repeatedly re-introduced same-class bugs. See tm-d22, tm-c7l, tm-nqc.

Thread setup and parking (the fixed leak)

The reproducible leak — one prompt minting many Codex threads — is fixed (commit 4203b31). The mechanism and its fix are worth understanding because the whole parking model exists for it.

Trap: the “prompt handling and turn start are serialized on the writer thread” invariant does not extend to waiters. The StartTurnAfterSetup hand-off is enqueued by a waiter thread, so a setup can be in flight when the hand-off runs. Reasoning that ignores this produced multiple wrong “this cannot happen” comments and one debug_assert! that actually fired.

Orphan-thread discovery and suppression

Codex persists its threads to its own state DB. At boot, import_discovered_codex_threads scans that DB and imports threads TermAl does not recognize as top-level “ghost” sessions, so history from Codex runs outside TermAl is not lost. This feature is also how orphans become visible clutter:

Cleanup pattern (used when phantoms accumulate): cross-check each candidate is truly empty against the SQLite blob, then POST /api/sessions/{id}/kill — which both removes the record and suppresses the thread. Verify the killed thread ids landed in the persisted ignore set afterward.

Pitfalls learned the hard way

Status summary