feat: concurrency — how many sessions ran at once, and what it bought - #32
Conversation
Adds the third axis to cc-audit: tokens first, wall-clock second, now concurrency. Two totals over the same minutes — agentMinutes (each session's active minutes summed) and wallMinutes (their union) — whose ratio is the mean number of sessions live whenever anything was live. The load-bearing change is `parentSessionId`. The loader makes one Session per FILE, and Claude Code 2.1.x moved subagent turns into `<session>/subagents/agent-*.jsonl`. That is correct for spend, where message-id dedup keeps the total honest, and wrong for anything counting sessions: a parent with six subagents reads as seven concurrent sessions. `concurrencyKey()` folds a subagent into its parent, and a test asserts the naive version says 7 where the correct one says 1. Three things the profile reports that a single number would hide: - `sensitivity` sweeps the bridge threshold (1/2/5/10 min) rather than reporting one flattering value. The bridge is the only free parameter in the method; a finding that dies inside its plausible range was never a finding. - `sessionWeightedMean` alongside `meanConcurrent`. The first is how crowded it is from inside a running session, the second is what the operator experiences. They differ by a lot (2.7 vs 3.8 on the author's corpus) and answer different questions. - `soloShare` is reported next to `multiShare` because solo is routinely the tallest single bar while being a minority of the time — a plurality read as a majority is the standing misreading of this distribution. Idle time is excluded rather than counted as zero: this measures how many ran when anything ran, not an average over the calendar. Prompts counted for `steering` skip sidechain spans — a subagent's task instruction is the machine talking to itself, not the operator steering. Every field is a de-identified aggregate (counts and ratios, no paths, project labels or session ids), so the profile is uploadable under the local-first rule.
Wires computeConcurrency into runAudit and gives it a section between FLUENCY and the context knee. Gated on two sessions actually existing — with one transcript the question has no answer and the card would print 1.0x as if it were a finding. The card prints the SHAPE, not just the average, because the average is the part that misleads: solo is routinely the tallest single bar while being a minority of the time, and a reader handed only "2.7x" reads it as "mostly alone". The per-level table makes that visible, and the solo line says outright whether it is a majority or merely the tallest bar. Both prompt rates are printed side by side rather than one being chosen. Per AGENT hour answers whether each session needs less of you when there are more of them; per WALL hour is what the day feels like. On the author's corpus they move in opposite directions (6.4 -> 5.1 and 6.4 -> 44.1), so either alone tells half the story. The steering verdict is derived from the ratio rather than asserted, so a corpus where attention does NOT hold flat gets the opposite sentence instead of the flattering one. The bridge sweep is printed for the same reason it is computed: it is the method's only free parameter, and a reader cannot tell a finding from an artifact of one threshold without seeing the range. A test asserts every line is exactly the card width. That is not decoration — three lines overflowed the frame the first time this rendered against a real corpus, and card() pads an underflow while silently letting an overflow break the right wall. The profile stays OUT of the uploaded aggregate. Every field is a count or a ratio and would be safe there, but adding it is a schema-version decision, not a rendering one.
Greptile SummaryThe PR adds a local concurrency profile that folds subagent transcripts into parent sessions and reports overlap, steering rates, concurrency distribution, and bridge sensitivity.
Confidence Score: 4/5The Windows parent-session parsing defect should be fixed before merging because it defeats the feature’s key subagent-folding guarantee and materially inflates concurrency reports. Native Windows paths reach the new slash-only parent parser unchanged, so subagent transcripts lose their parent relationship and are counted as separate concurrent sessions. Files Needing Attention: src/adapters/claudeCode.ts
|
| Filename | Overview |
|---|---|
| src/adapters/claudeCode.ts | Adds parent-session extraction, but its POSIX-only path parsing misses subagent relationships on Windows. |
| src/concurrency.ts | Implements minute-level concurrency, bridge sensitivity, distribution, and steering calculations with parent-session folding. |
| src/model.ts | Extends Session with parentSessionId and provides the grouping key used by concurrency calculations. |
| src/audit.ts | Computes and exposes the concurrency profile as part of every audit result. |
| src/report.ts | Adds a gated concurrency card with distribution, steering, and sensitivity output. |
| src/tests/concurrency.test.ts | Covers core calculations and rendering but tests parent extraction only with POSIX-style paths. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Claude Code transcript files] --> B[parseTranscript]
B --> C[parentSessionId]
C --> D[concurrencyKey]
D --> E[Group turns by owning session]
E --> F[Build active-minute map]
F --> G[Compute concurrency and steering profile]
G --> H[Render concurrency card]
Reviews (1): Last reviewed commit: "feat(report): print the concurrency card" | Re-trigger Greptile
| const parts = filePath.split('/'); | ||
| // .../<parentSessionId>/subagents/<agent>.jsonl | ||
| return parts.length >= 3 && parts[parts.length - 2] === 'subagents' | ||
| ? (parts[parts.length - 3] ?? null) | ||
| : null; |
There was a problem hiding this comment.
Windows parent-session links break
When an audit runs on Windows, native backslash-delimited subagent paths reach parentOf unchanged, but splitting only on / returns no parent. Each subagent is therefore counted as an independent session, inflating the reported session count, peak, mean concurrency, and minutes bought.
| const parts = filePath.split('/'); | |
| // .../<parentSessionId>/subagents/<agent>.jsonl | |
| return parts.length >= 3 && parts[parts.length - 2] === 'subagents' | |
| ? (parts[parts.length - 3] ?? null) | |
| : null; | |
| const parts = filePath.split(/[\\/]/); | |
| // .../<parentSessionId>/subagents/<agent>.jsonl | |
| return parts.length >= 3 && parts[parts.length - 2] === 'subagents' | |
| ? (parts[parts.length - 3] ?? null) | |
| : null; |
Greptile P1. The loader builds paths with join() from node:path, which emits backslashes on Windows, so splitting on '/' alone returned a single segment there — parentOf found no parent and every subagent became an independent top-level session, inflating session count, peak, mean concurrency and minutes bought by however many subagents the run spawned. Fixed at both sites rather than only the one the review flagged: `sessionId` had the identical defect on line 150 and predates this branch, where it turned the whole absolute path into the session id. One helper now owns the split, so a third caller cannot reintroduce it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One merged PR since 0.8.0 (#32), and it adds a measurement rather than correcting one — so unlike 0.8.0 there is no number a user has already read that changes meaning here. MINOR: new public surface (`computeConcurrency`, `renderConcurrency`, `concurrencyKey`, `Session.parentSessionId`, a `concurrency` field on `AuditResult`), all additive, aggregate schemaVersion untouched at 10. WHAT MAKES IT A RELEASE. cc-audit could say what sessions cost and how long they ran, and nothing about how many ran at the same time. For anyone running more than one that single fact reframes the rest of the report: 180h of wall time carrying 487h of agent work is a different product than 487h of work. THE FIX THAT PREDATES THE FEATURE. Transcript paths were split on '/' only, while the loader builds them with join() from node:path. On Windows that returns one segment, so the whole absolute path became the sessionId — shipped in every prior version — and with the new parent link every subagent would have counted as an independent session. Both sites now share one helper. VERIFIED, not assumed: - npm ci && lint && typecheck clean; 42 test files / 435 tests pass. - Built the npm artifact and confirmed bundles/npm/package.json reads 0.9.0, so publish.yml's tag-vs-package guard will match v0.9.0. - Ran the BUNDLE against the real local corpus, not just --help: 2.7x average, 180.7h wall carrying 486.7h agent, peak 13, bridge sweep 2.4/2.5/2.7/2.9 — the section renders inside its frame at every row, which is the failure this section actually has (three lines overflowed the card the first time it met real data). Tag v0.9.0 after merge; .github/workflows/publish.yml guards tag == package.json and publishes + attaches the standalone binaries. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the third axis to cc-audit. Tokens first, wall-clock second, now concurrency: how many sessions were alive at the same time, and what the overlap bought.
Two totals over the same minutes —
agentMinutes(each session's active minutes summed) andwallMinutes(their union) — whose ratio is the mean number of sessions live whenever anything was live.The load-bearing change is
parentSessionIdThe loader makes one
Sessionper file, and Claude Code 2.1.x moved subagent turns into<session>/subagents/agent-*.jsonl. That is correct for spend, where message-id dedup keeps the total honest, and wrong for anything counting sessions: a parent with six subagents reads as seven concurrent sessions.concurrencyKey()folds a subagent into its parent, and a test asserts the naive version says 7 where the correct one says 1.Three things the profile reports that a single number would hide
sensitivitysweeps the bridge threshold (1/2/5/10 min) instead of reporting one flattering value. The bridge — how long a gap still counts as working — is the method's only free parameter. A finding that dies inside its plausible range was never a finding.sessionWeightedMeanalongsidemeanConcurrent. The first is how crowded it is from inside a running session, the second is what the operator experiences. On the author's corpus they are 3.8 and 2.7, and they answer different questions.soloSharenext tomultiShare, and the card says outright which it is. Solo is routinely the tallest single bar while being a minority of the time; a plurality read as a majority is the standing misreading of this distribution.Both prompt rates print side by side rather than one being chosen. Per agent hour asks whether each session needs less of you when there are more of them; per wall hour is what the day feels like. They move in opposite directions (6.4 → 5.1 and 6.4 → 44.1 here), so either alone tells half the story. The steering verdict is derived from the ratio, so a corpus where attention does not hold flat gets the opposite sentence rather than the flattering one.
Idle time is excluded rather than counted as zero: this measures how many ran when anything ran, not an average over the calendar. Prompts counted for
steeringskip sidechain spans — a subagent's task instruction is the machine talking to itself, not the operator steering.Report
The card is gated on two sessions actually existing — with one transcript the question has no answer, and printing 1.0× would read as a finding.
Privacy
Every field is a count or a ratio: no paths, project labels, or session ids. It is nonetheless not in the uploaded aggregate — putting it there is a schema-version decision, not a rendering one, so it stays local until that call is made deliberately.
Verification
npm run typecheckcleannpm run lintcleannpx vitest run— 42 files / 434 tests passed (20 of them new)One test asserts every rendered line is exactly the card width. That is not decoration: three lines overflowed the frame the first time this ran against a real corpus, and
card()pads an underflow while silently letting an overflow break the right wall.🤖 Generated with Claude Code