Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from Settings → Dashboard to the Claude Code tab's AgentHub sub-tab (ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub. A home that is or contains the OS home is refused (EBADHOME). Settings persistence: dashboard-saved settings go to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin cache an update wipes. Load order: real env > the checkout's .env > that file. The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put. C1: codex-usage.js reads Codex rollout journals into sessions (new column tool) and token_usage, tracked per file in codex_rollouts; forks deduped by total vector, sub-agent threads folded into the root; pricing-sync also prices OpenAI models; PricingSettings with a vendor filter. C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on a click, through a stable forwarder; Codex trust is never written. The hook route creates tool='codex' sessions and folds sub-agent hooks into the root. D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab; Gemini pricing. Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=, clear-data covering the new tables, Codex child-thread events moving no status, hooks owning status only while current, EFOREIGN for a forwarder that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE honoured from .env. Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
10 KiB
Codex parity, part C1: sessions, token usage and pricing
Issue: #40 part C. Date: 2026-09-29. Status: self-reviewed (the user asked for autonomous work with a self-review per part). Part C2 (Codex hooks) is separate.
Problem
Codex emits no hook AgentHub listens to, so a Codex session never reaches the
sessions table and its tokens cost nothing on any page. The pricing table
and the automatic pricing sync know Claude models only.
What Codex writes (verified on 0.155, 178 real rollouts)
$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread id>.jsonl, append-only:
-
Line 1 is
session_meta, with these payload fields:id: the thread.session_id: the ROOT session. It is the same asidexcept in a sub-agent thread, whosethread_sourceissubagent. Older CLIs omit it.cwd,timestamp,cli_version.forked_from_id: set when the file is a fork.
-
turn_context.payload.modelis the model for the lines that follow. It can change mid-session, or be a router id such asclinepass/cline-pass/deepseek-v4-pro. -
event_msgwithpayload.type == "token_count"carries two things:info.total_token_usage: a cumulative total for the thread.info.last_token_usage: the delta.
infomay be null. The same total is often emitted twice in a row. -
Token fields:
input_tokensincludescached_input_tokens.output_tokensincludesreasoning_output_tokens.cache_write_input_tokensis separate.
-
A fork COPIES its parent's whole history into its own file,
token_countevents included, with rewritten timestamps. It then carries on from the parent's cumulative total. Summing the fork file naively counts the parent twice.
Design
sessions.toolis a new column:TEXT NOT NULL DEFAULT 'claude', added with the additiveALTER TABLEidiom thesourcecolumn used. Every existing row and every hook-created row staysclaude.codex_rolloutsis a new table recording how far each file has been read:- Columns:
pathPK,session_id(root),thread_id,size,mtime_ms,offset,stateJSON,updated_at. stateholds the current model, the last total seen, the per-model usage sums, and the meta.- A file whose size and mtime are unchanged is not read again.
- A grown file is read from
offsetup to its last newline. - A shrunken file is re-read from 0.
- Because this table lives in the DB, a session the user deleted stays deleted until Codex writes to its file again, while wiping the DB re-imports everything.
- Columns:
server/lib/codex-usage.jsdoes the ingest:-
parseRolloutChunk(text, state, skip)is pure. It JSON-parses only the lines it needs, picked by five needles (NEEDLES):session_meta,turn_context,token_count, and a prompt asuser_message(older CLIs) orUserMessage(0.149+). Each new total adds itslast_token_usageto the current model, mapped as:- input =
input − cached - cache_read =
cached - cache_write =
cache_write_input_tokens - output =
output_tokens
OpenAI bills reasoning as output.
- input =
-
A total equal to the previous one is skipped.
-
Forks: when a file with
forked_from_idis read from offset 0, the parent's file is found by the-<id>.jsonlname. Every total vector in the parent is skipped in the child: the copied prefix matches the parent exactly, and the child's own totals never do. If the parent's file is gone, nothing is skipped and the overcount is logged. -
ingestCodexUsage({ broadcast }):- Walks every rollout (no newest-40 cap) and yields to the event loop between files.
- For each root session touched, it sums its threads' usage per model and writes it with
stmts.setTokenUsage. The totals are exact recomputes, so the high-water baseline ofreplaceTokenUsagewould be wrong here. - It then inserts or updates the
sessionsrow:tool='codex'name= thethread_namefromsession_index.jsonl, else the first prompt (first line, 60 chars;user_messageor the 0.149+item_completedUserMessage), elseCodex <8>cwdandstarted_atfrom the root metamodel= the latest modelstatus=activewhile any of the session's files was written in the last 2 minutes, elsecompleted, withended_at= the newest journal's mtime- metadata
{source:"codex-rollout"}
- It never touches a row whose
toolis notcodex, and never setstranscript_path, so the Claude transcript parsers and sweeps skip these rows. - It broadcasts
session_created/session_updated. - One pass at a time. It runs at startup (in the background) and then every
DASHBOARD_CODEX_POLL_MS, the codex lane poll's own setting (0turns both off).
-
- Pricing sync learns OpenAI.
familyOfalso acceptsgpt-…,o<digit>…andcodex-…ids, dropping a-YYYY-MM-DDsuffix.toRowtakeslitellm_provider == "openai"entries. A cache write costscache_creation_input_token_costor 0, since OpenAI has no cache-write charge by default. The fast rate is 0. The display name comes fromdisplayName(gpt-5.6-sol→GPT-5.6 Sol).- Long-context (>272k) and priority/flex tiers are not modelled. Codex's context window stays under 272k.
POST /api/settings/reset-pricingstarts a sync right after reseeding, so OpenAI rows wiped by a reset return at once.
- Model Pricing in Codex → AgentHub.
- The pricing section of
ClaudeAgentHubSettings.tsxbecomescomponents/settings/PricingSettings.tsxwith avendorprop. The Claude tab showsclaude-…rows; the Codex tab shows OpenAI rows. - A Show all models switch shows every row, since one table prices every tool's sessions (a router model may serve either). A row added outside the filter turns the switch on, so it never vanishes from view.
- Reset Defaults stays on the Claude tab only and sends
{scope: "claude"}: it deletes and reseeds onlyclaude-…rows, so OpenAI rows (and a custom router row) survive it; a reset with no body wipes every row, and OpenAI rows come back from the sync.
- The pricing section of
- Claude-only surfaces exclude Codex sessions.
- The Workspace's external sessions, which offer attach and
claude --resume, listtool === 'claude'only. - The Sessions list and session detail show a Codex badge.
- The Workspace's external sessions, which offer attach and
Found in the browser check, fixed
- The Claude liveness reap and the transcript watchdog (
routes/hooks.js) probed Codex sessions for aclaudeprocess and reaped them: both now selecttool = 'claude'only. - The pricing sync had already run when the first ingest wrote new models; a pass that writes sessions now triggers a sync.
Found in the code review, fixed
- A journal met under a new path counted twice (CODEX_HOME moved, or a symlinked alias): there is now one
codex_rolloutsrow per thread, and reading a file drops the thread's rows under other paths. - The Claude resume picker offered Codex sessions (
RunSetup.tsx), whichclaude --resumecannot open: it liststool === 'claude'only. - A failed price-list fetch was retried on every Codex pass (every 15 s): a failure now waits for the sync's own 15-minute check.
- A fork's copied history arriving after the first read was counted: the parent's totals are skipped until the fork's first own total (
state.pastFork), on every read, not just the one from offset 0. - A journal written in the future (a clock running ahead) kept its session
active: it no longer counts as activity. - A large journal blocked the event loop: one pass reads at most 16 MB of a file, and a fork's parent is streamed.
- A model gone from a re-read journal kept its old usage row: a Codex session's usage is rewritten whole.
- A sub-agent file read before its root fixed the session's cwd, start and model: the root thread's file sets them once it is read.
- The startup cleanup completed active Codex sessions and a Claude lane's purge deleted a Codex lane's sessions at the same directory: both are scoped by tool.
- The codex lane mirror flapped (
codex-ingest.js, predates C1): a lane with an old journal beside the live one went running → idle → running on alternate passes, and a journal written in the current millisecond read as future. One verdict per lane now, with a second of slack.
Found in the whole-change review, fixed
- Hooks kept a session's status forever. A Codex session with any hook event kept its hook-given status even after the hooks were removed or no longer trusted, so it could stay
activefor good. Hooks now own the status only while current —hooksCurrent: the session's newest event is at or after its newest journal write minus 2 minutes — and otherwise the journal decides again, insettleActivetoo. - Clear data stopped Codex (and Antigravity) sessions ever coming back.
POST /api/settings/clear-datakeptcodex_rollouts, so a cleared session was never re-imported, or came back half on its next write: it now emptiescodex_rolloutsandantigravity_conversationstoo. - The per-thread lookup scanned the table:
idx_codex_rollouts_threadindexescodex_rollouts(thread_id). - A busy Codex lane pushed Claude rows out of a page:
GET /api/sessionstakes?tool=, applied before the limit, and the Workspace attach list and the run form's resume picker ask fortool: "claude". - Reset Defaults wiped the OpenAI rows a user had edited: the Claude tab's reset is scoped to
claude-…rows (above).
Left as is: a journal rewritten to the same or a larger size is not detected (Codex only appends), and a fork without a Codex thread name is named after the prompt it copied from its parent.
Not changed
Hook ingestion, transcript-cache.js, import-history.js and remote sync still handle Claude Code only. Codex lanes keep their idle/running mirror in codex-ingest.js. Session↔lane binding is untouched.
Tests
- Server:
- Chunk parser: mapping, duplicate totals, model switch, null info, partial last line.
- Fork skip, with and without the parent file.
- Sub-agent thread folded into the root session.
- Incremental read after an append; a shrunken file re-read.
- An unchanged file not re-read; a deleted session not resurrected.
- A claude row never touched.
- Pricing sync for an OpenAI family.
- Reset triggers a sync.
- Client:
PricingSettingsvendor filter and the show-all switch; the resume picker excluding codex sessions; the Codex badge. - Browser: an isolated server on a temp
CODEX_HOMEwith copied real rollouts.