tokentracker-cli 0.83.11 → 0.84.1

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 (106) hide show
  1. package/README.de.md +1 -0
  2. package/README.ja.md +1 -0
  3. package/README.ko.md +1 -0
  4. package/README.md +1 -0
  5. package/README.zh-CN.md +1 -0
  6. package/dashboard/dist/assets/{AchievementBadge-Ddtn6vg2.js → AchievementBadge-BhAc3c3S.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-AMo1Ib5I.js → AchievementsPage-waGlLQtl.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CUMjcYst.js → AchievementsSection-T-v65Q1T.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-CA03iBRx.js → BadgeDetailModal-5FcY9olH.js} +1 -1
  10. package/dashboard/dist/assets/{Card-DscI10IE.js → Card-BBCwyki4.js} +1 -1
  11. package/dashboard/dist/assets/{ClawdAnimated-5cIXJ0NQ.js → ClawdAnimated-BZwEWFcR.js} +1 -1
  12. package/dashboard/dist/assets/{CommandPalette-qCpUMoAO.js → CommandPalette-tKp20y4V.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-FT_T1UzH.js → CommunityStatsModal-s9IKLboA.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-GI2RxzAE.js → ConfirmModal-_Mz-U7UW.js} +1 -1
  15. package/dashboard/dist/assets/{DashboardPage-CkA3pfjj.js → DashboardPage-BZ2Z_lqN.js} +1 -1
  16. package/dashboard/dist/assets/DevicePage-Bx5FaEYs.js +1 -0
  17. package/dashboard/dist/assets/{DialogClose-cfdVTNyN.js → DialogClose-DaB9Ae7L.js} +1 -1
  18. package/dashboard/dist/assets/DialogDescription-BFVeSjTD.js +1 -0
  19. package/dashboard/dist/assets/{DialogTitle-CtptgR8Z.js → DialogTitle-mSFue3N1.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-CPpE5UGk.js → FadeIn-D4PsEvSg.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-vN3e1w0o.js → HeaderGithubStar-8sRKKeCh.js} +1 -1
  22. package/dashboard/dist/assets/HoverTooltip-8s49xhej.js +1 -0
  23. package/dashboard/dist/assets/{IpCheckPage-BqJPwFGV.js → IpCheckPage-DwoeRCBI.js} +1 -1
  24. package/dashboard/dist/assets/LandingPage-DCsOsDK3.js +2 -0
  25. package/dashboard/dist/assets/{LeaderboardAvatar-BRWPXDZl.js → LeaderboardAvatar-CRO38fgs.js} +1 -1
  26. package/dashboard/dist/assets/{LeaderboardPage-Dy55Qbr0.js → LeaderboardPage-DlyrceM-.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-Dm5bW2pX.js → LeaderboardProfileModal-DwpalEE6.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-CWmLrKsM.js → LeaderboardProfilePage-BqyfnJa1.js} +1 -1
  29. package/dashboard/dist/assets/LimitsPage-DS0_zKji.js +4 -0
  30. package/dashboard/dist/assets/{LocalOnlyNotice-Dso8I7Y4.js → LocalOnlyNotice-BZolN8xC.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-CnghY3oG.js → LoginCard-CyOwV4vy.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-B30O2N_x.js → LoginModal-C6CAcIUw.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-DglsaA23.js → LoginPage-BqG9CVw7.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-C7-xrZix.js → PetPage-BtXcBqm3.js} +1 -1
  35. package/dashboard/dist/assets/{PopoverPopup-CTbuRUy2.js → PopoverPopup-JsU4A3UY.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-D-sr7Qko.js → ProviderIcon-C8fKhRQt.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-CIbj5Hpp.js → ResetPasswordPage-CKmgCNNp.js} +1 -1
  38. package/dashboard/dist/assets/{SegmentedControl-dxer1vVZ.js → SegmentedControl-LnVjL9no.js} +1 -1
  39. package/dashboard/dist/assets/{Select-BSQkd3VW.js → Select-DKmJyUtS.js} +1 -1
  40. package/dashboard/dist/assets/{SelectItemText-EGKNiN7_.js → SelectItemText-BZFLz6Op.js} +1 -1
  41. package/dashboard/dist/assets/SessionsPage-D3kT552d.js +2 -0
  42. package/dashboard/dist/assets/{SettingsPage-3MQDH9bF.js → SettingsPage-D2BLe1ZU.js} +1 -1
  43. package/dashboard/dist/assets/{SkillsPage-DZ8k3xK4.js → SkillsPage-CSmQ6G7z.js} +1 -1
  44. package/dashboard/dist/assets/{TokenGalaxy-B5q_U7lQ.js → TokenGalaxy-D6D4Uvo5.js} +2 -2
  45. package/dashboard/dist/assets/{ToolbarRootContext-DIy-0I9L.js → ToolbarRootContext-Bs1pZOOn.js} +1 -1
  46. package/dashboard/dist/assets/{TrendMonitor-Bj558t-a.js → TrendMonitor-C7g8DZSW.js} +1 -1
  47. package/dashboard/dist/assets/{WidgetsPage-t9EgVnVK.js → WidgetsPage-Cz1mLk9N.js} +1 -1
  48. package/dashboard/dist/assets/{WrappedPage-B-8f6-oI.js → WrappedPage-COBjnmTi.js} +1 -1
  49. package/dashboard/dist/assets/{__vite-browser-external-DwFlHgQR.js → __vite-browser-external-CeOnjjgl.js} +27 -17
  50. package/dashboard/dist/assets/{__vite-browser-external-CvhQU1Bj.css → __vite-browser-external-WKilYZnq.css} +1 -1
  51. package/dashboard/dist/assets/{agent-logos-C6ooyoY_.js → agent-logos-B9ssYD4q.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-up-right-CWOhbEwU.js → arrow-up-right-DAWmHSlU.js} +1 -1
  53. package/dashboard/dist/assets/{check-DqrkOjUC.js → check-DPhmDgw5.js} +1 -1
  54. package/dashboard/dist/assets/{chevron-right-DlP0Hiu-.js → chevron-right-kOKJJLco.js} +1 -1
  55. package/dashboard/dist/assets/{copy-Dh_ZalIb.js → copy-gCs3DnsV.js} +1 -1
  56. package/dashboard/dist/assets/{download-fssIVO33.js → download-CH_YcQ58.js} +1 -1
  57. package/dashboard/dist/assets/{flame-COf56i7e.js → flame-BEU-cqaV.js} +1 -1
  58. package/dashboard/dist/assets/{format-tokens-DA4cG58O.js → format-tokens-DJ-Y0jv5.js} +1 -1
  59. package/dashboard/dist/assets/host-mode-Bc_iTzHk.js +1 -0
  60. package/dashboard/dist/assets/{icons-DigJtAfR.js → icons-mjdAc-2x.js} +1 -1
  61. package/dashboard/dist/assets/{index-b_fO7MtI.js → index-BX-ZVFrB.js} +1 -1
  62. package/dashboard/dist/assets/{index-DIEScXsc.js → index-DfAyiuyv.js} +1 -1
  63. package/dashboard/dist/assets/{index-BR90lSkB.js → index-DtrsMn3Y.js} +1 -1
  64. package/dashboard/dist/assets/{info-mwER43h2.js → info-CkFEh5Zu.js} +1 -1
  65. package/dashboard/dist/assets/{limits-providers-DyEdxJUi.js → limits-providers-DoHPHfeA.js} +1 -1
  66. package/dashboard/dist/assets/{link-2-Bfgtp0co.js → link-2-Cx8hG5wc.js} +1 -1
  67. package/dashboard/dist/assets/{loader-circle-B8ASQdW3.js → loader-circle-9rHTQTld.js} +1 -1
  68. package/dashboard/dist/assets/{maximize-2-88sF_sdM.js → maximize-2-6nkX0TnZ.js} +1 -1
  69. package/dashboard/dist/assets/{provider-display-CYWy8Wjh.js → provider-display-RBujrp08.js} +1 -1
  70. package/dashboard/dist/assets/{react-CcvGEOfz.js → react-CgmQzSd0.js} +1 -1
  71. package/dashboard/dist/assets/{search-C3gFd5W0.js → search-BpgAnH3M.js} +1 -1
  72. package/dashboard/dist/assets/{skills-api-BkjJfFxG.js → skills-api-CcfxTvl9.js} +1 -1
  73. package/dashboard/dist/assets/{store-CGE8neJG.js → store-BLjK5TrP.js} +1 -1
  74. package/dashboard/dist/assets/{terminal-CjoZ2jlv.js → terminal-Dbn_YmPM.js} +1 -1
  75. package/dashboard/dist/assets/{trash-2-Buwry_AW.js → trash-2-CUUFECPP.js} +1 -1
  76. package/dashboard/dist/assets/{use-community-stats-DZW_XQoS.js → use-community-stats-CQQhhlw9.js} +1 -1
  77. package/dashboard/dist/assets/{use-limits-display-prefs-BD0CXAYR.js → use-limits-display-prefs-CSxCVsC3.js} +1 -1
  78. package/dashboard/dist/assets/{use-native-settings-CPVIdR4z.js → use-native-settings-DlDAj7ZJ.js} +1 -1
  79. package/dashboard/dist/assets/{use-ordered-list-_yETN2gW.js → use-ordered-list-BTBKsubs.js} +1 -1
  80. package/dashboard/dist/assets/use-reduced-motion-DUgAHZln.js +1 -0
  81. package/dashboard/dist/assets/{use-session-efficiency-pref-mOQHAb6j.js → use-session-efficiency-pref-C_65WyiQ.js} +1 -1
  82. package/dashboard/dist/assets/{use-transform-CvmlOKm0.js → use-transform-OnxHQlyh.js} +1 -1
  83. package/dashboard/dist/assets/{use-usage-limits-9W0OuOdp.js → use-usage-limits-Mnco2YgQ.js} +1 -1
  84. package/dashboard/dist/assets/{useCurrency-BZVRsDTW.js → useCurrency-BS_xyhhg.js} +1 -1
  85. package/dashboard/dist/assets/{useScrollLock-Dw-jQnFB.js → useScrollLock-Ct5GyoVg.js} +1 -1
  86. package/dashboard/dist/assets/{useTokenFormat-BHw_Jkes.js → useTokenFormat-RvsCI_oE.js} +1 -1
  87. package/dashboard/dist/assets/{zap-CeYmAnn-.js → zap-D0DQz21w.js} +1 -1
  88. package/dashboard/dist/index.html +2 -2
  89. package/dashboard/dist/ip-check.html +2 -2
  90. package/dashboard/dist/leaderboard.html +2 -2
  91. package/dashboard/dist/share.html +2 -2
  92. package/package.json +1 -1
  93. package/src/commands/init.js +5 -6
  94. package/src/commands/status.js +10 -9
  95. package/src/lib/codex-config.js +45 -4
  96. package/src/lib/git-outcomes.js +67 -5
  97. package/src/lib/local-api.js +4 -1
  98. package/src/lib/pricing/curated-overrides.json +4 -0
  99. package/src/lib/pricing/seed-snapshot.json +1 -1
  100. package/src/lib/session-analytics.js +154 -32
  101. package/dashboard/dist/assets/DevicePage-CdtlanVo.js +0 -1
  102. package/dashboard/dist/assets/DialogDescription-CKjbQpir.js +0 -1
  103. package/dashboard/dist/assets/LandingPage-DrjAIdTL.js +0 -2
  104. package/dashboard/dist/assets/LimitsPage-B538PSle.js +0 -4
  105. package/dashboard/dist/assets/SessionsPage-CugLdscs.js +0 -1
  106. package/dashboard/dist/assets/use-reduced-motion-BOf4gJaX.js +0 -1
@@ -1,7 +1,26 @@
1
1
  "use strict";
2
2
 
3
- // Metadata-only session analytics sidecar. This scanner deliberately never
4
- // persists prompts, assistant text, tool arguments, command output, or diffs.
3
+ // Metadata-only session analytics sidecar.
4
+ //
5
+ // Never persisted, anywhere: prompts, assistant message bodies, tool arguments,
6
+ // command output, diffs. Prompts are read only to fingerprint a repeated
7
+ // request (a one-way hash, never stored).
8
+ //
9
+ // Persisted to the LOCAL sidecar only (~/.tokentracker/tracker, 0600 file in a
10
+ // 0700 dir), because the session browser needs them to identify and resume a
11
+ // session:
12
+ // - `title` — the one line the agent wrote to name the session (Claude's
13
+ // "ai-title" record, Codex's thread_name). Agent-authored, not the user's
14
+ // prompt, but it does summarize what the session was about, so treat it as
15
+ // session content: local-only, never uploaded.
16
+ // - `session_id` — the vendor session UUID, needed for `--resume`.
17
+ // - `project_ref` — the session's working directory. The resume command only
18
+ // works from that directory, so the UI shows it and lets the user copy it.
19
+ //
20
+ // All three are stripped in summarizeSessions() before anything reaches the
21
+ // cloud or a CSV export, and the browser endpoint that keeps them is served
22
+ // only over loopback. `test/session-analytics.test.js` guards that boundary —
23
+ // if you add a field here, decide which side of it the field belongs on.
5
24
 
6
25
  const crypto = require("node:crypto");
7
26
  const fs = require("node:fs");
@@ -22,7 +41,14 @@ const { computeRowCost } = require("./pricing");
22
41
  // the agent wrote itself, never the raw prompt body, and stay local-only
23
42
  // alongside session_id (no self-parsed prompt fallback, so the strict
24
43
  // metadata-only guarantee holds).
25
- const SIDECAR_VERSION = 8;
44
+ // v9 changes derived metrics, so every cached row must be rebuilt:
45
+ // - duration_ms is now *active* time (see IDLE_GAP_MS), not the wall-clock
46
+ // span between the first and last log line, which read as 89 days on
47
+ // resumed/forked sessions.
48
+ // - session_id is only set when the log actually contained a sessionId
49
+ // record. It used to fall back to the file's basename, which produced
50
+ // resume commands for non-session files (`claude --resume journal`).
51
+ const SIDECAR_VERSION = 9;
26
52
  const EDIT_TOOLS = new Set(["apply_patch", "edit", "write", "multiedit", "notebookedit"]);
27
53
  const PLACEHOLDER_MODELS = new Set(["<synthetic>", "synthetic", "<unknown>", "unknown"]);
28
54
  const CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX = "--claude-mem-observer-sessions";
@@ -74,11 +100,39 @@ function safeTimestamp(value) {
74
100
  return new Date(value).toISOString();
75
101
  }
76
102
 
103
+ // A session that was resumed days later, or whose file replays inherited
104
+ // history from a fork, spans a wall-clock range that says nothing about how
105
+ // long the user actually worked (observed: 2142h / 89 days on a session with
106
+ // zero turns). Accumulate *active* time instead: the sum of gaps between
107
+ // consecutive log timestamps, excluding any gap longer than IDLE_GAP_MS. The
108
+ // long jump from replayed history to real work is one such gap, so it drops
109
+ // out on its own. started_at / ended_at still carry the true span.
110
+ const IDLE_GAP_MS = 30 * 60 * 1000;
111
+
112
+ function emptyBounds() {
113
+ return { started_at: null, ended_at: null, active_ms: 0, _last_ts_ms: null };
114
+ }
115
+
77
116
  function updateBounds(bounds, value) {
78
117
  const timestamp = safeTimestamp(value);
79
118
  if (!timestamp) return;
80
119
  if (!bounds.started_at || timestamp < bounds.started_at) bounds.started_at = timestamp;
81
120
  if (!bounds.ended_at || timestamp > bounds.ended_at) bounds.ended_at = timestamp;
121
+ const ms = Date.parse(timestamp);
122
+ if (!Number.isFinite(ms)) return;
123
+ if (Number.isFinite(bounds._last_ts_ms)) {
124
+ const delta = ms - bounds._last_ts_ms;
125
+ // Ignore out-of-order lines and idle gaps; only real working time counts.
126
+ if (delta > 0 && delta <= IDLE_GAP_MS) bounds.active_ms += delta;
127
+ }
128
+ bounds._last_ts_ms = ms;
129
+ }
130
+
131
+ // Throwaway per-agent checkouts (`.claude/worktrees/<id>`, `.codex/worktrees/…`)
132
+ // are not the project the user is working on, and the directory is usually gone
133
+ // by the time the session is browsed.
134
+ function isWorktreeRef(ref) {
135
+ return /[\\/](worktrees|subagents)[\\/]/.test(String(ref || ""));
82
136
  }
83
137
 
84
138
  function projectKey(cwd, filePath) {
@@ -162,11 +216,9 @@ function extractCodexSignalTools(payload) {
162
216
  }
163
217
 
164
218
  function finalizeRecord(record) {
165
- const startedMs = Date.parse(record.started_at || "");
166
- const endedMs = Date.parse(record.ended_at || "");
167
- record.duration_ms = Number.isFinite(startedMs) && Number.isFinite(endedMs)
168
- ? Math.max(0, endedMs - startedMs)
169
- : 0;
219
+ delete record._last_ts_ms;
220
+ record.active_ms = Math.max(0, finite(record.active_ms));
221
+ record.duration_ms = record.active_ms;
170
222
  record.total_tokens = finite(record.total_tokens || record.tokens?.total_tokens);
171
223
  record.cost_usd = computeRowCost({ source: record.source, model: record.model, ...record.tokens });
172
224
  record.productive = record.edit_turns > 0;
@@ -184,9 +236,26 @@ async function scanClaudeSession(filePath) {
184
236
  const input = fs.createReadStream(filePath, { encoding: "utf8" });
185
237
  const lines = readline.createInterface({ input, crlfDelay: Infinity });
186
238
  const tokens = emptyTotals();
239
+ // Per-file, deliberately. rollout.js persists its dedup set across files to
240
+ // catch cross-file duplicates, but that is only sound there because it owns a
241
+ // single global total. Here each file becomes an independently cached row, so
242
+ // a cross-file set would make a row's tokens depend on which other files were
243
+ // scanned first — and the incremental cache reuses rows without replaying
244
+ // that state. Measured cost of the per-file scope on real data: 808 assistant
245
+ // messages appear in more than one file (fork/resume replays history into a
246
+ // new session file), inflating per-session tokens by ~1.5% in aggregate.
247
+ // Fixing it needs a deterministic owner (e.g. earliest started_at) plus
248
+ // message keys persisted per row — a sidecar redesign, not a one-liner. Do
249
+ // NOT "fix" this by widening the set without solving the cache interaction.
187
250
  const seenMessages = new Set();
188
- const bounds = { started_at: null, ended_at: null };
251
+ const bounds = emptyBounds();
252
+ // The basename keeps grouping stable for files that never write a sessionId
253
+ // record, but only an *observed* sessionId may become a resumable
254
+ // session_id — otherwise non-session logs under ~/.claude/projects (e.g.
255
+ // skill-injections.jsonl, journal.jsonl) yield `claude --resume journal`,
256
+ // a command that always fails.
189
257
  let rawSessionId = path.basename(filePath, ".jsonl");
258
+ let observedSessionId = null;
190
259
  let cwd = null;
191
260
  let model = "unknown";
192
261
  let aiTitle = null;
@@ -209,7 +278,10 @@ async function scanClaudeSession(filePath) {
209
278
  let obj;
210
279
  try { obj = JSON.parse(line); } catch { continue; }
211
280
  updateBounds(bounds, obj.timestamp || obj.message?.timestamp);
212
- if (typeof obj.sessionId === "string" && obj.sessionId) rawSessionId = obj.sessionId;
281
+ if (typeof obj.sessionId === "string" && obj.sessionId) {
282
+ rawSessionId = obj.sessionId;
283
+ observedSessionId = obj.sessionId;
284
+ }
213
285
  if (typeof obj.cwd === "string" && obj.cwd) cwd = obj.cwd;
214
286
  // Claude writes its own generated one-line summary as an "ai-title" record.
215
287
  // It is agent-authored metadata (not the raw prompt body); keep the latest.
@@ -257,7 +329,7 @@ async function scanClaudeSession(filePath) {
257
329
  return finalizeRecord({
258
330
  version: SIDECAR_VERSION,
259
331
  session_hash: sessionHash("claude", rawSessionId),
260
- session_id: rawSessionId || null,
332
+ session_id: observedSessionId || null,
261
333
  // Claude's own generated one-line title (the "ai-title" record). Agent-
262
334
  // authored metadata, never the raw prompt body. Local-only: stripped in
263
335
  // summarizeSessions before any cloud/CSV export. Null when Claude never
@@ -279,7 +351,7 @@ async function scanClaudeSession(filePath) {
279
351
  }
280
352
 
281
353
  async function scanCodexDeliverySignals(filePath) {
282
- const bounds = { started_at: null, ended_at: null };
354
+ const bounds = emptyBounds();
283
355
  const input = fs.createReadStream(filePath, { encoding: "utf8" });
284
356
  const lines = readline.createInterface({ input, crlfDelay: Infinity });
285
357
  let turns = 0;
@@ -541,16 +613,32 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
541
613
  ...discovered.claude.map((filePath) => ({ source: "claude", filePath, scan: scanClaudeSession })),
542
614
  ...discovered.codex.map((filePath) => ({ source: "codex", filePath, scan: scanCodexSession })),
543
615
  ];
616
+ // Files we could not turn into a row (permission denied, half-written line,
617
+ // vanished mid-scan). Swallowing these silently made sessions disappear with
618
+ // no signal at all; count them so the API can say so.
619
+ let skippedFiles = 0;
544
620
  for (const entry of entries) {
545
621
  const cacheKey = sessionFileCacheKey(entry.source, entry.filePath);
546
622
  const statKey = analyticsEntryStatKey(entry.source, entry.filePath);
547
- if (!statKey) continue;
623
+ if (!statKey) {
624
+ skippedFiles += 1;
625
+ continue;
626
+ }
548
627
  let row = null;
549
628
  if (!force && previousFiles[cacheKey]?.stat_key === statKey) {
550
629
  row = previousRowsByFile.get(cacheKey) || null;
551
630
  }
552
631
  if (!row) {
553
- try { row = await entry.scan(entry.filePath); } catch { /* one active/partial session must not poison the sidecar */ }
632
+ try {
633
+ row = await entry.scan(entry.filePath);
634
+ } catch (error) {
635
+ // One active/partial session must not poison the sidecar, but do not
636
+ // pretend it never existed either.
637
+ skippedFiles += 1;
638
+ if (!process.env.NODE_TEST_CONTEXT) {
639
+ console.warn(`[session-analytics] skipped ${entry.source} session file: ${error?.message || error}`);
640
+ }
641
+ }
554
642
  }
555
643
  if (!row) continue;
556
644
  // A one-way file hash enables incremental reuse without persisting or
@@ -570,6 +658,9 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
570
658
  checked_at: generatedAt,
571
659
  files: nextFiles,
572
660
  })}\n`);
661
+ // Non-enumerable so the array still behaves exactly like a plain row list
662
+ // for every existing caller (map/filter/JSON of the rows is unaffected).
663
+ Object.defineProperty(sessions, "skippedFiles", { value: skippedFiles, enumerable: false });
573
664
  return sessions;
574
665
  }
575
666
 
@@ -582,23 +673,41 @@ const sessionAnalyticsBuilds = new Map();
582
673
  function buildSessionAnalytics(options = {}) {
583
674
  const normalizedOptions = options && typeof options === "object" ? options : {};
584
675
  const home = path.resolve(String(normalizedOptions.home || os.homedir()));
676
+ const force = Boolean(normalizedOptions.force);
585
677
  const existing = sessionAnalyticsBuilds.get(home);
586
- if (existing) return existing;
587
-
588
- const promise = buildSessionAnalyticsInternal({ ...normalizedOptions, home });
589
- sessionAnalyticsBuilds.set(home, promise);
678
+ // Joining an in-flight build is only correct when that build is at least as
679
+ // thorough as what this caller asked for. A refresh that landed while a plain
680
+ // build was running used to be handed the un-refreshed result: the spinner
681
+ // stopped and nothing had actually been re-scanned. Chain instead of joining
682
+ // — never run two scans at once, they race the atomic sidecar write.
683
+ if (existing && (!force || existing.force)) return existing.promise;
684
+
685
+ const run = () => buildSessionAnalyticsInternal({ ...normalizedOptions, home, force });
686
+ const promise = existing ? existing.promise.then(run, run) : run();
687
+ const entry = { promise, force: force || Boolean(existing?.force) };
688
+ sessionAnalyticsBuilds.set(home, entry);
590
689
  const clear = () => {
591
- if (sessionAnalyticsBuilds.get(home) === promise) sessionAnalyticsBuilds.delete(home);
690
+ if (sessionAnalyticsBuilds.get(home) === entry) sessionAnalyticsBuilds.delete(home);
592
691
  };
593
692
  promise.then(clear, clear);
594
693
  return promise;
595
694
  }
596
695
 
696
+ // Does a session overlap the [from, to] day window? Testing started_at alone
697
+ // dropped every session that began before the window and ended inside it —
698
+ // exactly the long/resumed ones, which also sort to the top because the sort
699
+ // key is ended_at. On real data a 7-day window lost 6 sessions, the largest
700
+ // 224M tokens. Treat the window as an interval intersection instead.
701
+ function withinDayRange(row, from, to) {
702
+ const startDay = String(row?.started_at || row?.ended_at || "").slice(0, 10);
703
+ const endDay = String(row?.ended_at || row?.started_at || "").slice(0, 10);
704
+ if (from && (!endDay || endDay < from)) return false;
705
+ if (to && (!startDay || startDay > to)) return false;
706
+ return true;
707
+ }
708
+
597
709
  function summarizeSessions(sessions, { from = "", to = "", includeSessions = true } = {}) {
598
- const filtered = (sessions || []).filter((row) => {
599
- const day = String(row.started_at || row.ended_at || "").slice(0, 10);
600
- return (!from || day >= from) && (!to || day <= to);
601
- });
710
+ const filtered = (sessions || []).filter((row) => withinDayRange(row, from, to));
602
711
  const byModel = new Map();
603
712
  const subagents = new Map();
604
713
  for (const row of filtered) {
@@ -750,14 +859,22 @@ function mergeSessionFragments(rows) {
750
859
  cur.total_tokens = finite(cur.total_tokens) + finite(row.total_tokens);
751
860
  cur.cost_usd = finite(cur.cost_usd) + finite(row.cost_usd);
752
861
  cur.productive = Boolean(cur.productive) || Boolean(row.productive);
862
+ cur.active_ms = finite(cur.active_ms) + finite(row.active_ms);
753
863
  if (!cur.title && row.title) cur.title = row.title;
754
864
  // Representative model/project comes from the busiest fragment (most
755
865
  // tokens) so a tiny sidechain cannot overwrite the real coding model.
756
866
  if (finite(row.total_tokens) > finite(cur._repr_tokens)) {
757
867
  cur._repr_tokens = finite(row.total_tokens);
758
868
  if (row.model && row.model !== "unknown") cur.model = row.model;
759
- cur.project_key = row.project_key;
760
- cur.project_ref = row.project_ref;
869
+ // Project needs its own guard: a subagent running in a git worktree has
870
+ // that throwaway directory as its cwd, so the busiest fragment can label
871
+ // the whole session with a temp name like "agent-a3cb8089a11d3471a" and
872
+ // point project_ref at a directory that no longer exists. Only let a
873
+ // worktree fragment name the session when nothing better is available.
874
+ if (!isWorktreeRef(row.project_ref) || isWorktreeRef(cur.project_ref)) {
875
+ cur.project_key = row.project_key;
876
+ cur.project_ref = row.project_ref;
877
+ }
761
878
  }
762
879
  if (row.started_at && (!cur.started_at || row.started_at < cur.started_at)) cur.started_at = row.started_at;
763
880
  if (row.ended_at && (!cur.ended_at || row.ended_at > cur.ended_at)) cur.ended_at = row.ended_at;
@@ -765,9 +882,9 @@ function mergeSessionFragments(rows) {
765
882
  const merged = [];
766
883
  for (const row of byHash.values()) {
767
884
  delete row._repr_tokens;
768
- const startedMs = Date.parse(row.started_at || "");
769
- const endedMs = Date.parse(row.ended_at || "");
770
- row.duration_ms = Number.isFinite(startedMs) && Number.isFinite(endedMs) ? Math.max(0, endedMs - startedMs) : 0;
885
+ // Active time is additive across fragments (see IDLE_GAP_MS); the
886
+ // wall-clock span between first and last line is not.
887
+ row.duration_ms = finite(row.active_ms);
771
888
  row.first_pass = finite(row.edit_turns) === 1 && finite(row.retry_turns) === 0;
772
889
  row.one_shot = row.first_pass;
773
890
  merged.push(row);
@@ -780,10 +897,14 @@ function mergeSessionFragments(rows) {
780
897
  // the UI can offer one-click resume. Callers must only expose it over the
781
898
  // local API.
782
899
  function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0 } = {}) {
783
- const filtered = mergeSessionFragments(sessions).filter((row) => {
784
- const day = String(row.started_at || row.ended_at || "").slice(0, 10);
785
- return (!from || day >= from) && (!to || day <= to);
786
- });
900
+ const filtered = mergeSessionFragments(sessions)
901
+ // Only sessions that actually spent tokens are worth listing. This drops
902
+ // two kinds of noise: non-session logs under ~/.claude/projects
903
+ // (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
904
+ // the model replied. Both render as "unknown · 0 tokens · $0.00" rows with
905
+ // no model, no cost and nothing to analyze.
906
+ .filter((row) => finite(row.total_tokens) > 0)
907
+ .filter((row) => withinDayRange(row, from, to));
787
908
  filtered.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
788
909
  const cap = Number(limit) > 0 ? Number(limit) : 0;
789
910
  const limited = cap > 0 ? filtered.slice(0, cap) : filtered;
@@ -791,6 +912,7 @@ function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0 } = {}
791
912
  available: filtered.length > 0,
792
913
  session_count: filtered.length,
793
914
  returned_count: limited.length,
915
+ skipped_files: finite(sessions?.skippedFiles),
794
916
  sessions: limited.map(toSessionBrowserRow),
795
917
  provenance: {
796
918
  source: "local-session-log",
@@ -1 +0,0 @@
1
- import{a as b,r as l,j as r,c as e,h as v}from"./__vite-browser-external-DwFlHgQR.js";const x={BASE_URL:"/",DEV:!1,MODE:"production",PROD:!0,SSR:!1,VITE_APP_VERSION:"0.83.11",VITE_INSFORGE_ANON_KEY:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3OC0xMjM0LTU2NzgtOTBhYi1jZGVmMTIzNDU2NzgiLCJlbWFpbCI6ImFub25AaW5zZm9yZ2UuY29tIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODExNDU5NDd9.T0auta_IrVIh0uXW1bob5QSnzvsnJmN28r5XkSGEuQY",VITE_INSFORGE_BASE_URL:"https://srctyff5.us-east.insforge.app"};function f(){return v(),`${(x&&"https://srctyff5.us-east.insforge.app"||"https://srctyff5.us-east.insforge.app").replace(/\/$/,"")}/functions/tokentracker-device-flow-grant`}function p(s){return(s||"").toUpperCase().replace(/\s+/g,"").replace(/[^A-Z0-9-]/g,"")}function h(){const s=b(),[m,g]=l.useState(""),[i,a]=l.useState({kind:"idle",message:""});l.useEffect(()=>{try{const d=new URLSearchParams(window.location.search).get("user_code");d&&g(p(d))}catch{}},[]);async function u(c){if(c?.preventDefault?.(),!s?.signedIn){a({kind:"error",message:e("device.approval.error.sign_in")});return}const d=p(m);if(!/^[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}$/.test(d)){a({kind:"error",message:e("device.approval.error.invalid_code")});return}a({kind:"working",message:e("device.approval.action.approving")});try{const n=await s.getAccessToken?.();if(!n){a({kind:"error",message:e("device.approval.error.no_token")});return}const o=await fetch(f(),{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${n}`},body:JSON.stringify({user_code:d})}),t=await o.json().catch(()=>({}));o.ok?a({kind:"success",message:t.status==="already_approved"?t.client_info?e("device.approval.success.already_client",{client:t.client_info}):e("device.approval.success.already"):t.client_info?e("device.approval.success.approved_client",{client:t.client_info}):e("device.approval.success.approved")}):o.status===404?a({kind:"error",message:e("device.approval.error.not_found")}):o.status===410?a({kind:"error",message:e("device.approval.error.expired")}):o.status===401?a({kind:"error",message:e("device.approval.error.session_expired")}):a({kind:"error",message:t?.error?e("device.approval.error.server_detail",{message:t.error}):e("device.approval.error.server",{status:o.status})})}catch(n){a({kind:"error",message:e("device.approval.error.network",{message:n?.message||n})})}}return r.jsx("div",{className:"min-h-screen flex items-center justify-center bg-white dark:bg-oai-gray-950 px-6 py-12",children:r.jsxs("div",{className:"w-full max-w-md rounded-xl border border-oai-gray-200 dark:border-oai-gray-800 bg-white dark:bg-oai-gray-900 p-8",children:[r.jsx("h1",{className:"text-2xl font-semibold text-oai-gray-900 dark:text-white mb-2",children:e("device.approval.title")}),r.jsx("p",{className:"text-sm text-oai-gray-500 dark:text-oai-gray-400 mb-6",children:e("device.approval.description")}),s?.signedIn?null:r.jsx("div",{className:"rounded-md border border-amber-200 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-800 p-4 mb-4",children:r.jsx("p",{className:"text-sm text-amber-700 dark:text-amber-300",children:e("device.approval.sign_in_banner")})}),r.jsxs("form",{onSubmit:u,className:"space-y-4",children:[r.jsxs("label",{className:"block",children:[r.jsx("span",{className:"block text-xs font-medium uppercase tracking-wide text-oai-gray-500 dark:text-oai-gray-400 mb-1",children:e("device.approval.code_label")}),r.jsx("input",{type:"text",value:m,onChange:c=>g(p(c.target.value)),placeholder:"XXXX-XXXX",spellCheck:!1,autoCapitalize:"characters",maxLength:9,className:"w-full font-mono text-xl tracking-widest text-center rounded-md border border-oai-gray-300 dark:border-oai-gray-700 bg-white dark:bg-oai-gray-900 text-oai-gray-900 dark:text-white px-4 py-3 focus:outline-none focus:ring-inset focus:ring-2 focus:ring-emerald-600"})]}),r.jsx("button",{type:"submit",disabled:i.kind==="working"||!s?.signedIn,className:"w-full rounded-md bg-emerald-600 hover:bg-emerald-500 disabled:bg-oai-gray-300 disabled:cursor-not-allowed text-white font-medium py-3 transition-colors",children:i.kind==="working"?e("device.approval.action.approving"):e("device.approval.action.approve")})]}),i.kind==="success"&&r.jsx("div",{className:"mt-4 rounded-md border border-emerald-200 bg-emerald-50 dark:bg-emerald-900/20 dark:border-emerald-800 p-4",children:r.jsx("p",{className:"text-sm text-emerald-700 dark:text-emerald-300",children:i.message})}),i.kind==="error"&&r.jsx("div",{className:"mt-4 rounded-md border border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800 p-4",children:r.jsx("p",{className:"text-sm text-red-700 dark:text-red-300",children:i.message})})]})})}export{h as default};
@@ -1 +0,0 @@
1
- import{r as n,z as a}from"./__vite-browser-external-DwFlHgQR.js";import{u as p}from"./DialogTitle-CtptgR8Z.js";import{u as c}from"./useScrollLock-Dw-jQnFB.js";const x=n.forwardRef(function(e,r){const{render:l,className:u,style:m,id:s,...t}=e,{store:i}=p(),o=c(s);return i.useSyncedValueWithCleanup("descriptionElementId",o),a("p",e,{ref:r,props:[{id:o},t]})});export{x as D};