Skip to content

fix(hooks): find subagents in both layouts, tolerate non-string timestamps - #247

Merged
pugazhendhi-m merged 2 commits into
stagingfrom
WEB-5508
Aug 20, 2026
Merged

fix(hooks): find subagents in both layouts, tolerate non-string timestamps#247
pugazhendhi-m merged 2 commits into
stagingfrom
WEB-5508

Conversation

@pugazhendhi-m

@pugazhendhi-m pugazhendhi-m commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Two format-change exposures in the device hooks. Both are correct on today's data and both fail silently when the upstream format shifts. Found by the scheduled ccusage parity audit.

Linear: WEB-5508

1. Nested session-directory layout dropped every subagent token

_fold_subagent_usage derived the subagent directory by stripping the transcript's extension. That locates it in the flat layout (projects/{proj}/{session}.jsonl{session}/subagents) but not the nested one (projects/{proj}/{session}/chat.jsonlchat/subagents, which never exists). The os.path.isdir guard then returned an empty list without logging, so every subagent token was dropped and the turn under-billed by the whole Task.

New _subagent_dir() checks both positions and returns whichever exists. The listing is now an os.walk instead of a single-level listdir, matching ccusage, which recurses without a depth limit (rust/adapters/claude/src/paths.rs:130; its README covers "both nested session directories and legacy flat JSONL files").

2. A non-string timestamp discarded the turn's usage

entry.get('timestamp', '') < user_prompt_timestamp raises TypeError on an integer. The inner handler only catches json.JSONDecodeError, so it unwound to the bare except Exception: return None — losing every token_count line already read for that turn, not one line, and dropping the turn to the tiktoken estimate.

All comparisons now route through a _ts_lt() helper that orders strings only. Applied at all five sites, including two the audit didn't name (codex parse_transcript_file:445, parse_codex_transcript_for_tools:1370).

str() coercion was rejected. A numeric timestamp compared lexically against an ISO string mis-scopes the turn silently, which is worse than the crash it replaces.

The two directions are deliberate. When an entry can't be ordered:

  • Codex usage aggregation counts it toward the turn — the alternative is losing the whole turn.
  • Scoping filters drop it — including it would re-fold it on every later Stop, double-counting forever. This also preserves the existing timestamp-less behaviour, whose comment already explains why.

Rule: never raise, and when ordering is impossible prefer the choice that cannot double-count. Both helper docstrings carry that reasoning.

Verification

Seven synthetic cases:

case result
flat layout still folds subagents 110/110
nested layout folds subagents 110/110 (was 0)
recursive walk finds nested subagent dirs 71/71
timestamp-less subagent entry still excluded no re-fold
claude numeric timestamp no crash, entry excluded
codex numeric timestamp turn kept, was None
codex string path unchanged

Suites: codex 33, augment 133, copilot 25, claude-code 220 pass. Two failures are pre-existing on staging in files this branch doesn't touch (TestMatcherParityAcrossTrees compares setup.py matchers; the cursor identity test is unrelated — cursor has no changes here).

No new imports, so binary/unbound-hook.spec HIDDEN needs no update. No version bump — that belongs to the release commit.

Not in this PR

Codex subagent token collection (WEB-5507) — separate change, needs a sessions-tree scan and replay-prefix handling.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JVhNY6fkBwnqhsZkQfJnoT


Note

Medium Risk
Changes directly affect per-turn token aggregation and billing; incorrect timestamp or subagent path handling could still over- or under-count usage, though the diff targets known parity gaps.

Overview
Fixes silent under-billing when Claude subagent transcripts live outside the flat {session}.jsonl layout and turn loss / mis-scoping when transcript timestamps are numeric instead of ISO strings.

Claude Code: _fold_subagent_usage now resolves subagents/ via _subagent_dir() (flat {session}/subagents and nested {session}/subagents beside the transcript) and recursively collects all .jsonl files under that tree, matching ccusage-style discovery.

Claude + Codex: Adds _ts_key() / _ts_lt() so comparisons never raise—numeric epochs (seconds or ms) normalize to ISO Z strings before ordering. Turn scoping (main transcript, subagent fold, Codex tool parsing) skips entries unless _ts_lt(user_prompt_timestamp, entry_ts) proves they are after the prompt. Codex token_count cumulative usage uses the same helpers with an explicit rule: unorderable timestamps count as pre-prompt baseline so sticky cumulative snapshots are not attributed to the current turn.

Reviewed by Cursor Bugbot for commit ec8292b. Bugbot is set up for automated code reviews on this repo. Configure here.

Greptile Summary

The PR makes Claude Code and Codex transcript parsing tolerant of numeric timestamps and restores Claude subagent usage discovery across flat and nested session layouts.

  • Normalizes numeric epoch timestamps before turn-boundary comparisons.
  • Treats missing or unorderable Codex cumulative snapshots as the pre-prompt baseline.
  • Recursively discovers Claude subagent transcripts in both supported directory layouts.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
claude-code/hooks/unbound.py Adds timestamp normalization and recursive subagent transcript discovery for both flat and nested session layouts; no blocking issue identified.
codex/hooks/unbound.py Normalizes transcript timestamps and fixes the previously reported missing-timestamp baseline behavior; no blocking issue remains.

Reviews (2): Last reviewed commit: "fix(hooks): treat an unplaceable timesta..." | Re-trigger Greptile

Context used:

…tamps

The subagent directory was derived by stripping the transcript's extension,
which only locates it in the flat layout. Under the nested layout the path
resolves to a directory that does not exist, the isdir guard returns an
empty list without logging, and every subagent token is dropped. It is now
looked up in both positions, and the listing recurses.

Ordering an entry against the turn's prompt compared a raw timestamp with
a string. A numeric one raises TypeError past the JSONDecodeError handler,
reaching the bare except and discarding usage already read for the turn.
Comparisons go through a helper that orders strings only.

Where an entry cannot be ordered, the two paths differ on purpose: the
codex usage aggregation counts it toward the turn, because the alternative
is losing the whole turn to estimation, while the scoping filters drop it,
because including it would re-fold it on every later Stop.

Coercing with str() was rejected: a numeric timestamp compared lexically
against an ISO string mis-scopes the turn silently, which is worse than
the crash it would replace.
@pugazhendhi-m
pugazhendhi-m requested a review from a team August 20, 2026 06:55
Comment thread codex/hooks/unbound.py Outdated

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed to authenticate: OAuth session expired and could not be refreshed


🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head fe0ffef6 · 2026-08-20T07:00Z

Routing comparisons through a string-only helper changed what happens to a
cumulative snapshot with no timestamp. It had been sorted into the baseline
by the empty-string default; it began landing in the turn instead, so an
earlier session's running total was billed as this turn's spend.

Timestamps now normalize before comparison: numeric epoch seconds and
milliseconds resolve to the same ISO form the string ones use, so a numeric
transcript is ordered rather than discarded. What remains unplaceable is
sorted into the baseline, as it was before, because a cumulative snapshot
that cannot be placed is far likelier to predate the turn than to be its
spend, and guessing the other way over-bills.

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed to authenticate: OAuth session expired and could not be refreshed


🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head ec8292b4 · 2026-08-20T07:41Z

@pugazhendhi-m
pugazhendhi-m merged commit 6dabf0f into staging Aug 20, 2026
4 checks passed
pugazhendhi-m added a commit that referenced this pull request Aug 20, 2026
staging carries #247 as a squash, so its commits are not ancestors here and
git saw two lineages over the same region of the codex hook. The file is
resolved to this branch's version, which already contains that work and
builds the subagent fold on top; staging's copy of it is byte-identical, so
nothing from #247 is lost.
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.

3 participants