@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 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 | `~/.codex/sessions/` and `~/.codex/archived_sessions/` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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 ~/.codex/sessions and, once a session is
8
- // "completed", moves its rollout file verbatim into ~/.codex/archived_sessions.
9
- // A session can be archived between two syncs, so scanning only the live dir
10
- // loses that session's usage forever. We scan both: the parser is stateless
11
- // and the server dedups on (source, sessionHash/bucket), so re-reading an
12
- // archived file that was already synced from sessions/ is idempotent. Indexing
13
- // both together also keeps fork replay-skip correct when a fork and its parent
14
- // end up split across the two directories.
15
- const SESSIONS_DIRS = [
16
- join(homedir(), '.codex', 'sessions'),
17
- join(homedir(), '.codex', 'archived_sessions'),
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, and the total count of
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
- if (!SESSIONS_DIRS.some(existsSync)) return { buckets: [], sessions: [] };
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 = SESSIONS_DIRS.flatMap(findJsonlFiles);
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 session's
174
- // duration/active-time/message counts reflect only the new
175
- // conversation, not the copied original. session_meta itself is
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 it's an exact copy of a record
211
- // already counted from the source session's own file skip it (but
212
- // still advance the cumulative-total baseline below so the first real
213
- // post-fork delta is measured correctly).
214
- const isReplayedHistory = tokenCountSeen < replayTokenCountToSkip;
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