Files
AgentHub/docs/superpowers/specs/2026-09-29-codex-usage-design.md
T
nntrivi2001 969b06253c feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
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.
2026-09-29 16:09:19 +07:00

10 KiB
Raw Blame History

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 as id except in a sub-agent thread, whose thread_source is subagent. Older CLIs omit it.
    • cwd, timestamp, cli_version.
    • forked_from_id: set when the file is a fork.
  • turn_context.payload.model is the model for the lines that follow. It can change mid-session, or be a router id such as clinepass/cline-pass/deepseek-v4-pro.

  • event_msg with payload.type == "token_count" carries two things:

    • info.total_token_usage: a cumulative total for the thread.
    • info.last_token_usage: the delta.

    info may be null. The same total is often emitted twice in a row.

  • Token fields:

    • input_tokens includes cached_input_tokens.
    • output_tokens includes reasoning_output_tokens.
    • cache_write_input_tokens is separate.
  • A fork COPIES its parent's whole history into its own file, token_count events included, with rewritten timestamps. It then carries on from the parent's cumulative total. Summing the fork file naively counts the parent twice.

Design

  1. sessions.tool is a new column: TEXT NOT NULL DEFAULT 'claude', added with the additive ALTER TABLE idiom the source column used. Every existing row and every hook-created row stays claude.
  2. codex_rollouts is a new table recording how far each file has been read:
    • Columns: path PK, session_id (root), thread_id, size, mtime_ms, offset, state JSON, updated_at.
    • state holds 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 offset up 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.
  3. server/lib/codex-usage.js does 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 as user_message (older CLIs) or UserMessage (0.149+). Each new total adds its last_token_usage to 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.

    • A total equal to the previous one is skipped.

    • Forks: when a file with forked_from_id is read from offset 0, the parent's file is found by the -<id>.jsonl name. 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 of replaceTokenUsage would be wrong here.
      • It then inserts or updates the sessions row:
        • tool='codex'
        • name = the thread_name from session_index.jsonl, else the first prompt (first line, 60 chars; user_message or the 0.149+ item_completed UserMessage), else Codex <8>
        • cwd and started_at from the root meta
        • model = the latest model
        • status = active while any of the session's files was written in the last 2 minutes, else completed, with ended_at = the newest journal's mtime
        • metadata {source:"codex-rollout"}
      • It never touches a row whose tool is not codex, and never sets transcript_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 (0 turns both off).
  4. Pricing sync learns OpenAI.
    • familyOf also accepts gpt-…, o<digit>… and codex-… ids, dropping a -YYYY-MM-DD suffix.
    • toRow takes litellm_provider == "openai" entries. A cache write costs cache_creation_input_token_cost or 0, since OpenAI has no cache-write charge by default. The fast rate is 0. The display name comes from displayName (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-pricing starts a sync right after reseeding, so OpenAI rows wiped by a reset return at once.
  5. Model Pricing in Codex → AgentHub.
    • The pricing section of ClaudeAgentHubSettings.tsx becomes components/settings/PricingSettings.tsx with a vendor prop. The Claude tab shows claude-… 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 only claude-… 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.
  6. Claude-only surfaces exclude Codex sessions.
    • The Workspace's external sessions, which offer attach and claude --resume, list tool === 'claude' only.
    • The Sessions list and session detail show a Codex badge.

Found in the browser check, fixed

  • The Claude liveness reap and the transcript watchdog (routes/hooks.js) probed Codex sessions for a claude process and reaped them: both now select tool = '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_rollouts row per thread, and reading a file drops the thread's rows under other paths.
  • The Claude resume picker offered Codex sessions (RunSetup.tsx), which claude --resume cannot open: it lists tool === '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 active for 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, in settleActive too.
  • Clear data stopped Codex (and Antigravity) sessions ever coming back. POST /api/settings/clear-data kept codex_rollouts, so a cleared session was never re-imported, or came back half on its next write: it now empties codex_rollouts and antigravity_conversations too.
  • The per-thread lookup scanned the table: idx_codex_rollouts_thread indexes codex_rollouts(thread_id).
  • A busy Codex lane pushed Claude rows out of a page: GET /api/sessions takes ?tool=, applied before the limit, and the Workspace attach list and the run form's resume picker ask for tool: "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: PricingSettings vendor filter and the show-all switch; the resume picker excluding codex sessions; the Codex badge.
  • Browser: an isolated server on a temp CODEX_HOME with copied real rollouts.