@vibe-cafe/vibe-usage 0.9.11 → 0.9.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/parsers/codex.js +89 -27
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
51
51
|
| Tool | Data Location |
|
|
52
52
|
|------|---------------|
|
|
53
53
|
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR` when set (deduped), so relocated configs and GUI/CLI env mismatches are both covered |
|
|
54
|
-
| Codex CLI |
|
|
54
|
+
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`); forked and sub-agent rollouts replay the parent conversation's `token_count` records at spawn time — those are skipped (fork: by source session's record count; sub-agent: everything before its own first `task_started`), and duplicate `token_count` emissions with an unchanged cumulative total count once |
|
|
55
55
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
56
56
|
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
|
|
57
57
|
| Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
|
package/package.json
CHANGED
package/src/parsers/codex.js
CHANGED
|
@@ -4,18 +4,22 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
5
|
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
6
6
|
|
|
7
|
-
// Codex stores live sessions in
|
|
8
|
-
// "completed", moves its rollout file verbatim into
|
|
9
|
-
// A session can be archived between two syncs,
|
|
10
|
-
// loses that session's usage forever. We scan
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
join(homedir(), '.codex'
|
|
18
|
-
|
|
7
|
+
// Codex stores live sessions in $CODEX_HOME/sessions (default ~/.codex) and,
|
|
8
|
+
// once a session is "completed", moves its rollout file verbatim into
|
|
9
|
+
// $CODEX_HOME/archived_sessions. A session can be archived between two syncs,
|
|
10
|
+
// so scanning only the live dir loses that session's usage forever. We scan
|
|
11
|
+
// both: the parser is stateless and the server dedups on
|
|
12
|
+
// (source, sessionHash/bucket), so re-reading an archived file that was
|
|
13
|
+
// already synced from sessions/ is idempotent. Indexing both together also
|
|
14
|
+
// keeps fork replay-skip correct when a fork and its parent end up split
|
|
15
|
+
// across the two directories.
|
|
16
|
+
function sessionsDirs() {
|
|
17
|
+
const codexHome = process.env.CODEX_HOME?.trim() || join(homedir(), '.codex');
|
|
18
|
+
return [
|
|
19
|
+
join(codexHome, 'sessions'),
|
|
20
|
+
join(codexHome, 'archived_sessions'),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
25
|
* Recursively find all .jsonl files under a directory.
|
|
@@ -56,9 +60,24 @@ function extractProject(meta) {
|
|
|
56
60
|
return 'unknown';
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A sub-agent rollout (spawned thread / guardian / collab agent). Depending
|
|
65
|
+
* on the Codex version the marker is `thread_source: "subagent"`, a
|
|
66
|
+
* `source: { subagent: {...} }` object, or just a `parent_thread_id` — check
|
|
67
|
+
* all three so no version's sub-agents slip through as normal sessions.
|
|
68
|
+
*/
|
|
69
|
+
function isSubagentMeta(meta) {
|
|
70
|
+
if (meta.thread_source === 'subagent') return true;
|
|
71
|
+
const src = meta.source;
|
|
72
|
+
if (src === 'subagent') return true;
|
|
73
|
+
if (src && typeof src === 'object' && 'subagent' in src) return true;
|
|
74
|
+
return meta.parent_thread_id != null;
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
/**
|
|
60
78
|
* Stream a session file once and extract its index metadata: the session
|
|
61
|
-
* id, the forked-from id, the project name,
|
|
79
|
+
* id, the forked-from id, the project name, whether it is a sub-agent
|
|
80
|
+
* rollout (and contains a task_started boundary), and the total count of
|
|
62
81
|
* `event_msg/token_count` records. The token_count total is used to size
|
|
63
82
|
* the replayed-history block of a forked session — a fork copies the
|
|
64
83
|
* original conversation verbatim, so it begins with exactly as many
|
|
@@ -69,6 +88,8 @@ async function indexSessionFile(filePath) {
|
|
|
69
88
|
let forkedFromId = null;
|
|
70
89
|
let sessionProject = 'unknown';
|
|
71
90
|
let tokenCountRecords = 0;
|
|
91
|
+
let isSubagent = false;
|
|
92
|
+
let hasTaskStarted = false;
|
|
72
93
|
|
|
73
94
|
for await (const line of readLines(filePath)) {
|
|
74
95
|
if (!line.trim()) continue;
|
|
@@ -78,24 +99,28 @@ async function indexSessionFile(filePath) {
|
|
|
78
99
|
const meta = obj.payload;
|
|
79
100
|
sessionId = meta.id || sessionId;
|
|
80
101
|
forkedFromId = meta.forked_from_id || null;
|
|
102
|
+
isSubagent = isSubagentMeta(meta);
|
|
81
103
|
sessionProject = extractProject(meta);
|
|
82
104
|
} else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
|
|
83
105
|
tokenCountRecords++;
|
|
106
|
+
} else if (obj.type === 'event_msg' && obj.payload?.type === 'task_started') {
|
|
107
|
+
hasTaskStarted = true;
|
|
84
108
|
}
|
|
85
109
|
} catch {
|
|
86
110
|
continue;
|
|
87
111
|
}
|
|
88
112
|
}
|
|
89
113
|
|
|
90
|
-
return { sessionId, forkedFromId, sessionProject, tokenCountRecords };
|
|
114
|
+
return { sessionId, forkedFromId, sessionProject, tokenCountRecords, isSubagent, hasTaskStarted };
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
export async function parse() {
|
|
94
|
-
|
|
118
|
+
const dirs = sessionsDirs();
|
|
119
|
+
if (!dirs.some(existsSync)) return { buckets: [], sessions: [] };
|
|
95
120
|
|
|
96
121
|
const entries = [];
|
|
97
122
|
const sessionEvents = [];
|
|
98
|
-
const files =
|
|
123
|
+
const files = dirs.flatMap(findJsonlFiles);
|
|
99
124
|
if (files.length === 0) return { buckets: [], sessions: [] };
|
|
100
125
|
|
|
101
126
|
// Pass 1: index every session by its UUID and count its token_count
|
|
@@ -144,6 +169,19 @@ export async function parse() {
|
|
|
144
169
|
}
|
|
145
170
|
let tokenCountSeen = 0;
|
|
146
171
|
|
|
172
|
+
// Sub-agent rollouts can begin with the parent session's history copied
|
|
173
|
+
// in — including its token_count records — all re-stamped at the spawn
|
|
174
|
+
// instant. Those tokens are already counted from the parent's own file,
|
|
175
|
+
// so re-counting them here double-counts usage and produces a huge
|
|
176
|
+
// spurious spike at spawn time (observed 20×+). Unlike forks there is no
|
|
177
|
+
// forked_from_id/count to size the block by, but the copied history ends
|
|
178
|
+
// at the sub-agent's own first task_started event, so skip everything
|
|
179
|
+
// before it. If the file has no task_started at all (unknown/legacy
|
|
180
|
+
// format) we can't locate the boundary; skip nothing so incomplete data
|
|
181
|
+
// over-counts rather than silently dropping real usage.
|
|
182
|
+
const skipUntilTaskStarted = fm.isSubagent && fm.hasTaskStarted;
|
|
183
|
+
let taskStartedSeen = false;
|
|
184
|
+
|
|
147
185
|
const sessionProject = fm.sessionProject;
|
|
148
186
|
// Group timing events by the real Codex session id, not the file path: the
|
|
149
187
|
// same session can briefly exist in both sessions/ and archived_sessions/
|
|
@@ -154,6 +192,7 @@ export async function parse() {
|
|
|
154
192
|
|
|
155
193
|
let turnContextModel = 'unknown';
|
|
156
194
|
const prevTotal = new Map();
|
|
195
|
+
let prevCumulativeTotal = null;
|
|
157
196
|
for await (const line of readLines(filePath)) {
|
|
158
197
|
if (!line.trim()) continue;
|
|
159
198
|
try {
|
|
@@ -167,14 +206,23 @@ export async function parse() {
|
|
|
167
206
|
// edge — the new conversation's events come strictly after it.)
|
|
168
207
|
const inReplayBlock = tokenCountSeen < replayTokenCountToSkip;
|
|
169
208
|
|
|
209
|
+
// The task_started event itself belongs to the sub-agent — it marks
|
|
210
|
+
// the start of its own first turn — so flip the flag before deciding
|
|
211
|
+
// whether this record is inherited.
|
|
212
|
+
if (skipUntilTaskStarted && !taskStartedSeen
|
|
213
|
+
&& obj.type === 'event_msg' && obj.payload?.type === 'task_started') {
|
|
214
|
+
taskStartedSeen = true;
|
|
215
|
+
}
|
|
216
|
+
const inInheritedBlock = skipUntilTaskStarted && !taskStartedSeen;
|
|
217
|
+
|
|
170
218
|
if (obj.timestamp) {
|
|
171
219
|
const evTs = new Date(obj.timestamp);
|
|
172
220
|
if (!isNaN(evTs.getTime())) {
|
|
173
|
-
// Skip replayed history events so a forked
|
|
174
|
-
// duration/active-time/message counts reflect only the
|
|
175
|
-
// conversation, not the copied original. session_meta itself
|
|
176
|
-
// kept: it marks when the fork actually started.
|
|
177
|
-
const isReplay = inReplayBlock && obj.type !== 'session_meta';
|
|
221
|
+
// Skip replayed/inherited history events so a forked or sub-agent
|
|
222
|
+
// session's duration/active-time/message counts reflect only the
|
|
223
|
+
// new conversation, not the copied original. session_meta itself
|
|
224
|
+
// is kept: it marks when the fork/sub-agent actually started.
|
|
225
|
+
const isReplay = (inReplayBlock || inInheritedBlock) && obj.type !== 'session_meta';
|
|
178
226
|
if (!isReplay) {
|
|
179
227
|
const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
|
|
180
228
|
sessionEvents.push({
|
|
@@ -207,13 +255,27 @@ export async function parse() {
|
|
|
207
255
|
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
208
256
|
|
|
209
257
|
// This is the (tokenCountSeen+1)-th token_count in the file. If it
|
|
210
|
-
// falls inside the fork's replay block
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
258
|
+
// falls inside the fork's replay block or a sub-agent's inherited
|
|
259
|
+
// block it's an exact copy of a record already counted from the
|
|
260
|
+
// source session's own file — skip it (but still advance the
|
|
261
|
+
// cumulative-total baselines below so the first real delta after the
|
|
262
|
+
// copied history is measured correctly).
|
|
263
|
+
const isReplayedHistory = tokenCountSeen < replayTokenCountToSkip || inInheritedBlock;
|
|
215
264
|
tokenCountSeen++;
|
|
216
265
|
|
|
266
|
+
// Codex sometimes writes the same token_count twice back-to-back:
|
|
267
|
+
// identical last_token_usage with an unchanged cumulative total. A
|
|
268
|
+
// real API call always advances the cumulative counter (its input
|
|
269
|
+
// tokens alone are non-zero), so an unchanged positive total marks a
|
|
270
|
+
// duplicate emission — or a zero-usage bookkeeping event such as
|
|
271
|
+
// compaction — and must count as zero, not a second copy of
|
|
272
|
+
// last_token_usage. Guarded to positive totals so builds that leave
|
|
273
|
+
// total_token_usage all-zero can't suppress real usage.
|
|
274
|
+
const cumulativeTotal = info.total_token_usage?.total_tokens;
|
|
275
|
+
const isDuplicateEmission = typeof cumulativeTotal === 'number'
|
|
276
|
+
&& cumulativeTotal > 0 && cumulativeTotal === prevCumulativeTotal;
|
|
277
|
+
if (typeof cumulativeTotal === 'number') prevCumulativeTotal = cumulativeTotal;
|
|
278
|
+
|
|
217
279
|
// Prefer incremental per-request usage; compute delta from cumulative total as fallback
|
|
218
280
|
let usage = info.last_token_usage;
|
|
219
281
|
if (!usage && info.total_token_usage) {
|
|
@@ -237,7 +299,7 @@ export async function parse() {
|
|
|
237
299
|
prevTotal.set(totalKey, { ...curr });
|
|
238
300
|
}
|
|
239
301
|
if (!usage) continue;
|
|
240
|
-
if (isReplayedHistory) continue;
|
|
302
|
+
if (isReplayedHistory || isDuplicateEmission) continue;
|
|
241
303
|
|
|
242
304
|
const model = info.model || payload.model || turnContextModel || 'unknown';
|
|
243
305
|
|