ftown-bridge 0.20.4 → 0.20.6

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.
@@ -0,0 +1,98 @@
1
+ export const SECURITY_NUDGE_TYPE = 'ftown_security_changed';
2
+ /** `bridges:security#${userId}`; userId is the Centrifugo user (account email). */
3
+ export function securityNudgeChannel(userId) {
4
+ if (!userId)
5
+ throw new Error('securityNudgeChannel: userId is required');
6
+ return `bridges:security#${userId}`;
7
+ }
8
+ export function securityNudge(scope) {
9
+ return { type: SECURITY_NUDGE_TYPE, version: 1, scope };
10
+ }
11
+ export function parseSecurityNudge(data) {
12
+ if (typeof data !== 'object' || data === null || Array.isArray(data))
13
+ return null;
14
+ const proto = Object.getPrototypeOf(data);
15
+ if (proto !== Object.prototype && proto !== null)
16
+ return null;
17
+ const record = data;
18
+ const keys = Object.keys(record);
19
+ if (keys.length !== 3 || !keys.includes('type') || !keys.includes('version') || !keys.includes('scope'))
20
+ return null;
21
+ const type = record.type;
22
+ const version = record.version;
23
+ const scope = record.scope;
24
+ if (type !== SECURITY_NUDGE_TYPE || version !== 1)
25
+ return null;
26
+ return scope === 'approvals' || scope === 'account' ? scope : null;
27
+ }
28
+ /** Minimum spacing between nudge-triggered refreshes of one scope at one receiver. */
29
+ export const SECURITY_NUDGE_MIN_SPACING_MS = 1000;
30
+ /** Bridge-only safety re-read of account trust when no nudge arrives. */
31
+ export const ACCOUNT_SAFETY_REFRESH_MS = 3600000;
32
+ const defaultTimers = {
33
+ now: () => Date.now(),
34
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
35
+ clearTimeout: (handle) => clearTimeout(handle),
36
+ };
37
+ /**
38
+ * Calls `run` for request()s, never two runs at once, and never two run starts closer than minSpacingMs.
39
+ * Requests during a run or inside the spacing window collapse into ONE trailing run.
40
+ * `run` rejections are swallowed. After dispose(), nothing runs. Timers use the injected `timers`.
41
+ */
42
+ export function createCoalescedTrigger(run, minSpacingMs, timers = defaultTimers) {
43
+ let disposed = false;
44
+ let running = false;
45
+ let pending = false;
46
+ let lastStart = null;
47
+ let timer = null;
48
+ const schedule = () => {
49
+ if (disposed || running || !pending || timer !== null)
50
+ return;
51
+ const wait = lastStart === null ? 0 : lastStart + minSpacingMs - timers.now();
52
+ if (wait <= 0) {
53
+ start();
54
+ return;
55
+ }
56
+ timer = timers.setTimeout(() => { timer = null; schedule(); }, wait);
57
+ };
58
+ const finish = () => {
59
+ running = false;
60
+ schedule();
61
+ };
62
+ const start = () => {
63
+ pending = false;
64
+ running = true;
65
+ lastStart = timers.now();
66
+ let result;
67
+ try {
68
+ result = run();
69
+ }
70
+ catch (_error) {
71
+ finish();
72
+ return;
73
+ }
74
+ if (result && typeof result.then === 'function') {
75
+ result.then(finish, finish);
76
+ }
77
+ else {
78
+ finish();
79
+ }
80
+ };
81
+ return {
82
+ request() {
83
+ if (disposed)
84
+ return;
85
+ pending = true;
86
+ schedule();
87
+ },
88
+ dispose() {
89
+ disposed = true;
90
+ pending = false;
91
+ if (timer !== null) {
92
+ timers.clearTimeout(timer);
93
+ timer = null;
94
+ }
95
+ },
96
+ };
97
+ }
98
+ //# sourceMappingURL=security-nudge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"security-nudge.js","sourceRoot":"","sources":["../../src/security/security-nudge.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { type JsonlFoldCache } from './jsonl-fold-cache.js';
1
2
  import type { Session, SessionUsage } from './types.js';
2
3
  /**
3
4
  * Per-session token usage extraction from harness-native session files.
@@ -38,6 +39,8 @@ export interface UsageCollectorOptions {
38
39
  museSessionsDir?: string;
39
40
  /** Cap on wall-clock read time per collection. Default 15s. */
40
41
  timeoutMs?: number;
42
+ /** Incremental read cache. Default: defaultJsonlFoldCache (shared process-wide). Tests inject a fresh one. */
43
+ foldCache?: JsonlFoldCache;
41
44
  }
42
45
  /**
43
46
  * Claude Code project-directory slug: every non-alphanumeric character of the
@@ -3,7 +3,39 @@ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { homedir } from 'node:os';
5
5
  import { join, resolve, sep } from 'node:path';
6
+ import { defaultJsonlFoldCache } from './jsonl-fold-cache.js';
6
7
  const DEFAULT_TIMEOUT_MS = 15_000;
8
+ function newModelSums() {
9
+ return { byModel: new Map(), counted: 0 };
10
+ }
11
+ function modelAcc(state, model) {
12
+ let acc = state.byModel.get(model);
13
+ if (!acc) {
14
+ acc = { model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
15
+ state.byModel.set(model, acc);
16
+ }
17
+ return acc;
18
+ }
19
+ /** Builds the SessionUsage for per-model sums; copies, so the result never aliases fold state. */
20
+ function usageFromModels(byModel, harness) {
21
+ const perModel = [...byModel.values()].map((m) => ({ ...m }));
22
+ const sum = (pick) => perModel.reduce((acc, m) => acc + pick(m), 0);
23
+ const inputTokens = sum((m) => m.inputTokens);
24
+ const outputTokens = sum((m) => m.outputTokens);
25
+ const cacheReadTokens = sum((m) => m.cacheReadTokens);
26
+ const cacheWriteTokens = sum((m) => m.cacheWriteTokens);
27
+ return {
28
+ inputTokens,
29
+ outputTokens,
30
+ cacheReadTokens,
31
+ cacheWriteTokens,
32
+ totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
33
+ models: perModel.map((m) => m.model),
34
+ perModel,
35
+ harness,
36
+ collectedAt: new Date().toISOString(),
37
+ };
38
+ }
7
39
  /**
8
40
  * Claude Code project-directory slug: every non-alphanumeric character of the
9
41
  * working directory becomes '-'. Verified against real transcript dirs on this
@@ -93,48 +125,29 @@ async function collectPiUsage(workingDir, sessionCreatedAt, options, nativeSessi
93
125
  const filePath = await containedPiSessionFile(baseDir, candidatePath);
94
126
  if (!filePath)
95
127
  return null;
96
- let counted = 0;
97
- const byModel = new Map();
98
- for await (const raw of jsonlLines(filePath, deadline)) {
128
+ const usage = await (options.foldCache ?? defaultJsonlFoldCache).fold(filePath, piUsageFold, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, (state) => (state.counted === 0 ? null : usageFromModels(state.byModel, 'pi')));
129
+ return usage ?? null;
130
+ }
131
+ const piUsageFold = {
132
+ kind: 'pi-usage-v1',
133
+ init: newModelSums,
134
+ step(state, raw) {
99
135
  const entry = raw;
100
136
  const message = entry?.type === 'message' ? entry.message : undefined;
101
137
  const usage = message?.role === 'assistant' ? message.usage : undefined;
102
138
  if (!message || !usage || typeof message.model !== 'string' || !message.model)
103
- continue;
139
+ return;
104
140
  const model = typeof message.provider === 'string' && message.provider
105
141
  ? `${message.provider}/${message.model}`
106
142
  : message.model;
107
- let acc = byModel.get(model);
108
- if (!acc) {
109
- acc = { model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
110
- byModel.set(model, acc);
111
- }
143
+ const acc = modelAcc(state, model);
112
144
  acc.inputTokens += safeTokenCount(usage.input);
113
145
  acc.outputTokens += safeTokenCount(usage.output);
114
146
  acc.cacheReadTokens += safeTokenCount(usage.cacheRead);
115
147
  acc.cacheWriteTokens += safeTokenCount(usage.cacheWrite);
116
- counted += 1;
117
- }
118
- if (counted === 0)
119
- return null;
120
- const perModel = [...byModel.values()];
121
- const sum = (pick) => perModel.reduce((total, model) => total + pick(model), 0);
122
- const inputTokens = sum((model) => model.inputTokens);
123
- const outputTokens = sum((model) => model.outputTokens);
124
- const cacheReadTokens = sum((model) => model.cacheReadTokens);
125
- const cacheWriteTokens = sum((model) => model.cacheWriteTokens);
126
- return {
127
- inputTokens,
128
- outputTokens,
129
- cacheReadTokens,
130
- cacheWriteTokens,
131
- totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
132
- models: perModel.map((model) => model.model),
133
- perModel,
134
- harness: 'pi',
135
- collectedAt: new Date().toISOString(),
136
- };
137
- }
148
+ state.counted += 1;
149
+ },
150
+ };
138
151
  function safeTokenCount(value) {
139
152
  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
140
153
  }
@@ -183,64 +196,39 @@ async function collectClaudeUsage(claudeSessionId, workingDir, options) {
183
196
  return null;
184
197
  const baseDir = options.claudeProjectsDir ?? join(homedir(), '.claude', 'projects');
185
198
  const filePath = join(baseDir, claudeProjectSlug(workingDir), `${claudeSessionId}.jsonl`);
186
- const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
187
- let counted = 0;
188
- // Keyed by model id; Map preserves first-appearance order.
189
- const byModel = new Map();
190
- // An assistant message spans MULTIPLE jsonl lines (one per content block),
191
- // each repeating the same message.id and identical usage — count each
192
- // message id exactly once or sums double/triple.
193
- const seenMessageIds = new Set();
194
- for await (const raw of jsonlLines(filePath, deadline)) {
199
+ const usage = await (options.foldCache ?? defaultJsonlFoldCache).fold(filePath, claudeUsageFold, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, (state) => (state.counted === 0 ? null : usageFromModels(state.byModel, 'claude')));
200
+ return usage ?? null;
201
+ }
202
+ const claudeUsageFold = {
203
+ kind: 'claude-usage-v1',
204
+ init: () => ({ ...newModelSums(), seenMessageIds: new Set() }),
205
+ step(state, raw) {
195
206
  const entry = raw;
196
207
  if (entry?.type !== 'assistant')
197
- continue;
208
+ return;
198
209
  const message = entry.message;
199
210
  const usage = message?.usage;
200
211
  if (!message || !usage)
201
- continue;
212
+ return;
202
213
  const model = message.model ?? '';
203
214
  // '<synthetic>' rows are harness-injected placeholders with zero usage.
204
215
  if (!model || model === '<synthetic>')
205
- continue;
216
+ return;
206
217
  const id = message.id;
207
218
  if (id) {
208
- if (seenMessageIds.has(id))
209
- continue;
210
- seenMessageIds.add(id);
219
+ if (state.seenMessageIds.has(id))
220
+ return;
221
+ state.seenMessageIds.add(id);
211
222
  }
212
223
  // Models can vary mid-session — attribute tokens per message's model.
213
- let acc = byModel.get(model);
214
- if (!acc) {
215
- acc = { model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
216
- byModel.set(model, acc);
217
- }
224
+ const acc = modelAcc(state, model);
218
225
  acc.inputTokens += usage.input_tokens ?? 0;
219
226
  acc.outputTokens += usage.output_tokens ?? 0;
220
227
  acc.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
221
228
  acc.cacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
222
- counted += 1;
223
- }
224
- if (counted === 0)
225
- return null;
226
- const perModel = [...byModel.values()];
227
- const sum = (pick) => perModel.reduce((acc, m) => acc + pick(m), 0);
228
- const inputTokens = sum((m) => m.inputTokens);
229
- const outputTokens = sum((m) => m.outputTokens);
230
- const cacheReadTokens = sum((m) => m.cacheReadTokens);
231
- const cacheWriteTokens = sum((m) => m.cacheWriteTokens);
232
- return {
233
- inputTokens,
234
- outputTokens,
235
- cacheReadTokens,
236
- cacheWriteTokens,
237
- totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
238
- models: perModel.map((m) => m.model),
239
- perModel,
240
- harness: 'claude',
241
- collectedAt: new Date().toISOString(),
242
- };
243
- }
229
+ state.counted += 1;
230
+ },
231
+ };
244
232
  /** Resolve ~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-*-<id>.jsonl (date-partitioned). */
245
233
  async function findCodexRollout(baseDir, codexSessionId) {
246
234
  const suffix = `-${codexSessionId}.jsonl`;
@@ -282,41 +270,16 @@ async function collectCodexUsage(codexSessionId, options) {
282
270
  const filePath = await findCodexRollout(baseDir, codexSessionId);
283
271
  if (!filePath)
284
272
  return null;
285
- const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
286
- const models = [];
287
- let lastTotals = null;
288
- for await (const raw of jsonlLines(filePath, deadline)) {
289
- const entry = raw;
290
- const payload = entry?.payload;
291
- if (!payload)
292
- continue;
293
- if (entry.type === 'turn_context' && payload.model) {
294
- if (!models.includes(payload.model))
295
- models.push(payload.model);
296
- continue;
297
- }
298
- // token_count events carry cumulative totals — the LAST one wins. Some
299
- // token_count events have info: null (rate-limit-only updates); skip them.
300
- if (payload.type === 'token_count') {
301
- const totals = payload.info?.total_token_usage;
302
- if (totals) {
303
- lastTotals = {
304
- input: totals.input_tokens ?? 0,
305
- cached: totals.cached_input_tokens ?? 0,
306
- output: totals.output_tokens ?? 0,
307
- };
308
- }
309
- }
310
- }
311
- if (!lastTotals)
273
+ const totals = await (options.foldCache ?? defaultJsonlFoldCache).fold(filePath, codexUsageFold, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, (state) => (state.lastTotals ? { models: [...state.models], ...state.lastTotals } : null));
274
+ if (!totals)
312
275
  return null;
313
276
  // Codex counts cached tokens INSIDE input_tokens (input 11276 / cached 9088 /
314
277
  // total 11305 = 11276 + 29 in real rollouts) — split them so totalTokens
315
278
  // (sum of the four) matches codex's own total_tokens. output_tokens already
316
279
  // includes reasoning tokens. Codex reports no cache writes.
317
- const cacheReadTokens = Math.min(lastTotals.cached, lastTotals.input);
318
- const inputTokens = lastTotals.input - cacheReadTokens;
319
- const outputTokens = lastTotals.output;
280
+ const cacheReadTokens = Math.min(totals.cached, totals.input);
281
+ const inputTokens = totals.input - cacheReadTokens;
282
+ const outputTokens = totals.output;
320
283
  const cacheWriteTokens = 0;
321
284
  // Codex rollouts carry only cumulative totals — there is no per-model
322
285
  // attribution, so perModel is deliberately absent (models lists what ran).
@@ -326,11 +289,38 @@ async function collectCodexUsage(codexSessionId, options) {
326
289
  cacheReadTokens,
327
290
  cacheWriteTokens,
328
291
  totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
329
- models,
292
+ models: totals.models,
330
293
  harness: 'codex',
331
294
  collectedAt: new Date().toISOString(),
332
295
  };
333
296
  }
297
+ const codexUsageFold = {
298
+ kind: 'codex-usage-v1',
299
+ init: () => ({ models: [], lastTotals: null }),
300
+ step(state, raw) {
301
+ const entry = raw;
302
+ const payload = entry?.payload;
303
+ if (!payload)
304
+ return;
305
+ if (entry.type === 'turn_context' && payload.model) {
306
+ if (!state.models.includes(payload.model))
307
+ state.models.push(payload.model);
308
+ return;
309
+ }
310
+ // token_count events carry cumulative totals — the LAST one wins. Some
311
+ // token_count events have info: null (rate-limit-only updates); skip them.
312
+ if (payload.type === 'token_count') {
313
+ const totals = payload.info?.total_token_usage;
314
+ if (totals) {
315
+ state.lastTotals = {
316
+ input: totals.input_tokens ?? 0,
317
+ cached: totals.cached_input_tokens ?? 0,
318
+ output: totals.output_tokens ?? 0,
319
+ };
320
+ }
321
+ }
322
+ },
323
+ };
334
324
  /**
335
325
  * Resolve the kimi-code session dir that this ftown session spawned.
336
326
  *
@@ -393,54 +383,57 @@ async function collectKimiCodeUsage(workingDir, sessionCreatedAt, options) {
393
383
  catch {
394
384
  return null;
395
385
  }
386
+ const cache = options.foldCache ?? defaultJsonlFoldCache;
396
387
  let counted = 0;
397
388
  // Keyed by model id; Map preserves first-appearance order across agents.
398
389
  const byModel = new Map();
399
390
  for (const agent of agentNames) {
400
391
  const wirePath = join(agentsDir, agent, 'wire.jsonl');
401
- for await (const raw of jsonlLines(wirePath, deadline)) {
402
- const entry = raw;
403
- if (entry?.type !== 'usage.record')
404
- continue;
405
- const usage = entry.usage;
406
- if (!usage)
407
- continue;
408
- const model = entry.model ?? '';
409
- if (!model)
410
- continue;
411
- let acc = byModel.get(model);
392
+ const sums = await cache.fold(wirePath, kimiUsageFold, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, (state) => ({
393
+ byModel: new Map([...state.byModel].map(([k, m]) => [k, { ...m }])),
394
+ counted: state.counted,
395
+ }));
396
+ if (!sums)
397
+ continue;
398
+ counted += sums.counted;
399
+ for (const m of sums.byModel.values()) {
400
+ const acc = byModel.get(m.model);
412
401
  if (!acc) {
413
- acc = { model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
414
- byModel.set(model, acc);
402
+ byModel.set(m.model, m);
403
+ continue;
415
404
  }
416
- // Per-turn incremental records — SUM across all of them.
417
- acc.inputTokens += usage.inputOther ?? 0;
418
- acc.outputTokens += usage.output ?? 0;
419
- acc.cacheReadTokens += usage.inputCacheRead ?? 0;
420
- acc.cacheWriteTokens += usage.inputCacheCreation ?? 0;
421
- counted += 1;
405
+ acc.inputTokens += m.inputTokens;
406
+ acc.outputTokens += m.outputTokens;
407
+ acc.cacheReadTokens += m.cacheReadTokens;
408
+ acc.cacheWriteTokens += m.cacheWriteTokens;
422
409
  }
423
410
  }
424
411
  if (counted === 0)
425
412
  return null;
426
- const perModel = [...byModel.values()];
427
- const sum = (pick) => perModel.reduce((acc, m) => acc + pick(m), 0);
428
- const inputTokens = sum((m) => m.inputTokens);
429
- const outputTokens = sum((m) => m.outputTokens);
430
- const cacheReadTokens = sum((m) => m.cacheReadTokens);
431
- const cacheWriteTokens = sum((m) => m.cacheWriteTokens);
432
- return {
433
- inputTokens,
434
- outputTokens,
435
- cacheReadTokens,
436
- cacheWriteTokens,
437
- totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
438
- models: perModel.map((m) => m.model),
439
- perModel,
440
- harness: 'kimi-code',
441
- collectedAt: new Date().toISOString(),
442
- };
413
+ return usageFromModels(byModel, 'kimi-code');
443
414
  }
415
+ const kimiUsageFold = {
416
+ kind: 'kimi-usage-v1',
417
+ init: newModelSums,
418
+ step(state, raw) {
419
+ const entry = raw;
420
+ if (entry?.type !== 'usage.record')
421
+ return;
422
+ const usage = entry.usage;
423
+ if (!usage)
424
+ return;
425
+ const model = entry.model ?? '';
426
+ if (!model)
427
+ return;
428
+ const acc = modelAcc(state, model);
429
+ // Per-turn incremental records — SUM across all of them.
430
+ acc.inputTokens += usage.inputOther ?? 0;
431
+ acc.outputTokens += usage.output ?? 0;
432
+ acc.cacheReadTokens += usage.inputCacheRead ?? 0;
433
+ acc.cacheWriteTokens += usage.inputCacheCreation ?? 0;
434
+ state.counted += 1;
435
+ },
436
+ };
444
437
  /**
445
438
  * Resolve the muse session.jsonl that this ftown session spawned.
446
439
  *
@@ -578,8 +571,7 @@ async function collectMuseUsageById(museSessionId, options) {
578
571
  const filePath = await findMuseSessionFileById(baseDir, museSessionId);
579
572
  if (!filePath)
580
573
  return null;
581
- const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
582
- return sumMuseSessionFile(filePath, deadline);
574
+ return sumMuseSessionFile(filePath, options);
583
575
  }
584
576
  async function collectMuseUsage(workingDir, sessionCreatedAt, options) {
585
577
  const baseDir = options.museSessionsDir
@@ -588,28 +580,27 @@ async function collectMuseUsage(workingDir, sessionCreatedAt, options) {
588
580
  const filePath = await resolveMuseSessionFile(baseDir, workingDir, sessionCreatedAt, deadline);
589
581
  if (!filePath)
590
582
  return null;
591
- return sumMuseSessionFile(filePath, deadline);
583
+ return sumMuseSessionFile(filePath, options);
592
584
  }
593
- async function sumMuseSessionFile(filePath, deadline) {
594
- let counted = 0;
595
- // Keyed by model id; Map preserves first-appearance order.
596
- const byModel = new Map();
597
- for await (const raw of jsonlLines(filePath, deadline)) {
585
+ async function sumMuseSessionFile(filePath, options) {
586
+ const usage = await (options.foldCache ?? defaultJsonlFoldCache).fold(filePath, museUsageFold, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, (state) => (state.counted === 0 ? null : usageFromModels(state.byModel, 'muse')));
587
+ return usage ?? null;
588
+ }
589
+ const museUsageFold = {
590
+ kind: 'muse-usage-v1',
591
+ init: newModelSums,
592
+ step(state, raw) {
598
593
  const entry = raw;
599
594
  if (entry?.payload_type !== 'runtime.session')
600
- continue;
595
+ return;
601
596
  const event = entry.payload?.kind === 'run' ? entry.payload.event : undefined;
602
597
  if (event?.kind !== 'model_completed')
603
- continue;
598
+ return;
604
599
  const usage = event.usage;
605
600
  const model = event.model ?? '';
606
601
  if (!usage || !model)
607
- continue;
608
- let acc = byModel.get(model);
609
- if (!acc) {
610
- acc = { model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
611
- byModel.set(model, acc);
612
- }
602
+ return;
603
+ const acc = modelAcc(state, model);
613
604
  // Per-call records — SUM across all of them. Verified against real logs:
614
605
  // input_tokens INCLUDES cached tokens (cached(n) ≈ input(n-1)), so split
615
606
  // them codex-style; cached_tokens duplicates cache_read_tokens (ignored);
@@ -620,26 +611,7 @@ async function sumMuseSessionFile(filePath, deadline) {
620
611
  acc.outputTokens += safeTokenCount(usage.output_tokens);
621
612
  acc.cacheReadTokens += cacheRead;
622
613
  acc.cacheWriteTokens += safeTokenCount(usage.cache_write_tokens);
623
- counted += 1;
624
- }
625
- if (counted === 0)
626
- return null;
627
- const perModel = [...byModel.values()];
628
- const sum = (pick) => perModel.reduce((acc, m) => acc + pick(m), 0);
629
- const inputTokens = sum((m) => m.inputTokens);
630
- const outputTokens = sum((m) => m.outputTokens);
631
- const cacheReadTokens = sum((m) => m.cacheReadTokens);
632
- const cacheWriteTokens = sum((m) => m.cacheWriteTokens);
633
- return {
634
- inputTokens,
635
- outputTokens,
636
- cacheReadTokens,
637
- cacheWriteTokens,
638
- totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
639
- models: perModel.map((m) => m.model),
640
- perModel,
641
- harness: 'muse',
642
- collectedAt: new Date().toISOString(),
643
- };
644
- }
614
+ state.counted += 1;
615
+ },
616
+ };
645
617
  //# sourceMappingURL=usage-collector.js.map