sidekick-shared 0.27.2 → 0.27.4

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +57 -14
  3. package/dist/aggregation/EventAggregator.d.ts +12 -2
  4. package/dist/aggregation/EventAggregator.js +49 -19
  5. package/dist/browser.d.ts +2 -1
  6. package/dist/browser.js +9 -2
  7. package/dist/extractors/tokenUsage.js +1 -0
  8. package/dist/formatters/sessionDump.d.ts +10 -2
  9. package/dist/formatters/sessionDump.js +45 -23
  10. package/dist/index.d.ts +7 -3
  11. package/dist/index.js +31 -10
  12. package/dist/node.d.ts +6 -1
  13. package/dist/node.js +12 -2
  14. package/dist/parsers/codexParser.d.ts +22 -2
  15. package/dist/parsers/codexParser.js +64 -5
  16. package/dist/parsers/subagentScanner.d.ts +9 -0
  17. package/dist/parsers/subagentScanner.js +45 -12
  18. package/dist/parsers/subagentStatsCache.d.ts +17 -0
  19. package/dist/parsers/subagentStatsCache.js +82 -0
  20. package/dist/parsers/subagentTraceParser.js +19 -10
  21. package/dist/providers/claudeCode.js +8 -41
  22. package/dist/providers/codex.js +28 -14
  23. package/dist/providers/codexDatabase.d.ts +16 -1
  24. package/dist/providers/codexDatabase.js +49 -1
  25. package/dist/providers/openCode.js +30 -4
  26. package/dist/report/htmlReportGenerator.js +33 -11
  27. package/dist/report/sessionReportInputs.d.ts +3 -1
  28. package/dist/report/sessionReportInputs.js +2 -0
  29. package/dist/report/types.d.ts +5 -0
  30. package/dist/schemas/sessionEvent.d.ts +30 -6
  31. package/dist/schemas/sessionEvent.js +7 -1
  32. package/dist/schemas/stateFile.d.ts +2 -0
  33. package/dist/schemas/stateFile.js +1 -0
  34. package/dist/sessionTokenTotals.d.ts +46 -0
  35. package/dist/sessionTokenTotals.js +138 -0
  36. package/dist/stateFile.d.ts +7 -0
  37. package/dist/tokenSummary.d.ts +27 -1
  38. package/dist/tokenSummary.js +50 -3
  39. package/dist/types/codex.d.ts +4 -0
  40. package/dist/types/sessionEvent.d.ts +22 -0
  41. package/dist/usage/claudeUsageDedupe.d.ts +53 -0
  42. package/dist/usage/claudeUsageDedupe.js +195 -0
  43. package/dist/usage/subagentUsage.d.ts +13 -0
  44. package/dist/usage/subagentUsage.js +21 -0
  45. package/dist/usage/usageEvents.d.ts +18 -2
  46. package/dist/usage/usageEvents.js +83 -8
  47. package/dist/watchers/eventBridge.js +5 -0
  48. package/dist/watchers/jsonlWatcher.d.ts +5 -0
  49. package/dist/watchers/jsonlWatcher.js +40 -5
  50. package/dist/watchers/types.d.ts +5 -0
  51. package/package.json +1 -1
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Claude Code writes one API response as several JSONL lines — one per content
3
+ * block — and every line repeats that response's `message.usage`. Streaming
4
+ * also means early lines can carry a partial `output_tokens` (often 1) that a
5
+ * later line of the same `message.id` supersedes. Summing every line
6
+ * therefore roughly doubles a session's tokens and cost.
7
+ *
8
+ * The correct rule is "the last copy of a message's usage wins", but readers
9
+ * stream events and cannot retract what they already emitted. So the first
10
+ * line carries the usage as a normal call, identical repeats are stripped, and
11
+ * a repeat that grew emits only the growth as a `usageKind: 'correction'`
12
+ * top-up. Consumers add corrections to token and cost totals without counting
13
+ * another call or context sample.
14
+ *
15
+ * Browser-safe: no Node imports.
16
+ *
17
+ * @module usage/claudeUsageDedupe
18
+ */
19
+ import { type NormalizedUsage } from '../usageNormalization';
20
+ import type { SessionEvent } from '../types/sessionEvent';
21
+ /** Claude Code's placeholder model for locally generated (unbilled) messages. */
22
+ export declare const SYNTHETIC_MODEL = "<synthetic>";
23
+ /** Attach Anthropic-semantics normalized usage and Claude Code provenance to an event. */
24
+ export declare function normalizeClaudeUsage(event: SessionEvent): SessionEvent;
25
+ /**
26
+ * Per-reader dedupe state. Split lines of one response are adjacent, so a
27
+ * bounded map (oldest keys evicted first) is enough.
28
+ */
29
+ export declare class ClaudeUsageDeduper {
30
+ private readonly maxKeys;
31
+ private readonly seen;
32
+ constructor(maxKeys?: number);
33
+ /**
34
+ * Returns the event with its usage adjusted: unchanged for the first line of
35
+ * a message, stripped for an identical repeat or a `<synthetic>` message,
36
+ * and replaced by the growth (marked as a correction) for a repeat that grew.
37
+ * Events must already carry `message.normalizedUsage`.
38
+ */
39
+ apply(event: SessionEvent): SessionEvent;
40
+ /** Whether a message id has already been counted by this deduper. */
41
+ has(id: string): boolean;
42
+ reset(): void;
43
+ private remember;
44
+ }
45
+ /**
46
+ * Deduplicated normalized usage for one raw Claude Code JSONL assistant line,
47
+ * or null when the line carries none (or only repeats usage already counted).
48
+ */
49
+ export declare function dedupedRawClaudeUsage(raw: {
50
+ type?: string;
51
+ timestamp?: string;
52
+ message?: SessionEvent['message'];
53
+ } | null | undefined, deduper: ClaudeUsageDeduper): NormalizedUsage | null;
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ /**
3
+ * Claude Code writes one API response as several JSONL lines — one per content
4
+ * block — and every line repeats that response's `message.usage`. Streaming
5
+ * also means early lines can carry a partial `output_tokens` (often 1) that a
6
+ * later line of the same `message.id` supersedes. Summing every line
7
+ * therefore roughly doubles a session's tokens and cost.
8
+ *
9
+ * The correct rule is "the last copy of a message's usage wins", but readers
10
+ * stream events and cannot retract what they already emitted. So the first
11
+ * line carries the usage as a normal call, identical repeats are stripped, and
12
+ * a repeat that grew emits only the growth as a `usageKind: 'correction'`
13
+ * top-up. Consumers add corrections to token and cost totals without counting
14
+ * another call or context sample.
15
+ *
16
+ * Browser-safe: no Node imports.
17
+ *
18
+ * @module usage/claudeUsageDedupe
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.ClaudeUsageDeduper = exports.SYNTHETIC_MODEL = void 0;
22
+ exports.normalizeClaudeUsage = normalizeClaudeUsage;
23
+ exports.dedupedRawClaudeUsage = dedupedRawClaudeUsage;
24
+ const usageNormalization_1 = require("../usageNormalization");
25
+ /** Claude Code's placeholder model for locally generated (unbilled) messages. */
26
+ exports.SYNTHETIC_MODEL = '<synthetic>';
27
+ const DEFAULT_MAX_KEYS = 4096;
28
+ /** Attach Anthropic-semantics normalized usage and Claude Code provenance to an event. */
29
+ function normalizeClaudeUsage(event) {
30
+ const message = event.message;
31
+ const usage = message?.usage;
32
+ if (!message || !usage) {
33
+ return {
34
+ ...event,
35
+ providerMetadata: {
36
+ ...event.providerMetadata,
37
+ providerId: 'claude-code',
38
+ source: 'claude-code-jsonl',
39
+ },
40
+ };
41
+ }
42
+ return {
43
+ ...event,
44
+ message: {
45
+ ...message,
46
+ normalizedUsage: (0, usageNormalization_1.normalizeProviderUsage)({
47
+ semantics: 'anthropic',
48
+ provider: 'anthropic',
49
+ source: 'claude-code-jsonl',
50
+ model: message.model,
51
+ inputTokens: usage.input_tokens,
52
+ outputTokens: usage.output_tokens,
53
+ cacheReadTokens: usage.cache_read_input_tokens,
54
+ cacheWriteTokens: usage.cache_creation_input_tokens,
55
+ reasoningTokens: usage.reasoning_tokens,
56
+ reasoningIncludedInOutput: false,
57
+ reportedCostUsd: usage.reported_cost,
58
+ }),
59
+ },
60
+ providerMetadata: {
61
+ ...event.providerMetadata,
62
+ providerId: 'claude-code',
63
+ source: 'claude-code-jsonl',
64
+ },
65
+ };
66
+ }
67
+ function bucketsOf(usage) {
68
+ return {
69
+ input: usage.uncachedInputTokens,
70
+ cacheRead: usage.cacheReadTokens,
71
+ cacheWrite: usage.cacheWriteTokens,
72
+ output: usage.outputTokens,
73
+ reasoning: usage.reasoningTokens,
74
+ };
75
+ }
76
+ function stripUsage(event) {
77
+ if (!event.message)
78
+ return event;
79
+ const message = { ...event.message };
80
+ delete message.usage;
81
+ delete message.normalizedUsage;
82
+ return { ...event, message };
83
+ }
84
+ /**
85
+ * Per-reader dedupe state. Split lines of one response are adjacent, so a
86
+ * bounded map (oldest keys evicted first) is enough.
87
+ */
88
+ class ClaudeUsageDeduper {
89
+ maxKeys;
90
+ seen = new Map();
91
+ constructor(maxKeys = DEFAULT_MAX_KEYS) {
92
+ this.maxKeys = maxKeys;
93
+ }
94
+ /**
95
+ * Returns the event with its usage adjusted: unchanged for the first line of
96
+ * a message, stripped for an identical repeat or a `<synthetic>` message,
97
+ * and replaced by the growth (marked as a correction) for a repeat that grew.
98
+ * Events must already carry `message.normalizedUsage`.
99
+ */
100
+ apply(event) {
101
+ const message = event.message;
102
+ const usage = message?.normalizedUsage;
103
+ if (!message || !usage)
104
+ return event;
105
+ if (message.model === exports.SYNTHETIC_MODEL)
106
+ return stripUsage(event);
107
+ const id = message.id;
108
+ if (!id)
109
+ return event;
110
+ const current = bucketsOf(usage);
111
+ const previous = this.seen.get(id);
112
+ if (!previous) {
113
+ this.remember(id, current);
114
+ return event;
115
+ }
116
+ const delta = {
117
+ input: Math.max(0, current.input - previous.input),
118
+ cacheRead: Math.max(0, current.cacheRead - previous.cacheRead),
119
+ cacheWrite: Math.max(0, current.cacheWrite - previous.cacheWrite),
120
+ output: Math.max(0, current.output - previous.output),
121
+ reasoning: Math.max(0, current.reasoning - previous.reasoning),
122
+ };
123
+ this.remember(id, {
124
+ input: previous.input + delta.input,
125
+ cacheRead: previous.cacheRead + delta.cacheRead,
126
+ cacheWrite: previous.cacheWrite + delta.cacheWrite,
127
+ output: previous.output + delta.output,
128
+ reasoning: previous.reasoning + delta.reasoning,
129
+ });
130
+ if (!delta.input &&
131
+ !delta.cacheRead &&
132
+ !delta.cacheWrite &&
133
+ !delta.output &&
134
+ !delta.reasoning) {
135
+ return stripUsage(event);
136
+ }
137
+ const cacheInclusiveInputTokens = delta.input + delta.cacheRead + delta.cacheWrite;
138
+ const billableOutputTokens = delta.output + (usage.reasoningIncludedInOutput ? 0 : delta.reasoning);
139
+ const normalizedUsage = {
140
+ uncachedInputTokens: delta.input,
141
+ cacheReadTokens: delta.cacheRead,
142
+ cacheWriteTokens: delta.cacheWrite,
143
+ outputTokens: delta.output,
144
+ reasoningTokens: delta.reasoning,
145
+ reasoningIncludedInOutput: usage.reasoningIncludedInOutput,
146
+ cacheInclusiveInputTokens,
147
+ billableOutputTokens,
148
+ totalTokens: cacheInclusiveInputTokens + billableOutputTokens,
149
+ ...(usage.model ? { model: usage.model } : {}),
150
+ provenance: usage.provenance,
151
+ };
152
+ const messageUsage = {
153
+ input_tokens: delta.input,
154
+ output_tokens: delta.output,
155
+ cache_creation_input_tokens: delta.cacheWrite,
156
+ cache_read_input_tokens: delta.cacheRead,
157
+ ...(delta.reasoning ? { reasoning_tokens: delta.reasoning } : {}),
158
+ };
159
+ return {
160
+ ...event,
161
+ message: { ...message, usage: messageUsage, normalizedUsage, usageKind: 'correction' },
162
+ };
163
+ }
164
+ /** Whether a message id has already been counted by this deduper. */
165
+ has(id) {
166
+ return this.seen.has(id);
167
+ }
168
+ reset() {
169
+ this.seen.clear();
170
+ }
171
+ remember(id, buckets) {
172
+ this.seen.delete(id);
173
+ this.seen.set(id, buckets);
174
+ if (this.seen.size > this.maxKeys) {
175
+ const oldest = this.seen.keys().next().value;
176
+ if (oldest !== undefined)
177
+ this.seen.delete(oldest);
178
+ }
179
+ }
180
+ }
181
+ exports.ClaudeUsageDeduper = ClaudeUsageDeduper;
182
+ /**
183
+ * Deduplicated normalized usage for one raw Claude Code JSONL assistant line,
184
+ * or null when the line carries none (or only repeats usage already counted).
185
+ */
186
+ function dedupedRawClaudeUsage(raw, deduper) {
187
+ if (!raw || raw.type !== 'assistant' || !raw.message?.usage)
188
+ return null;
189
+ const event = deduper.apply(normalizeClaudeUsage({
190
+ type: 'assistant',
191
+ timestamp: raw.timestamp ?? '',
192
+ message: raw.message,
193
+ }));
194
+ return event.message?.normalizedUsage ?? null;
195
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Accumulate normalized usage into a subagent's token fields so every
3
+ * provider's `scanSubagents()` reports the same four buckets and the same
4
+ * cache-inclusive total as `summarizeTokens()`.
5
+ *
6
+ * Browser-safe: no Node imports.
7
+ *
8
+ * @module usage/subagentUsage
9
+ */
10
+ import type { NormalizedUsage } from '../usageNormalization';
11
+ import type { SubagentStats } from '../types/sessionEvent';
12
+ /** Add one usage record (a call or a correction) to a subagent's token totals. */
13
+ export declare function addUsageToSubagent(stats: SubagentStats, usage: NormalizedUsage): void;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ /**
3
+ * Accumulate normalized usage into a subagent's token fields so every
4
+ * provider's `scanSubagents()` reports the same four buckets and the same
5
+ * cache-inclusive total as `summarizeTokens()`.
6
+ *
7
+ * Browser-safe: no Node imports.
8
+ *
9
+ * @module usage/subagentUsage
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.addUsageToSubagent = addUsageToSubagent;
13
+ /** Add one usage record (a call or a correction) to a subagent's token totals. */
14
+ function addUsageToSubagent(stats, usage) {
15
+ stats.inputTokens += usage.uncachedInputTokens;
16
+ stats.outputTokens += usage.outputTokens;
17
+ stats.cacheReadTokens = (stats.cacheReadTokens ?? 0) + usage.cacheReadTokens;
18
+ stats.cacheWriteTokens = (stats.cacheWriteTokens ?? 0) + usage.cacheWriteTokens;
19
+ stats.reasoningTokens = (stats.reasoningTokens ?? 0) + usage.reasoningTokens;
20
+ stats.totalTokens = (stats.totalTokens ?? 0) + usage.totalTokens;
21
+ }
@@ -10,6 +10,11 @@
10
10
  * catalog refresh never leaves stale costs behind. Cache files are touched on
11
11
  * every hit and pruned least-recently-used, once per collection.
12
12
  *
13
+ * A Claude Code session's subagents write their own transcripts under
14
+ * `<sessionId>/subagents/`; their usage is attributed to the parent session
15
+ * and their sizes and mtimes join the fingerprint. (Codex spawned subagents
16
+ * are top-level rollouts, already listed as sessions of their own.)
17
+ *
13
18
  * Node-only: reads session files and the cache directory.
14
19
  *
15
20
  * @module usage/usageEvents
@@ -17,8 +22,12 @@
17
22
  import type { ProviderId, SessionProviderBase, SessionProviderDiagnostic } from '../providers/types';
18
23
  import type { TokenTotals } from '../types/historicalData';
19
24
  import type { PricingProvenance } from '../usageNormalization';
20
- /** Bump when the cached record shape changes; older files are re-read. */
21
- export declare const USAGE_CACHE_VERSION = 1;
25
+ /**
26
+ * Bump when the cached record shape or the counting rules change; older files
27
+ * are re-read. v2: Claude split lines and duplicate Codex `token_count` events
28
+ * are counted once, and Claude subagent transcripts are included.
29
+ */
30
+ export declare const USAGE_CACHE_VERSION = 2;
22
31
  /**
23
32
  * Most-recently-used cache files kept on disk. Sized for a busy month of
24
33
  * sessions (files are a few KB each); a collection larger than this still
@@ -69,6 +78,13 @@ export interface CollectUsageEventsOptions {
69
78
  /** Skip the on-disk cache entirely. */
70
79
  noCache?: boolean;
71
80
  onDiagnostic?: (diagnostic: SessionProviderDiagnostic) => void;
81
+ /**
82
+ * Stops the collection before listing and between sessions; the promise
83
+ * rejects with the signal's reason (an `AbortError` by default). Sessions
84
+ * read before the abort keep their cache entries; no partial session is
85
+ * cached.
86
+ */
87
+ signal?: AbortSignal;
72
88
  }
73
89
  export interface CollectUsageEventsResult {
74
90
  /** Events within the window, oldest first. */
@@ -11,6 +11,11 @@
11
11
  * catalog refresh never leaves stale costs behind. Cache files are touched on
12
12
  * every hit and pruned least-recently-used, once per collection.
13
13
  *
14
+ * A Claude Code session's subagents write their own transcripts under
15
+ * `<sessionId>/subagents/`; their usage is attributed to the parent session
16
+ * and their sizes and mtimes join the fingerprint. (Codex spawned subagents
17
+ * are top-level rollouts, already listed as sessions of their own.)
18
+ *
14
19
  * Node-only: reads session files and the cache directory.
15
20
  *
16
21
  * @module usage/usageEvents
@@ -59,8 +64,12 @@ const paths_1 = require("../paths");
59
64
  const sessionPreviews_1 = require("../sessionPreviews");
60
65
  const usageNormalization_1 = require("../usageNormalization");
61
66
  const atomic_1 = require("../writers/atomic");
62
- /** Bump when the cached record shape changes; older files are re-read. */
63
- exports.USAGE_CACHE_VERSION = 1;
67
+ /**
68
+ * Bump when the cached record shape or the counting rules change; older files
69
+ * are re-read. v2: Claude split lines and duplicate Codex `token_count` events
70
+ * are counted once, and Claude subagent transcripts are included.
71
+ */
72
+ exports.USAGE_CACHE_VERSION = 2;
64
73
  /**
65
74
  * Most-recently-used cache files kept on disk. Sized for a busy month of
66
75
  * sessions (files are a few KB each); a collection larger than this still
@@ -188,20 +197,81 @@ function priceCachedEvent(cached, provider, sessionId, project) {
188
197
  costProvenance: priced.source,
189
198
  };
190
199
  }
191
- function readSessionUsage(provider, preview) {
192
- const reader = provider.createReader(preview.filePath);
200
+ const CLAUDE_AGENT_FILE = /^agent-.+\.jsonl$/;
201
+ /** Claude Code subagent transcripts of a session, sorted by name. */
202
+ function claudeSubagentFiles(preview) {
203
+ if (preview.provider !== 'claude-code')
204
+ return [];
205
+ const dir = path.join(path.dirname(preview.filePath), preview.sessionId, 'subagents');
206
+ let names;
207
+ try {
208
+ names = fs.readdirSync(dir).filter((name) => CLAUDE_AGENT_FILE.test(name));
209
+ }
210
+ catch {
211
+ return [];
212
+ }
213
+ const files = [];
214
+ for (const name of names.sort()) {
215
+ try {
216
+ const stat = fs.statSync(path.join(dir, name));
217
+ files.push({
218
+ path: path.join(dir, name),
219
+ name,
220
+ sizeBytes: stat.size,
221
+ modifiedAt: stat.mtimeMs,
222
+ });
223
+ }
224
+ catch {
225
+ // Removed between listing and stat; the next collection re-reads.
226
+ }
227
+ }
228
+ return files;
229
+ }
230
+ function sessionFingerprint(preview, subagents) {
231
+ const base = `${preview.sizeBytes}:${preview.modifiedAt}`;
232
+ if (subagents.length === 0)
233
+ return base;
234
+ return `${base}|${subagents.map((f) => `${f.name}:${f.sizeBytes}:${f.modifiedAt}`).join(',')}`;
235
+ }
236
+ function readTranscriptUsage(provider, filePath, cached) {
237
+ const reader = provider.createReader(filePath);
193
238
  const events = reader.readAll();
194
239
  reader.flush();
195
- const cached = [];
240
+ // A correction tops up the call its message already recorded, so it merges
241
+ // into that record instead of counting as another call.
242
+ const byMessageId = new Map();
196
243
  for (const event of events) {
197
244
  const usage = (0, usageNormalization_1.extractNormalizedUsage)(event);
198
245
  if (!usage)
199
246
  continue;
247
+ const messageId = event.message?.id;
248
+ const target = event.message?.usageKind === 'correction' && messageId
249
+ ? byMessageId.get(messageId)
250
+ : undefined;
251
+ if (target) {
252
+ target.i += usage.uncachedInputTokens;
253
+ target.o += usage.outputTokens;
254
+ target.cw += usage.cacheWriteTokens;
255
+ target.cr += usage.cacheReadTokens;
256
+ target.bo += usage.billableOutputTokens;
257
+ target.tt += usage.totalTokens;
258
+ continue;
259
+ }
200
260
  const timestamp = Date.parse(event.timestamp);
201
261
  if (!Number.isFinite(timestamp))
202
262
  continue;
203
- cached.push(cachedEventFromUsage(usage, timestamp, event.message?.model ?? usage.model ?? 'unknown'));
263
+ const record = cachedEventFromUsage(usage, timestamp, event.message?.model ?? usage.model ?? 'unknown');
264
+ cached.push(record);
265
+ if (messageId)
266
+ byMessageId.set(messageId, record);
204
267
  }
268
+ }
269
+ function readSessionUsage(provider, preview, subagents) {
270
+ const cached = [];
271
+ readTranscriptUsage(provider, preview.filePath, cached);
272
+ for (const file of subagents)
273
+ readTranscriptUsage(provider, file.path, cached);
274
+ cached.sort((a, b) => a.t - b.t);
205
275
  return cached;
206
276
  }
207
277
  function yieldToEventLoop() {
@@ -220,12 +290,15 @@ async function collectUsageEvents(options) {
220
290
  const untilMs = toMs(options.until, 'until');
221
291
  const cacheDir = options.cacheDir ?? getUsageCacheDir();
222
292
  const useCache = !options.noCache;
293
+ const signal = options.signal;
294
+ signal?.throwIfAborted();
223
295
  const listed = await (0, sessionPreviews_1.listSessionPreviewsAsync)(options.providers, {
224
296
  since: options.since,
225
297
  workspacePath: options.workspacePath,
226
298
  limit: Number.MAX_SAFE_INTEGER,
227
299
  concurrency: options.concurrency,
228
300
  });
301
+ signal?.throwIfAborted();
229
302
  const diagnostics = [...listed.diagnostics];
230
303
  for (const diagnostic of listed.diagnostics)
231
304
  options.onDiagnostic?.(diagnostic);
@@ -236,10 +309,12 @@ async function collectUsageEvents(options) {
236
309
  let cacheMisses = 0;
237
310
  let wroteCache = false;
238
311
  for (const preview of listed.previews) {
312
+ signal?.throwIfAborted();
239
313
  const provider = providerById.get(preview.provider);
240
314
  if (!provider)
241
315
  continue;
242
- const fingerprint = `${preview.sizeBytes}:${preview.modifiedAt}`;
316
+ const subagentFiles = claudeSubagentFiles(preview);
317
+ const fingerprint = sessionFingerprint(preview, subagentFiles);
243
318
  const filePath = cacheFilePath(cacheDir, preview.provider, preview.sessionId);
244
319
  let cachedEvents = null;
245
320
  let project = preview.workspacePath;
@@ -257,7 +332,7 @@ async function collectUsageEvents(options) {
257
332
  if (!cachedEvents) {
258
333
  cacheMisses += 1;
259
334
  try {
260
- cachedEvents = readSessionUsage(provider, preview);
335
+ cachedEvents = readSessionUsage(provider, preview, subagentFiles);
261
336
  }
262
337
  catch (error) {
263
338
  const diagnostic = {
@@ -29,6 +29,7 @@ function toFollowEvents(event, providerId) {
29
29
  ? { read: usage.cache_read_input_tokens || 0, write: usage.cache_creation_input_tokens || 0 }
30
30
  : undefined;
31
31
  const cost = usage?.reported_cost;
32
+ const usageKind = usage ? event.message?.usageKind : undefined;
32
33
  const model = event.message?.model;
33
34
  const rateLimits = event.rateLimits;
34
35
  switch (event.type) {
@@ -94,6 +95,7 @@ function toFollowEvents(event, providerId) {
94
95
  summary: text || '(thinking...)',
95
96
  tokens,
96
97
  cacheTokens,
98
+ ...(usageKind ? { usageKind } : {}),
97
99
  cost,
98
100
  model,
99
101
  raw: event,
@@ -104,6 +106,8 @@ function toFollowEvents(event, providerId) {
104
106
  const last = events[events.length - 1];
105
107
  last.tokens = tokens;
106
108
  last.cacheTokens = cacheTokens;
109
+ if (usageKind)
110
+ last.usageKind = usageKind;
107
111
  last.cost = cost;
108
112
  }
109
113
  break;
@@ -158,6 +162,7 @@ function toFollowEvents(event, providerId) {
158
162
  summary: label && text ? `${label}: ${text}` : summary,
159
163
  tokens,
160
164
  cacheTokens,
165
+ ...(usageKind ? { usageKind } : {}),
161
166
  cost,
162
167
  model,
163
168
  rateLimits,
@@ -11,6 +11,9 @@ export declare class JsonlSessionWatcher implements SessionWatcher {
11
11
  private tail;
12
12
  private startOffset;
13
13
  private codexInPlanMode;
14
+ private readonly claudeUsageDeduper;
15
+ /** Last Codex cumulative usage seen; repeated `token_count` events carry the same value. */
16
+ private codexPrevTotal;
14
17
  constructor(providerId: ProviderId, sessionPath: string, callbacks: SessionWatcherCallbacks);
15
18
  get isActive(): boolean;
16
19
  /** Seek to a committed byte offset before starting replay. */
@@ -20,5 +23,7 @@ export declare class JsonlSessionWatcher implements SessionWatcher {
20
23
  start(replay: boolean): void;
21
24
  stop(): void;
22
25
  private handleRawEvent;
26
+ /** One call's usage from a token_count `info`, skipping duplicate events. */
27
+ private codexCallUsage;
23
28
  private normalizeCodexEvent;
24
29
  }
@@ -8,12 +8,29 @@ exports.JsonlSessionWatcher = void 0;
8
8
  const jsonlTail_1 = require("./jsonlTail");
9
9
  const codexParser_1 = require("../parsers/codexParser");
10
10
  const toolSummary_1 = require("../formatters/toolSummary");
11
+ const claudeUsageDedupe_1 = require("../usage/claudeUsageDedupe");
11
12
  // ── Normalizers ──
12
- function normalizeClaudeCodeEvent(raw) {
13
+ /**
14
+ * Split lines of one Claude response repeat its usage; run the raw usage
15
+ * through the shared deduper so each response is counted once (last copy wins).
16
+ */
17
+ function dedupedClaudeUsage(raw, deduper) {
18
+ if (!raw.message?.usage)
19
+ return { usage: undefined, usageKind: undefined };
20
+ const event = deduper.apply((0, claudeUsageDedupe_1.normalizeClaudeUsage)({
21
+ type: 'assistant',
22
+ timestamp: raw.timestamp || '',
23
+ message: raw.message,
24
+ }));
25
+ return { usage: event.message?.usage, usageKind: event.message?.usageKind };
26
+ }
27
+ function normalizeClaudeCodeEvent(raw, deduper) {
13
28
  const events = [];
14
29
  const ts = raw.timestamp || new Date().toISOString();
15
30
  const permissionMode = raw.permissionMode;
16
- const usage = raw.message?.usage;
31
+ const { usage, usageKind } = raw.type === 'assistant'
32
+ ? dedupedClaudeUsage(raw, deduper)
33
+ : { usage: undefined, usageKind: undefined };
17
34
  const tokens = usage
18
35
  ? { input: usage.input_tokens || 0, output: usage.output_tokens || 0 }
19
36
  : undefined;
@@ -83,6 +100,7 @@ function normalizeClaudeCodeEvent(raw) {
83
100
  summary: text || '(thinking...)',
84
101
  tokens,
85
102
  cacheTokens,
103
+ ...(usageKind ? { usageKind } : {}),
86
104
  cost,
87
105
  model,
88
106
  raw,
@@ -93,6 +111,8 @@ function normalizeClaudeCodeEvent(raw) {
93
111
  const last = events[events.length - 1];
94
112
  last.tokens = tokens;
95
113
  last.cacheTokens = cacheTokens;
114
+ if (usageKind)
115
+ last.usageKind = usageKind;
96
116
  last.cost = cost;
97
117
  }
98
118
  }
@@ -190,6 +210,9 @@ class JsonlSessionWatcher {
190
210
  tail;
191
211
  startOffset;
192
212
  codexInPlanMode = false;
213
+ claudeUsageDeduper = new claudeUsageDedupe_1.ClaudeUsageDeduper();
214
+ /** Last Codex cumulative usage seen; repeated `token_count` events carry the same value. */
215
+ codexPrevTotal;
193
216
  constructor(providerId, sessionPath, callbacks) {
194
217
  this.providerId = providerId;
195
218
  this.sessionPath = sessionPath;
@@ -223,17 +246,25 @@ class JsonlSessionWatcher {
223
246
  stop() {
224
247
  this.tail?.stop();
225
248
  this.codexInPlanMode = false;
249
+ this.claudeUsageDeduper.reset();
250
+ this.codexPrevTotal = undefined;
226
251
  }
227
252
  handleRawEvent(event) {
228
253
  // For codex, raw events don't match RawSessionEvent shape exactly, but
229
254
  // JsonlParser parses any JSON object. Cast to Record for codex normalizer.
230
255
  const followEvents = this.providerId === 'codex'
231
256
  ? this.normalizeCodexEvent(event)
232
- : normalizeClaudeCodeEvent(event);
257
+ : normalizeClaudeCodeEvent(event, this.claudeUsageDeduper);
233
258
  for (const fe of followEvents) {
234
259
  this.callbacks.onEvent(fe);
235
260
  }
236
261
  }
262
+ /** One call's usage from a token_count `info`, skipping duplicate events. */
263
+ codexCallUsage(info) {
264
+ const resolved = (0, codexParser_1.resolveCodexCallUsage)(info, this.codexPrevTotal ?? null);
265
+ this.codexPrevTotal = resolved.total ?? undefined;
266
+ return resolved.usage;
267
+ }
237
268
  normalizeCodexEvent(raw) {
238
269
  const events = [];
239
270
  const ts = raw.timestamp || new Date().toISOString();
@@ -332,7 +363,7 @@ class JsonlSessionWatcher {
332
363
  const evtType = payload?.type;
333
364
  if (evtType === 'token_count') {
334
365
  const info = payload?.info;
335
- const usage = (info?.last_token_usage || info?.total_token_usage);
366
+ const usage = this.codexCallUsage(info);
336
367
  const rl = payload?.rate_limits;
337
368
  const rateLimits = rl ? extractRateLimits(rl) : undefined;
338
369
  if (usage || rateLimits) {
@@ -343,12 +374,16 @@ class JsonlSessionWatcher {
343
374
  summary: usage
344
375
  ? `Tokens: ${usage.input_tokens ?? 0} in / ${usage.output_tokens ?? 0} out`
345
376
  : 'Rate limits updated',
377
+ // Codex input includes cached input; FollowEvent buckets are disjoint.
346
378
  tokens: usage
347
379
  ? {
348
- input: usage.input_tokens || 0,
380
+ input: Math.max(0, (usage.input_tokens || 0) - (usage.cached_input_tokens || 0)),
349
381
  output: usage.output_tokens || 0,
350
382
  }
351
383
  : undefined,
384
+ cacheTokens: usage?.cached_input_tokens
385
+ ? { read: usage.cached_input_tokens, write: 0 }
386
+ : undefined,
352
387
  rateLimits,
353
388
  raw,
354
389
  });
@@ -18,6 +18,11 @@ export interface FollowEvent {
18
18
  read: number;
19
19
  write: number;
20
20
  };
21
+ /**
22
+ * `'correction'` marks tokens that top up a response already counted (a later
23
+ * split line reported more). Absent means a regular call.
24
+ */
25
+ usageKind?: 'call' | 'correction';
21
26
  cost?: number;
22
27
  toolName?: string;
23
28
  toolInput?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.27.2",
3
+ "version": "0.27.4",
4
4
  "description": "Shared data access layer for Sidekick — readers, types, providers, credentials, quota",
5
5
  "keywords": [
6
6
  "claude-code",