Skip to content

[opus4.7] Detect Codex CLI sessions on par with Claude Code and OpenCode - #658

Merged
srid merged 22 commits into
masterfrom
codex-provider
Apr 23, 2026
Merged

[opus4.7] Detect Codex CLI sessions on par with Claude Code and OpenCode#658
srid merged 22 commits into
masterfrom
codex-provider

Conversation

@srid

@srid srid commented Apr 22, 2026

Copy link
Copy Markdown
Member

Codex now lights up the tile chrome the same way Claude Code and OpenCode do — state pill (thinking / tool_use / waiting), session title, model name, and a running context-token count on every terminal where codex is the foreground process. Dropping into a Codex session no longer looks like running a plain shell.

The integration watches two things Codex writes atomically on every turn: its threads SQLite DB (the highest-numbered ~/.codex/state_<N>.sqlite, auto-discovered at startup — indexed metadata columns) and the per-thread rollout JSONL (~/.codex/sessions/…/rollout-*.jsonl — the event stream carrying task_started / task_complete / function_call pairings and per-turn token_count records). Both files share mtimes to the nanosecond, so one fs.watch on state_<N>.sqlite-wal covers both sources — the watcher re-reads threads.{title, model} on every event and tails the rollout only when its byte size grows.

The design splits cleanly because each source owns what the other doesn't expose. SQLite has no state column, so lifecycle transitions live only in the event stream. JSONL has no indexed title, so fishing it out would cost a full-file parse per refresh. The context-token count comes from the JSONL's latest token_count.info.last_token_usage.input_tokensnot threads.tokens_used, which is the session-lifetime cumulative total and climbs into millions over a long session. OpenAI's schema bakes the cached portion into input_tokens (unlike Anthropic's disjoint buckets), so Kolu treats cached_input_tokens as a breakdown and never adds it — matching exactly what Codex's own /status reports.

Robustness against upstream drift

Codex bumps SQLite schema versions occasionally (state_5, with logs_2 alongside); a user upgrading past our pin would silently stop getting a Codex badge. Two layered guards address that: (1) filename auto-discovery enumerates state_*.sqlite and picks the highest N at startup, so a future state_6.sqlite is picked up transparently; (2) a column-level schema guard runs PRAGMA table_info(threads) on the first DB open and logs a one-shot error listing observed vs required columns if any depended-on column (id, rollout_path, cwd, source, archived, updated_at_ms, title, model) is renamed or dropped. Silent zero-match breakage becomes a loud, actionable log line pointing at KOLU_CODEX_DB for a manual pin.

State derivation is per-turn, not per-tail

parseRolloutState tracks open function_call call_ids scoped to the current turn only. A function_call with no matching _output that straddles a task_started boundary — user aborted a prior tool-using turn, or the tail head clipped the closing output — would otherwise pin state at tool_use forever into the next turn. Clearing openCalls at each task_started matches the intent of the state machine.

Wiring was mechanical everywhere outside the new package

A new AgentProvider instance, one line in startProviders, one fill on each Record<AgentInfo["kind"], …> map (TS refuses to compile until filled), and one branch on the discriminated union. The preexec allowlist already had codex, and APM vendoring for Codex skills landed previously.

What we can't detect yet: Codex has no TodoWrite equivalent (per-turn task_started/task_complete are lifecycle events, not user-facing checklists), so taskProgress stays permanently null for Codex sessions. E2e coverage of the full waiting→thinking→tool_use→waiting loop is tracked in #664 (ollama-in-Nix test plan, shared with opencode).

Try it locally

```sh
nix run github:juspay/kolu/codex-provider
```

srid added 10 commits April 22, 2026 12:55
Adds a codexProvider on par with claudeCodeProvider and opencodeProvider
so any terminal running the `codex` TUI gets the same live state badge
(thinking / tool_use / waiting), title, model, and running context-token
count as the other two agents.

Why two sources? Codex writes both `~/.codex/state_5.sqlite` (threads
table — pre-summed metadata including tokens_used) and per-thread
rollout JSONLs atomically in the same cycle. Metadata (title, model,
tokens) comes from indexed column reads; state transitions come from
tailing the rollout for task_started / task_complete / function_call
pairings. One fs.watch on the SQLite WAL file covers both.

Kolu's AgentProvider seam (anyagent) meant no new server-side adapter
file — one import + one line in startProviders, one Record<...> fill on
each of agentIcons/agentNames (TS refuses to compile until filled), and
a new discriminated-union branch on AgentInfoSchema. Preexec allowlist
already had `codex`.
The field was copied from the threads row at match time, but the
watcher never reads it — every refresh re-queries title via
getThreadMetadata(). Dead data complecting identity with presentation
state; OpenCode's equivalent field is used as a fallback, Codex's
never is.
The field's comment promised it was 'stored so the watcher can log it
without a re-query,' but no log or code path reads it. The watcher
uses session.id and session.rolloutPath; the agent-provider reads
state.cwd from the AgentTerminalState snapshot, not the session.
Remove the field and its SQL projection.
Before: the "row missing after match" path logged at debug, same
structural shape as the expected "no task events yet" path a few
lines below. An operator filtering on >=warn saw neither — the real
anomaly (row disappeared mid-session) was indistinguishable from the
benign race window (fresh thread with no turns yet).

Fix: elevate the row-disappeared log to warn and rephrase it so the
two paths are distinct at every observable layer (message text,
severity, documented cause).
Before: deriveState returned null for three distinct cases (ENOENT
race, hard read error, rollout with no turns yet), and the caller
emitted a single debug 'codex rollout has no task events yet'
whenever state was null — misleading under the EACCES branch where
the error path had already logged.

Fix: each failure mode logs at its own site with its own severity
(ENOENT silent, hard failure at error, no-turns at debug), and the
caller treats null uniformly as 'skip this refresh'. Three modes
remain distinguishable to an operator via log level + message; the
caller no longer needs to reinvent the distinction.
…rt tail

Tracking latest task_started and latest task_complete as two
independent turn_ids and declaring 'waiting' only when they match
misclassified the case where the tail chopped the current turn's
task_started but kept its task_complete — the session was returned as
thinking/tool_use when it should have been waiting. Triggers on
tool-heavy turns whose event volume exceeds TAIL_BYTES.

Collapse to a single 'last lifecycle event' signal. Codex guarantees
task_complete follows task_started for the same turn, so matching
turn ids was redundant; dropping the match also handles the
tail-chop case structurally.

New test covers the regression.
Per the errors-must-log-at-error project rule: non-ENOENT failures
(EACCES, EMFILE, EIO) in tryWatchWal and the parent-dir fallback mean
state detection is broken for every Codex session until resolved —
real failures, not expected-absent conditions. Promoting to `error`
lets operators filtering on >=error see them without a dedicated
debug pass.
…anged

Before: every debounced WAL fire re-read up to 256 KB from the
rollout JSONL and re-parsed every line, even when the WAL event was
for a DB-only change (e.g. `token_count` updating
`threads.tokens_used` on a turn whose last rollout line was already
in the prior parse).

After: cache last-parsed `{ size, state }`. On refresh, stat the
rollout first; if size matches the cache, reuse the state and skip
the open/read/parse pass. DB-only fires now cost one statSync + one
indexed SQLite SELECT + an info-equality check.

Split the previous `deriveState` into `statRollout` +
`readAndParseTail` so the size check is natural rather than an
internal flag. The parse short-circuits `parseRolloutState` entirely
when the underlying file bytes haven't changed — it's a pure function
of the tail, so same bytes ⟹ same output.
`statRollout` and `readAndParseTail` were taking `rolloutPath` and
`sessionId` as separate params, with `sessionId` threaded purely for
log enrichment. Since every caller has the full `CodexSession` in
scope, accept the whole object — the helpers now reach into
session.rolloutPath / session.id directly instead of being apologetic
about the extra arg.
…outState doc

The 'why not match turn ids' paragraph referenced a previous
implementation — content that belongs in git log, not a code comment.
The structural invariant it argued for is now stated alongside step 1
(turn ids deliberately unmatched, to cover chopped-tail case), and
the test 'returns waiting when tail kept task_complete but chopped
the start' encodes the regression concretely. Doc is now about the
current algorithm, not the path to it.
@srid

srid commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey CodexSession.title snapshot field never read Fixed in this PR
2 Hickey CodexSession.cwd "stored for logging" but never logged Fixed in this PR
3 Hickey meta===null (row vanished) logged at debug, indistinguishable from "no turns yet" path Fixed in this PR
4 Hickey parseRolloutState correctly scoped, no cross-contamination No-op
5 Hickey wal-watcher.ts is near-verbatim copy of opencode's Deferred (follow-up)
6 Hickey deriveState ENOENT vs hard-failure indistinguishable at the call boundary Fixed in this PR
Lowy No findings (all 7 volatility axes contained)

Hickey rationale

Six findings; four fixed as individual commits, one no-op (clean), one deferred.

#1 CodexSession.title snapshot — carried from the initial DB row into the session identity object, but the watcher re-reads title from the DB on every refresh. The snapshot was dead data with a comment promising it might be used as a fallback; unlike OpenCode's equivalent field it never was. Fixed in 9fb9598 by dropping the field and its SQL projection.

#2 CodexSession.cwd — same structural issue as #1. Comment claimed it was "stored so the watcher can log it without a re-query," but no log call referenced it. Fixed in f738d20.

#3 Row-missing log level — when getThreadMetadata returns null after the session was successfully matched, that's a real anomaly (Codex deleted the row mid-session), not an expected-absent condition. The original code logged at debug, conflating it with the expected "no turns yet" path. Fixed in 2bd4802 by promoting to warn with a distinct message.

#4 parseRolloutState — pure function, well-tested (10+ unit tests covering all branches including the new tail-chopped regression), no smuggling of SQLite or watcher concerns. Accepted as-is.

#5 WAL watcher duplicationwal-watcher.ts is near-verbatim identical to packages/integrations/opencode/src/wal-watcher.ts (same WalListener interface, same refcounted singleton, same parent-dir fallback, only the path constants differ). The deeper reuse review also surfaced withDb<T> as byte-identical between codex and opencode, and the JSONL tail reader duplicating claude-code's tailJsonlLines with better error handling. Together these are ~300 lines of structural duplication that'd collapse into an anyagent extraction — a clean follow-up PR that also fixes a latent FD leak in claude-code's tail reader. Deferred — not PR-blocking; each duplicate is individually readable.

#6 Rollout-skip log taxonomyderiveState previously returned null for three distinct failure modes (ENOENT race, hard read error, rollout with no turns yet), and the caller emitted a single codex rollout has no task events yet debug line for every null. Under the EACCES branch this was misleading — the error was already logged, and the caller added a second misleading line. Fixed in f10d810 by scoping each failure mode to its own log site with its own severity.

Lowy rationale

Volatility review surfaced seven axes — Codex schema version (state_56?), threads.source filter for subagent exclusion, rollout JSONL event shape, session identity, the two-source merge, server-pushed state delivery, and the taskProgress placeholder — and found all seven cleanly encapsulated. Schema version lives in one constant; subagent exclusion is one SQL WHERE clause; event shape is confined to parseRolloutState and a minimal RolloutLine interface guarded by unit tests; session identity is abstracted by AgentProvider.sessionKey(); the two-source merge lives in createCodexWatcher's refresh() without leaking outward; the WAL singleton correctly identifies itself as a cross-session resource rather than a per-session one; and taskProgress: null is schema uniformity, not padding. No actions.


Follow-up issue to be filed: extract shared { createWalSubscription, withDb, tailLines, createDebouncedWalWatcher } into anyagent. Collapses ~300 lines across codex + opencode and also fixes an FD-leak-on-throw bug in claude-code's tailJsonlLines.

@srid
srid marked this pull request as ready for review April 22, 2026 17:18
@srid

srid commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

/do results

Step Status Duration Verification
sync 0s forge=github
research 1m 52s SQLite threads + JSONL rollouts write atomically (ns-identical mtimes)
branch 22s codex-provider at origin/master
implement 4m 30s kolu-codex package + wiring (common/server/client)
check 0s pnpm typecheck clean (ran at end of implement)
docs 50s README Codex Status section + architecture prose
fmt 15s prettier normalized; 0 manual reformats
commit 21s 1592698 feat commit (16 files, 940/+6/-)
hickey+lowy 6m 33s Lowy: 0 findings. Hickey: 4 fix-in-PR, 1 no-op, 1 deferred
police 11m 58s Rules clean; 1 fact-check bug fixed; 4 elegance fixes
test 49s 11 unit tests pass; e2e skipped (no live-Codex harness)
create-pr 1m 8s Draft PR #658 + hickey/lowy findings comment
ci 2m 15s 10/10 contexts success on HEAD
Total 31m 29s

Slowest step: police (11m 58s)

Optimization suggestions

  • police dominated at 38% of wall-clock — three fan-out review agents (reuse/quality/efficiency) each take 1–3 min, plus individual-commit fix-apply loops. Consider running the simplify fanout with tighter scoping (e.g., pre-filter to files I edited rather than re-reading the full diff) to cut 2–3 min.
  • hickey+lowy at 6m 33s ran on a ~940-line diff; the two subagents were parallel but each independently read 10+ files. If a future PR is this size, passing --review-model=haiku would cut the review cost substantially without losing much signal given the well-trodden path.
  • Follow-up PR worth filing: extract shared { createWalSubscription, withDb, tailLines, createDebouncedWalWatcher } into anyagent. Collapses ~300 lines across codex + opencode and also fixes a latent FD-leak-on-throw bug in claude-code's tailJsonlLines. Flagged in the hickey analysis above.
  • CI was fast (2m 15s) — no tuning needed there.

Workflow completed at $(date -u +"%Y-%m-%dT%H:%M:%SZ").

srid added 4 commits April 22, 2026 13:25
Three integration packages (claude-code, codex, opencode) independently
re-implemented the same three patterns:

- **createWalSubscription**: refcounted fs.watch singleton on a SQLite
  WAL file with a parent-dir fallback. Codex and opencode each had
  byte-identical copies (~120 lines each).
- **withDb**: open-if-absent, close-if-owned wrapper around a SQLite
  handle with uniform error logging. Codex and opencode had the same
  ~20-line helper verbatim.
- **readTailLines**: read the trailing window of a line-oriented
  file, split into complete lines, drop any partial first line.
  Codex had a try/finally-correct version; claude-code's copy leaked
  the FD on readSync throw and silently collapsed all errors to [].

Extracting to anyagent folds the three into one source each, with
the library versions getting the stricter behavior (onError callback
for non-ENOENT failures, FD-close-on-throw, error-level log for hard
fs.watch failures per the project rule). Next commits wire the three
consumers to the shared helpers.

anyagent picks up a @types/node dev-dep for fs/path/NodeJS types.
…L subscribe)

codex/src/wal-watcher.ts collapses from 119 lines to 11 — all the
refcounted-singleton and parent-dir-fallback machinery now lives
upstream in anyagent.createWalSubscription.

codex/src/index.ts's withDb helper delegates to anyagent.withDb,
keeping the same partial-application signature locally so callsites
don't change.

codex/src/session-watcher.ts's readAndParseTail delegates to
anyagent.readTailLines, shedding the manual Buffer.alloc +
statSync/openSync/readSync/closeSync + line-split + partial-line
drop. Error-logging is unchanged (codex's own log-context fires via
the onError callback).

No behavior change; just shedding duplication with opencode +
claude-code.
opencode/src/wal-watcher.ts collapses from 130 lines to 11, matching
codex's shape. opencode/src/index.ts's withDb delegates to
anyagent.withDb while keeping the local call signature so no call
sites change.

No behavior change. OpenCode unit tests (7) still pass.
tailJsonlLines delegates the open/read to anyagent.readTailLines, which
closes the FD in a try/finally — fixing the pre-extraction leak where
a readSync throw (EIO, EINTR) skipped the closeSync. The caller
preserves its legacy silent-on-failure contract by ignoring onError
and flattening null to [].

No behavior change for the happy path. Claude Code unit tests (34)
still pass.
@srid

srid commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

Follow-up: shared helpers extracted into anyagent

Addressed the "Deferred" row from the Hickey analysis in this same PR. Four new commits:

# Commit Effect
d2c4ad5 feat(anyagent) New createWalSubscription + withDb + readTailLines helpers
7d53397 refactor(codex) wal-watcher.ts 119 → 11 lines; local withDb/tail-read delegate
df75a4f refactor(opencode) wal-watcher.ts 130 → 11 lines; local withDb delegates
9ab5a34 refactor(claude-code) tailJsonlLines delegates to shared reader

Net: 24 files, +1239 / −157, but the signal is the deduplication — three near-identical WAL watchers collapsed to one, two byte-identical withDb helpers to one, and the tail-read path unified across all three. The extraction also fixes a latent FD-leak bug in claude-code's tailJsonlLines: the shared readTailLines wraps openSync/readSync/closeSync in a try/finally so a readSync throw (EIO, EINTR after retries exhausted) no longer leaks the descriptor. Claude-code's original copy had no try/finally and leaked on every such throw.

All 308 unit tests pass across 10 packages; CI green on commit 9ab5a34 (10/10 contexts success).

The wal-subscription.ts factory's closure-private singleton means two createWalSubscription calls with different configs get independent singletons — no cross-contamination between codex and opencode when both are active in the same process. The onError callback on readTailLines lets each caller pick the severity for non-ENOENT errors: codex logs at error with session context, claude-code preserves its legacy silent-on-failure behavior by ignoring the callback.

Landing page's three agent mentions (agent-agnostic feature card,
live agent status card, hero paragraph) all listed claude + opencode;
now include codex in the same breath so the site reflects the three
agents kolu actually detects.
@srid srid changed the title Detect Codex CLI sessions on par with Claude Code and OpenCode [opus4.7] Detect Codex CLI sessions on par with Claude Code and OpenCode Apr 22, 2026
@srid

srid commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

Comparison: this PR vs #657 (Codex's take)

Read both diffs end-to-end. Here's a fair accounting — each approach has real wins the other missed.

Where this PR is stronger

Extracted three helpers into anyagent, not one. #657 extracted subscribeSqliteWal (good) and left withDb and the JSONL tail reader duplicated across integrations. This PR pulled all three — createWalSubscription, withDb, readTailLines — so every existing SQLite-watching integration and every future one converges on the same primitives. The extraction also fixed an FD-leak-on-throw bug in claude-code's tailJsonlLines as a side effect: the old copy had no try/finally around openSync/readSync, so readSync throws (EIO, EINTR after retries exhausted) leaked the descriptor. Commit 9ab5a34.

Reads threads.tokens_used for a live context-token badge. #657 hard-codes contextTokens: null (session-watcher.ts:92). Codex already pre-sums running totals from token_count events into that column — one SELECT surfaces it. This PR displays it; #657's badge is blank.

Size-based short-circuit on the hot path. createCodexWatcher caches last-parsed {size, state} and skips the JSONL re-read entirely when stat size is unchanged (session-watcher.ts:73-105). DB-only fires (every tokens_used tick) cost one statSync + one indexed SELECT. #657 re-reads 1MB of JSONL and re-parses every line on every WAL fire regardless of whether the rollout actually grew.

Hickey/Lowy review applied before merge. Commits 9fb9598 / f738d20 / 2bd4802 / f10d810 each address one structural finding (dead fields, log levels, param sprawl, ENOENT-vs-hard-error distinction). #657 has no corresponding review pass; reviewers get to find these cold.

Post-implement fact-check caught a logic bug. 4a46376 fixes a case where parseRolloutState misclassified "tail chopped the current turn's task_started but kept its task_complete" as thinking/tool_use. The regression test in index.test.ts:123-131 ("returns waiting when tail kept task_complete but chopped the start") encodes it. #657's deriveRolloutState handles this case too, via a different mechanism (see below).

Deeper unit coverage. 11 cases vs 5, including the tail-chop regression and open-call-across-multiple-turns scenarios.

Where #657 is stronger

Schema-version auto-discovery. config.ts:11-37 enumerates ~/.codex/state_*.sqlite, parses the version suffix, and picks the highest. When upstream ships state_6.sqlite, #657 picks it up automatically; this PR hard-codes state_5.sqlite and requires a constant bump or KOLU_CODEX_DB override. This is a better answer to the volatility that Lowy explicitly flagged in the review on this PR. Worth porting.

Per-turn tool scoping in deriveRolloutState. #657's index.ts:148-187 tracks lastBoundaryIndex and scopes pendingCalls to lines after that boundary. This PR's parseRolloutState tracks openCalls across the entire tail, which can mis-attribute an unpaired function_call from turn N-1 (whose function_call_output fell off the tail's head) to turn N. Edge case — requires the call-output to be chopped while the call survives, so typically only triggers when a single tool's output exceeds TAIL_BYTES. But #657's scoping handles it correctly and mine doesn't. Real correctness advantage.

Dual-watcher belt-and-suspenders. #657's createCodexWatcher installs its own fs.watch on the rollout JSONL in addition to subscribing to the WAL watcher (session-watcher.ts:45-91). I argued these are redundant (WAL + JSONL append atomically per verified nanosecond-identical mtimes) and skipped the second watcher. If that atomicity ever breaks — a future Codex release, a filesystem with weaker mtime semantics — #657 still picks up state transitions; mine goes silent.

Accepts both codex and codex-tui as foreground basenames. agent-provider.ts:10 uses a Set<string>. Mine only matches codex. I haven't confirmed which binary name Codex's installer actually uses across versions, but the inclusive check is cheap safety.

Simpler shared-watcher API. #657's subscribeSqliteWal(dbPath, walPath, ...) is a single free function keyed by dbPath in a Map; any caller with any path shares automatically. Mine's createWalSubscription({dbPath, walPath, label}) factory produces closure-private singletons — each call site that cares about sharing must route through the same factory instance. Both work; #657's is friendlier for ad-hoc callers. The closure-private design does give stronger isolation guarantees — two integrations watching the same DB-by-coincidence don't share, which is probably the right default — but the Map-keyed version is less ceremony.

Neither did

  • An e2e test for live Codex detection. (Same gap as opencode's tests.)
  • Reconciliation of the threads schema version as an actively-versioned upstream artifact with a mitigation for "user upgrades Codex, kolu goes blind until we re-release."

Net

Different dispositions toward the same problem. #657 ships a tight single-commit feature with one genuinely better architectural decision (schema-version auto-discovery) and one genuinely better algorithm (per-turn tool scoping). This PR ships a wider-net refactor (deduplication across three integrations, bug fix in claude-code) plus a reviewed & fact-checked provider that surfaces more data (tokens badge, richer docs) and optimizes the hot path (size short-circuit).

If I were merging one, I'd take this PR's extraction + docs + tokens + size-short-circuit as the base, then port #657's two wins (findCodexStateDbPath and the lastBoundaryIndex scoping in deriveRolloutState) on top. Happy to do that as follow-up commits if desired.

srid added 4 commits April 22, 2026 14:27
…okens_used

`threads.tokens_used` is the session-lifetime cumulative total
(total_token_usage.total_tokens summed across every turn, counting
each turn's cached re-read again). For long-running sessions it
climbs to tens of millions — the dainty-island thread was showing
17,024,190 against a 258,400-token context window, which is nonsense
as a context-pressure indicator.

The right analog of claude-code's `input_tokens +
cache_creation_input_tokens + cache_read_input_tokens` — tokens the
model actually had in its context this turn — is Codex's
`info.last_token_usage.input_tokens + cached_input_tokens` on the
latest `token_count` event. For the same dainty-island thread that
sums to 92,479 (~36% of the window), which is accurate.

Stop reading `threads.tokens_used` from SQLite; add
`parseRolloutContextTokens(lines)` that walks the JSONL tail
backward for the latest `token_count` event and sums
last_token_usage's two input-side fields. Wire it into
readAndParseTail so state and tokens come from one tail read; the
size-based short-circuit caches both. 8 new unit tests including
malformed-JSON, missing-cached_input_tokens, and zero-sum cases.

Reported by srid after seeing 17M in production on the dainty-island
worktree.
OpenAI's usage schema differs from Anthropic's: 'input_tokens' is
the TOTAL prompt the model saw this turn, already inclusive of any
cached portion. 'cached_input_tokens' is a breakdown of what portion
of input_tokens was a cache hit — a subset, not an additional count.

Anthropic's schema, which claude-code reads, makes the three
input-side fields (input_tokens, cache_creation_input_tokens,
cache_read_input_tokens) disjoint, so summing them is correct there.
Transplanting that pattern onto Codex's OpenAI-shaped emission
double-counts every cache re-read.

Verified empirically: Codex's own '/status' command displays
input_tokens alone (e.g. '48.9K used / 258K') whereas the wrong sum
showed 97.5K for the same turn. Also confirmed input_tokens +
output_tokens = total_tokens across every sampled rollout, with
cached_input_tokens NOT contributing — clear evidence it's a subset.

Reported by srid via a screenshot comparing kolu's badge against
Codex's /status output.
@srid

srid commented Apr 22, 2026

Copy link
Copy Markdown
Member Author
image

Codex bumps the numeric suffix on incompatible schema changes (current
is v5; logs_2.sqlite lives alongside at v2). Hard-coding the version
meant a user who upgrades Codex past v5 would silently lose session
detection until Kolu ships an update.

Replace the constant with findCodexStateDbPath() — enumerates
~/.codex/state_*.sqlite, picks the highest N. Resolution runs once at
module load; env override (KOLU_CODEX_DB) still wins; legacy
state_5.sqlite remains the fallback so hosts without Codex continue to
hit the existing ENOENT-silent path in openDb.

Ported from #657 per review discussion on #658#658 (comment)
srid added 2 commits April 22, 2026 17:19
`parseRolloutState` tracked open `function_call` call_ids across the
entire tail. A `function_call` with no matching `function_call_output`
that straddled a `task_started` boundary (user aborted a prior
tool-using turn, or the tail head clipped the closing output) would
pin state at `tool_use` into the next turn — even if the next turn
was purely thinking.

Clear `openCalls` on `task_started`. The set now reflects only calls
opened during the current turn, matching the intent of the state
machine. Existing tests still pass; added three new cases covering:
orphan call cleared across a completed-then-started boundary, orphan
with tail that began mid-prior-turn, and a new turn correctly
detecting its own tool call without prior-turn noise.

Ported from #657 per review discussion on #658#658 (comment)
The filename-level schema version is now auto-discovered (previous
commit), but column-level drift is still silent. If Codex renames or
drops a column our SELECTs depend on (rollout_path, cwd, source,
archived, updated_at_ms, title, model, id), findSessionByDirectory
returns zero rows and the user sees no Codex badge with no indication
why.

Introspect `threads` via `PRAGMA table_info` on the first openDb call.
If any required column is missing, close the DB, return null, and log
a one-time error listing the observed vs required columns plus a
pointer to KOLU_CODEX_DB for override. Subsequent openDb calls reuse
the latched verdict — no log spam, no repeat validation.

`missingThreadColumns` is exported and unit-tested with in-memory DBs
covering the happy path, extra-columns tolerance, partial missing set,
and fully-absent table.

Addresses the column-drift gap called out on #658#658 (comment)
@srid
srid merged commit e41884d into master Apr 23, 2026
12 checks passed
@srid
srid deleted the codex-provider branch April 23, 2026 00:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant