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

124 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.