tokenol 0.3.0__tar.gz → 0.3.2__tar.gz

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 (90) hide show
  1. {tokenol-0.3.0 → tokenol-0.3.2}/CHANGELOG.md +38 -0
  2. {tokenol-0.3.0 → tokenol-0.3.2}/PKG-INFO +9 -3
  3. {tokenol-0.3.0 → tokenol-0.3.2}/README.md +8 -2
  4. {tokenol-0.3.0 → tokenol-0.3.2}/pyproject.toml +1 -1
  5. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/__init__.py +1 -1
  6. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/app.py +33 -16
  7. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/app.js +58 -6
  8. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/streaming.py +11 -0
  9. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_serve_app.py +88 -0
  10. {tokenol-0.3.0 → tokenol-0.3.2}/uv.lock +1 -1
  11. {tokenol-0.3.0 → tokenol-0.3.2}/.claude/settings.local.json +0 -0
  12. {tokenol-0.3.0 → tokenol-0.3.2}/.claudesignore +0 -0
  13. {tokenol-0.3.0 → tokenol-0.3.2}/.github/workflows/ci.yml +0 -0
  14. {tokenol-0.3.0 → tokenol-0.3.2}/.gitignore +0 -0
  15. {tokenol-0.3.0 → tokenol-0.3.2}/docs/ASSUMPTIONS.md +0 -0
  16. {tokenol-0.3.0 → tokenol-0.3.2}/docs/METRICS.md +0 -0
  17. {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/breakdown_bottom.jpg +0 -0
  18. {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/breakdown_top.jpg +0 -0
  19. {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/main.jpg +0 -0
  20. {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/project.jpg +0 -0
  21. {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/session_bottom.jpg +0 -0
  22. {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/session_top.jpg +0 -0
  23. {tokenol-0.3.0 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr1.md +0 -0
  24. {tokenol-0.3.0 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr2.md +0 -0
  25. {tokenol-0.3.0 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr3.md +0 -0
  26. {tokenol-0.3.0 → tokenol-0.3.2}/docs/superpowers/specs/2026-04-24-breakdown-tab-design.md +0 -0
  27. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/assumptions.py +0 -0
  28. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/cli.py +0 -0
  29. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/enums.py +0 -0
  30. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/__init__.py +0 -0
  31. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/builder.py +0 -0
  32. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/discovery.py +0 -0
  33. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/parser.py +0 -0
  34. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/schema.py +0 -0
  35. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/__init__.py +0 -0
  36. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/context.py +0 -0
  37. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/cost.py +0 -0
  38. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/history.py +0 -0
  39. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/patterns.py +0 -0
  40. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/rollups.py +0 -0
  41. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/thresholds.py +0 -0
  42. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/verdicts.py +0 -0
  43. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/windows.py +0 -0
  44. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/model/__init__.py +0 -0
  45. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/model/events.py +0 -0
  46. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/model/pricing.py +0 -0
  47. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/model/registry.py +0 -0
  48. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/report/__init__.py +0 -0
  49. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/report/text.py +0 -0
  50. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/__init__.py +0 -0
  51. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/prefs.py +0 -0
  52. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/session_detail.py +0 -0
  53. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/state.py +0 -0
  54. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/breakdown.html +0 -0
  55. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/breakdown.js +0 -0
  56. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/chart.js +0 -0
  57. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/components.js +0 -0
  58. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/day.html +0 -0
  59. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/day.js +0 -0
  60. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/index.html +0 -0
  61. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/model.html +0 -0
  62. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/model.js +0 -0
  63. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/project.html +0 -0
  64. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/project.js +0 -0
  65. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/session.html +0 -0
  66. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/session.js +0 -0
  67. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/styles.css +0 -0
  68. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/tool.html +0 -0
  69. {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/tool.js +0 -0
  70. {tokenol-0.3.0 → tokenol-0.3.2}/tests/conftest.py +0 -0
  71. {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/basic.jsonl +0 -0
  72. {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/dedup.jsonl +0 -0
  73. {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/gemini.jsonl +0 -0
  74. {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/interrupted.jsonl +0 -0
  75. {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/missing_ids.jsonl +0 -0
  76. {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/multi.jsonl +0 -0
  77. {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/sidechain.jsonl +0 -0
  78. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_cli.py +0 -0
  79. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_context_metrics.py +0 -0
  80. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_dedup.py +0 -0
  81. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_history_and_thresholds.py +0 -0
  82. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_metrics.py +0 -0
  83. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_parser.py +0 -0
  84. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_patterns.py +0 -0
  85. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_serve_prefs.py +0 -0
  86. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_serve_state.py +0 -0
  87. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_serve_streaming.py +0 -0
  88. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_session_detail.py +0 -0
  89. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_verdicts.py +0 -0
  90. {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_windows.py +0 -0
@@ -4,6 +4,44 @@ All notable changes to tokenol are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.2] — 2026-04-28
8
+
9
+ ### Fixed
10
+ - **Dashboard fallback endpoints no longer freeze on stale turn counts.**
11
+ The 0.3.1 `/api/snapshot` fast-path stopped refreshing
12
+ `app.state.snapshot_result`, so every endpoint that fell back to it
13
+ (`/api/hourly`, `/api/daily`, `/api/models`, `/api/recent`, `/api/session/*`,
14
+ `/api/project/*`, `/api/breakdown/*`, `/api/search`, `/api/model/*`,
15
+ `/api/tool/*`) silently froze at first-page-load turn counts. Symptom: the
16
+ HIT% panel showed live turns but switching to $/KW, CTX, Cache Reuse, Output,
17
+ or Cost rendered stale numbers. Fix: `SnapshotBroadcaster` now exposes its
18
+ freshest `SnapshotResult`, and fallback endpoints prefer it over the
19
+ app-level cache.
20
+
21
+ ## [0.3.1] — 2026-04-27
22
+
23
+ ### Fixed
24
+ - **Dashboard auto-update self-heals from SSE drift.** The live dashboard
25
+ could intermittently freeze on stale tile/chart values while the SSE
26
+ connection appeared healthy (dot green, messages arriving), requiring a
27
+ hard reload. Reproduced reliably in long-lived browser tabs; root cause
28
+ is browser-environmental (extension hooks, accumulated tab state, or
29
+ long-lived `EventSource` quirks). Fix is a layered, root-cause-agnostic
30
+ resilience set in the static client:
31
+ - `/api/snapshot` polling backstop every 30 s while the tab is visible —
32
+ state self-heals within 30 s even if SSE delivery silently breaks.
33
+ - Force-reconnect on `visibilitychange → visible` when the last message
34
+ is older than 15 s (browsers throttle background-tab timers/SSE).
35
+ - 90 s SSE staleness watchdog (silent stalls don't always fire `onerror`).
36
+ - Live "last update Ns ago" tooltip on the SSE dot for at-a-glance
37
+ freshness.
38
+
39
+ ### Changed
40
+ - `/api/snapshot` reuses `SnapshotBroadcaster.cached_payload(period)` when
41
+ an SSE group is live for the requested period, avoiding a redundant full
42
+ rebuild. Cuts the snapshot fetch from ~175 ms to ~100 ms (mostly JSON
43
+ serialization), making the new poll backstop essentially free.
44
+
7
45
  ## [0.3.0] — 2026-04-27
8
46
 
9
47
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tokenol
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Audit Claude Code JSONL session logs: track cost, cache health, context blow-ups, and 5h-window pressure.
5
5
  Project-URL: Homepage, https://github.com/farhanferoz/tokenol
6
6
  Project-URL: Issues, https://github.com/farhanferoz/tokenol/issues
@@ -148,7 +148,11 @@ tokenol serve --all-projects --tick 2s --reference 25
148
148
  tokenol serve --open
149
149
  ```
150
150
 
151
- The dashboard updates via SSE as Claude Code writes events to disk. Main page layout (top to bottom):
151
+ The dashboard updates via SSE as Claude Code writes events to disk. The server gates rebuilds on JSONL file changes — when no files have changed, it idles at near-zero CPU and forces a refresh at most once a minute (so time-windowed panels like Recent Activity don't drift more than ~60 s from wall clock). Multiple browser tabs share a single producer, so opening more tabs does not multiply server cost.
152
+
153
+ If SSE delivery silently stalls (browser tab throttling, extension hooks, long-lived `EventSource` quirks), the client self-heals: it polls `/api/snapshot` every 30 s as a backstop, force-reconnects on tab-visibility return, and runs a 90 s staleness watchdog. `/api/snapshot` reuses the broadcaster's cached payload while an SSE group is live, so the backstop costs only a JSON serialize. Hover the live-status dot for a "last update Ns ago" indicator.
154
+
155
+ Main page layout (top to bottom):
152
156
 
153
157
  | Panel | What it shows |
154
158
  |---|---|
@@ -173,7 +177,7 @@ Keyboard shortcuts: `?` Glossary · `/` Find · `,` Settings · `Esc` close/back
173
177
 
174
178
  ### Preferences
175
179
 
176
- User preferences (tick cadence and threshold overrides) are saved to:
180
+ User preferences (gate-poll cadence and threshold overrides) are saved to:
177
181
 
178
182
  ```
179
183
  $XDG_CONFIG_HOME/tokenol/prefs.json # default: ~/.config/tokenol/prefs.json
@@ -197,6 +201,8 @@ Shape:
197
201
  }
198
202
  ```
199
203
 
204
+ `tick_seconds` is how often the server stat-checks the JSONL files for changes (cheap). The full snapshot only rebuilds on a detected change or once per ~60 s heartbeat — so a long `tick_seconds` mainly reduces stat-syscall noise, not rebuild cost.
205
+
200
206
  Reset to defaults via the Settings modal (`POST /api/prefs {"thresholds": "reset"}`).
201
207
 
202
208
  ### Session drill-down
@@ -115,7 +115,11 @@ tokenol serve --all-projects --tick 2s --reference 25
115
115
  tokenol serve --open
116
116
  ```
117
117
 
118
- The dashboard updates via SSE as Claude Code writes events to disk. Main page layout (top to bottom):
118
+ The dashboard updates via SSE as Claude Code writes events to disk. The server gates rebuilds on JSONL file changes — when no files have changed, it idles at near-zero CPU and forces a refresh at most once a minute (so time-windowed panels like Recent Activity don't drift more than ~60 s from wall clock). Multiple browser tabs share a single producer, so opening more tabs does not multiply server cost.
119
+
120
+ If SSE delivery silently stalls (browser tab throttling, extension hooks, long-lived `EventSource` quirks), the client self-heals: it polls `/api/snapshot` every 30 s as a backstop, force-reconnects on tab-visibility return, and runs a 90 s staleness watchdog. `/api/snapshot` reuses the broadcaster's cached payload while an SSE group is live, so the backstop costs only a JSON serialize. Hover the live-status dot for a "last update Ns ago" indicator.
121
+
122
+ Main page layout (top to bottom):
119
123
 
120
124
  | Panel | What it shows |
121
125
  |---|---|
@@ -140,7 +144,7 @@ Keyboard shortcuts: `?` Glossary · `/` Find · `,` Settings · `Esc` close/back
140
144
 
141
145
  ### Preferences
142
146
 
143
- User preferences (tick cadence and threshold overrides) are saved to:
147
+ User preferences (gate-poll cadence and threshold overrides) are saved to:
144
148
 
145
149
  ```
146
150
  $XDG_CONFIG_HOME/tokenol/prefs.json # default: ~/.config/tokenol/prefs.json
@@ -164,6 +168,8 @@ Shape:
164
168
  }
165
169
  ```
166
170
 
171
+ `tick_seconds` is how often the server stat-checks the JSONL files for changes (cheap). The full snapshot only rebuilds on a detected change or once per ~60 s heartbeat — so a long `tick_seconds` mainly reduces stat-syscall noise, not rebuild cost.
172
+
167
173
  Reset to defaults via the Settings modal (`POST /api/prefs {"thresholds": "reset"}`).
168
174
 
169
175
  ### Session drill-down
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tokenol"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Audit Claude Code JSONL session logs: track cost, cache health, context blow-ups, and 5h-window pressure."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,3 +1,3 @@
1
1
  """tokenol — Claude Code usage & efficiency audit tool."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -113,6 +113,16 @@ def _build_and_cache_snapshot(request: Request, period: str = "today") -> Snapsh
113
113
  return result
114
114
 
115
115
 
116
+ def _current_snapshot_result(request: Request) -> SnapshotResult:
117
+ """Prefer the broadcaster's latest build; fall back to the app cache or a fresh build."""
118
+ broadcaster = getattr(request.app.state, "broadcaster", None)
119
+ if broadcaster is not None:
120
+ latest = broadcaster.latest_result()
121
+ if latest is not None:
122
+ return latest
123
+ return request.app.state.snapshot_result or _build_and_cache_snapshot(request)
124
+
125
+
116
126
  def create_app(
117
127
  config: ServerConfig | None = None,
118
128
  prefs_path: Path | None = None,
@@ -182,11 +192,18 @@ def create_app(
182
192
 
183
193
  @app.get("/api/snapshot")
184
194
  async def api_snapshot(request: Request, period: str = "today"):
195
+ # Reuse the broadcaster's cached payload when a SSE group is live for
196
+ # this period — avoids a second full rebuild for polling backstops.
197
+ broadcaster = getattr(request.app.state, "broadcaster", None)
198
+ if broadcaster is not None:
199
+ cached = broadcaster.cached_payload(period)
200
+ if cached is not None:
201
+ return JSONResponse(cached)
185
202
  return JSONResponse(_build_and_cache_snapshot(request, period=period).payload)
186
203
 
187
204
  @app.get("/api/session/{session_id}")
188
205
  async def api_session_detail(session_id: str, request: Request):
189
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
206
+ result = _current_snapshot_result(request)
190
207
  session = next((s for s in result.sessions if s.session_id == session_id), None)
191
208
  if session is None:
192
209
  raise HTTPException(status_code=404, detail="Session not found")
@@ -194,7 +211,7 @@ def create_app(
194
211
 
195
212
  @app.get("/api/session/{session_id}/turn/{turn_idx}")
196
213
  async def api_turn_detail(session_id: str, turn_idx: int, request: Request):
197
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
214
+ result = _current_snapshot_result(request)
198
215
  session = next((s for s in result.sessions if s.session_id == session_id), None)
199
216
  if session is None:
200
217
  raise HTTPException(status_code=404, detail="Session not found")
@@ -211,7 +228,7 @@ def create_app(
211
228
  raise HTTPException(status_code=400, detail="Invalid cwd encoding") from None
212
229
  if range not in ("1d", "7d", "14d", "30d", "all"):
213
230
  raise HTTPException(status_code=400, detail="Invalid range — use 1d, 7d, 14d, 30d, or all")
214
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
231
+ result = _current_snapshot_result(request)
215
232
  detail = await asyncio.get_running_loop().run_in_executor(
216
233
  None,
217
234
  lambda: build_project_detail(cwd, result.sessions, range_key=range),
@@ -228,7 +245,7 @@ def create_app(
228
245
  raise HTTPException(status_code=400, detail="Invalid date format — use YYYY-MM-DD") from None
229
246
  if parsed_date > date.today():
230
247
  raise HTTPException(status_code=400, detail="Future dates not supported")
231
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
248
+ result = _current_snapshot_result(request)
232
249
  detail = await asyncio.get_running_loop().run_in_executor(
233
250
  None,
234
251
  lambda: build_day_detail(parsed_date, result.turns, result.sessions),
@@ -265,7 +282,7 @@ def create_app(
265
282
  parsed = date.fromisoformat(target_date)
266
283
  except ValueError:
267
284
  raise HTTPException(status_code=400, detail="Invalid date — use YYYY-MM-DD") from None
268
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
285
+ result = _current_snapshot_result(request)
269
286
  return JSONResponse(build_hourly_panel(parsed, result.turns, result.sessions, metric, project, model))
270
287
 
271
288
  @app.get("/api/daily")
@@ -282,7 +299,7 @@ def create_app(
282
299
  raise HTTPException(status_code=400, detail=f"Unknown metric — valid: {sorted(VALID_METRICS)}")
283
300
  if range not in ("7d", "30d", "90d", "all"):
284
301
  raise HTTPException(status_code=400, detail="range must be 7d, 30d, 90d, or all")
285
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
302
+ result = _current_snapshot_result(request)
286
303
  # Fall back silently to the longest available window when the requested range
287
304
  # exceeds the data we have — return 200 with a `note` so the UI can caption it.
288
305
  # Returning 400 here forced clients to special-case "policy" failures even though
@@ -311,7 +328,7 @@ def create_app(
311
328
 
312
329
  @app.get("/api/models")
313
330
  async def api_models(request: Request, range: str = "today"):
314
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
331
+ result = _current_snapshot_result(request)
315
332
  since = range_since(range, date.today())
316
333
  turns = [t for t in result.turns if t.timestamp.date() >= since] if since else result.turns
317
334
  return JSONResponse(build_models_panel(turns, range))
@@ -320,7 +337,7 @@ def create_app(
320
337
  async def api_recent(request: Request, window: str = "60m"):
321
338
  if window not in _WINDOW_MINUTES:
322
339
  raise HTTPException(status_code=400, detail=f"window must be one of: {list(_WINDOW_MINUTES)}")
323
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
340
+ result = _current_snapshot_result(request)
324
341
  now = datetime.now(tz=timezone.utc)
325
342
  return JSONResponse(build_recent_activity_panel(
326
343
  result.turns, result.sessions, now, _WINDOW_MINUTES[window]
@@ -328,7 +345,7 @@ def create_app(
328
345
 
329
346
  @app.get("/api/model/{name}")
330
347
  async def api_model_detail(name: str, request: Request):
331
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
348
+ result = _current_snapshot_result(request)
332
349
  detail = build_model_detail(name, result.turns, result.sessions)
333
350
  if detail is None:
334
351
  raise HTTPException(status_code=404, detail="Model not found")
@@ -336,7 +353,7 @@ def create_app(
336
353
 
337
354
  @app.get("/api/tool/{name}")
338
355
  async def api_tool_detail(name: str, request: Request):
339
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
356
+ result = _current_snapshot_result(request)
340
357
  detail = build_tool_detail(name, result.turns, result.sessions)
341
358
  if detail is None:
342
359
  raise HTTPException(status_code=404, detail="Tool not found")
@@ -346,7 +363,7 @@ def create_app(
346
363
  async def api_search(request: Request, q: str = ""):
347
364
  if not q.strip():
348
365
  return JSONResponse({"hits": [], "query": q})
349
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
366
+ result = _current_snapshot_result(request)
350
367
  return JSONResponse(build_search_results(q, result.turns, result.sessions))
351
368
 
352
369
  @app.get("/api/prefs")
@@ -395,7 +412,7 @@ def create_app(
395
412
  @app.get("/api/breakdown/summary")
396
413
  async def api_breakdown_summary(request: Request, range: str = "30d"):
397
414
  _validate_breakdown_range(range)
398
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
415
+ result = _current_snapshot_result(request)
399
416
  since = range_since(range, date.today()) if range != "all" else None
400
417
  if since is None:
401
418
  turns = list(result.turns)
@@ -422,7 +439,7 @@ def create_app(
422
439
  @app.get("/api/breakdown/daily-tokens")
423
440
  async def api_breakdown_daily_tokens(request: Request, range: str = "30d"):
424
441
  _validate_breakdown_range(range)
425
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
442
+ result = _current_snapshot_result(request)
426
443
  since = range_since(range, date.today()) if range != "all" else None
427
444
  if since is None:
428
445
  turns = list(result.turns)
@@ -449,7 +466,7 @@ def create_app(
449
466
  @app.get("/api/breakdown/by-project")
450
467
  async def api_breakdown_by_project(request: Request, range: str = "30d"):
451
468
  _validate_breakdown_range(range)
452
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
469
+ result = _current_snapshot_result(request)
453
470
  since = range_since(range, date.today()) if range != "all" else None
454
471
 
455
472
  cwd_by_sid = _grouped_cwd_by_sid(result.sessions)
@@ -477,7 +494,7 @@ def create_app(
477
494
  @app.get("/api/breakdown/by-model")
478
495
  async def api_breakdown_by_model(request: Request, range: str = "30d"):
479
496
  _validate_breakdown_range(range)
480
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
497
+ result = _current_snapshot_result(request)
481
498
  since = range_since(range, date.today()) if range != "all" else None
482
499
 
483
500
  buckets = _bucket_turns(
@@ -501,7 +518,7 @@ def create_app(
501
518
  @app.get("/api/breakdown/tools")
502
519
  async def api_breakdown_tools(request: Request, range: str = "30d"):
503
520
  _validate_breakdown_range(range)
504
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
521
+ result = _current_snapshot_result(request)
505
522
  since = range_since(range, date.today()) if range != "all" else None
506
523
 
507
524
  total: Counter[str] = Counter()
@@ -50,18 +50,44 @@ function _setPeriod(p) { localStorage.setItem(_LS_PERIOD, p); }
50
50
 
51
51
  // ---- SSE dot ----
52
52
  const _dot = $('sse-dot');
53
+ let _dotBaseTitle = '';
53
54
  function _dotState(cls, title) {
54
55
  _dot.className = 'sse-dot' + (cls ? ' ' + cls : '');
56
+ _dotBaseTitle = title;
55
57
  _dot.title = title;
56
58
  }
59
+ // Live tooltip: "Live — connected · last update 5s ago" — updated every second
60
+ // so you can hover-and-tell whether the page is genuinely stale.
61
+ setInterval(() => {
62
+ if (!_dot) return;
63
+ if (_lastMsgAt) {
64
+ const ageS = Math.round((Date.now() - _lastMsgAt) / 1000);
65
+ _dot.title = `${_dotBaseTitle} · last update ${ageS}s ago`;
66
+ } else {
67
+ _dot.title = _dotBaseTitle;
68
+ }
69
+ }, 1_000);
57
70
 
58
71
  // ---- SSE connection ----
59
72
  let _es = null;
60
73
  let _reconnectDelay = 1_000;
61
74
  let _fiveXXSince = null;
75
+ let _lastMsgAt = 0;
76
+ // Server heartbeat is ≤60s; >90s without a message means the connection
77
+ // silently stalled (system sleep, NAT drop, transparent proxy).
78
+ const _STALE_MS = 90_000;
79
+
80
+ function _scheduleReconnect(reason) {
81
+ if (_es) { _es.close(); _es = null; }
82
+ _lastMsgAt = 0;
83
+ _dotState('amber', `SSE ${reason} — reconnecting…`);
84
+ setTimeout(() => _connect(_getPeriod()), _reconnectDelay);
85
+ _reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
86
+ }
62
87
 
63
88
  function _connect(period) {
64
89
  if (_es) { _es.close(); _es = null; }
90
+ _lastMsgAt = 0;
65
91
  _dotState('', 'SSE connecting…');
66
92
  _es = new EventSource(`/api/stream?period=${period}`);
67
93
 
@@ -69,22 +95,48 @@ function _connect(period) {
69
95
  _dotState('connected', 'Live — connected');
70
96
  _reconnectDelay = 1_000;
71
97
  _fiveXXSince = null;
98
+ _lastMsgAt = Date.now();
72
99
  _resetIdleTimer();
73
100
  };
74
101
 
75
102
  _es.onmessage = ev => {
103
+ _lastMsgAt = Date.now();
76
104
  try { _applyPayload(JSON.parse(ev.data)); }
77
105
  catch (e) { console.error('SSE parse', e); }
78
106
  };
79
107
 
80
- _es.onerror = () => {
81
- _dotState('amber', 'SSE disconnected — reconnecting…');
82
- _es.close(); _es = null;
83
- setTimeout(() => _connect(_getPeriod()), _reconnectDelay);
84
- _reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
85
- };
108
+ _es.onerror = () => _scheduleReconnect('disconnected');
86
109
  }
87
110
 
111
+ // Watchdog: EventSource doesn't reliably fire onerror on silent stalls.
112
+ setInterval(() => {
113
+ if (!_es || !_lastMsgAt) return;
114
+ if (Date.now() - _lastMsgAt > _STALE_MS) _scheduleReconnect('stalled');
115
+ }, 10_000);
116
+
117
+ // Browsers throttle background tabs — timers slow, SSE may pause. On tab
118
+ // return, if our last message is older than one tick + slack, force a fresh
119
+ // reconnect so the user never sees stale data.
120
+ document.addEventListener('visibilitychange', () => {
121
+ if (document.visibilityState !== 'visible') return;
122
+ if (!_lastMsgAt || Date.now() - _lastMsgAt > 15_000) {
123
+ _reconnectDelay = 1_000;
124
+ _scheduleReconnect('tab visible');
125
+ }
126
+ });
127
+
128
+ // Backstop: re-fetch the authoritative snapshot periodically. SSE is the
129
+ // fast path; this catches any case where SSE flows but the merged client
130
+ // state drifts (browser extension hooks, long-lived tab quirks, etc.).
131
+ // Skipped while hidden — visibilitychange handler covers the wakeup case.
132
+ setInterval(() => {
133
+ if (document.visibilityState !== 'visible') return;
134
+ _fetchSnapshot(_getPeriod());
135
+ }, 30_000);
136
+
137
+ // Debug: window.__tokenolForceReconnect() simulates a silent stall.
138
+ window.__tokenolForceReconnect = () => _scheduleReconnect('forced (debug)');
139
+
88
140
  // ---- initial fetch ----
89
141
  async function _fetchSnapshot(period) {
90
142
  try {
@@ -177,6 +177,8 @@ class SnapshotBroadcaster:
177
177
  self._heartbeat_s = heartbeat_s
178
178
  self._groups: dict[str, _Group] = {}
179
179
  self._lock = asyncio.Lock()
180
+ # turns/sessions are period-agnostic, so any producer's freshest result serves all readers.
181
+ self._latest_result: SnapshotResult | None = None
180
182
 
181
183
  def _compute_active_keys(self) -> frozenset[tuple[str, int, int]]:
182
184
  return compute_active_keys(self._all_projects)
@@ -190,8 +192,12 @@ class SnapshotBroadcaster:
190
192
  period=period,
191
193
  thresholds=self._get_thresholds(),
192
194
  )
195
+ self._latest_result = result
193
196
  return result.payload
194
197
 
198
+ def latest_result(self) -> SnapshotResult | None:
199
+ return self._latest_result
200
+
195
201
  async def subscribe(self, period: str) -> AsyncGenerator[str, None]:
196
202
  sub = _Subscriber()
197
203
  async with self._lock:
@@ -243,3 +249,8 @@ class SnapshotBroadcaster:
243
249
  def group_count(self) -> int:
244
250
  """Number of active producer groups (testing aid)."""
245
251
  return len(self._groups)
252
+
253
+ def cached_payload(self, period: str) -> dict | None:
254
+ """Latest broadcast payload for *period*, or None if no group is live."""
255
+ grp = self._groups.get(period)
256
+ return grp.last_payload if grp is not None else None
@@ -1052,3 +1052,91 @@ async def test_api_tool_detail_404_on_unknown(tmp_path: Path) -> None:
1052
1052
  async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
1053
1053
  resp = await client.get("/api/tool/NoSuchTool")
1054
1054
  assert resp.status_code == 404
1055
+
1056
+
1057
+ @pytest.mark.asyncio
1058
+ async def test_api_hourly_reflects_broadcaster_freshness(tmp_path: Path) -> None:
1059
+ """Regression: /api/hourly must track the broadcaster, not stale app.state.snapshot_result."""
1060
+ import asyncio
1061
+ from datetime import datetime, timezone
1062
+
1063
+ from httpx import ASGITransport, AsyncClient
1064
+
1065
+ today = datetime.now(tz=timezone.utc).date()
1066
+ target_date = today.isoformat()
1067
+
1068
+ def _turn_line(uuid: str, hour: int, minute: int) -> bytes:
1069
+ ts = f"{target_date}T{hour:02d}:{minute:02d}:00Z"
1070
+ return (
1071
+ b'{"type":"assistant","timestamp":"' + ts.encode() + b'",'
1072
+ b'"sessionId":"sess-001","requestId":"req-' + uuid.encode() + b'",'
1073
+ b'"uuid":"' + uuid.encode() + b'","isSidechain":false,'
1074
+ b'"model":"claude-opus-4-7",'
1075
+ b'"message":{"id":"msg-' + uuid.encode() + b'","role":"assistant",'
1076
+ b'"stop_reason":"end_turn",'
1077
+ b'"usage":{"input_tokens":10,"output_tokens":20,'
1078
+ b'"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}\n'
1079
+ )
1080
+
1081
+ sess_path = tmp_path / "projects" / "sess-001.jsonl"
1082
+ sess_path.parent.mkdir(parents=True)
1083
+ sess_path.write_bytes(_turn_line("evt-001", 10, 0) + _turn_line("evt-002", 10, 5))
1084
+
1085
+ with _mock_dirs(tmp_path):
1086
+ app = create_app(ServerConfig())
1087
+ bc = app.state.broadcaster
1088
+ # Force the producer to rebuild on every mtime change with no heartbeat delay.
1089
+ bc._get_tick_seconds = lambda: 0
1090
+ bc._heartbeat_s = 0.0
1091
+
1092
+ agen = bc.subscribe("today").__aiter__()
1093
+ try:
1094
+ await asyncio.wait_for(agen.__anext__(), timeout=5.0)
1095
+
1096
+ async with AsyncClient(
1097
+ transport=ASGITransport(app=app), base_url="http://test"
1098
+ ) as client:
1099
+ resp = await client.get(
1100
+ f"/api/hourly/{target_date}?metric=cost_per_kw"
1101
+ )
1102
+ turns_before = sum(
1103
+ p["turns"] for s in resp.json()["series"] for p in s["points"]
1104
+ )
1105
+ assert turns_before == 2, (
1106
+ f"fixture has 2 assistant turns, /api/hourly saw {turns_before}"
1107
+ )
1108
+
1109
+ sess_path.write_bytes(sess_path.read_bytes() + _turn_line("evt-003", 10, 30))
1110
+
1111
+ # Poll /api/snapshot as the sync barrier — it exercises the same fast-path
1112
+ # the fix targets, without reaching into broadcaster internals.
1113
+ async with AsyncClient(
1114
+ transport=ASGITransport(app=app), base_url="http://test"
1115
+ ) as client:
1116
+ snapshot_turns = 0
1117
+ for _ in range(200):
1118
+ resp = await client.get("/api/snapshot?period=today")
1119
+ snapshot_turns = sum(
1120
+ s["turns"]
1121
+ for s in resp.json()["hourly_today"]["series"]
1122
+ )
1123
+ if snapshot_turns >= 3:
1124
+ break
1125
+ await asyncio.sleep(0.02)
1126
+ assert snapshot_turns == 3, (
1127
+ "broadcaster did not pick up appended turn within 4 s "
1128
+ f"(snapshot still reports {snapshot_turns} turns)"
1129
+ )
1130
+
1131
+ resp = await client.get(
1132
+ f"/api/hourly/{target_date}?metric=cost_per_kw"
1133
+ )
1134
+ turns_after = sum(
1135
+ p["turns"] for s in resp.json()["series"] for p in s["points"]
1136
+ )
1137
+ assert turns_after == 3, (
1138
+ "/api/hourly returned stale turns "
1139
+ f"({turns_after}); expected 3 after appending one turn"
1140
+ )
1141
+ finally:
1142
+ await agen.aclose()
@@ -741,7 +741,7 @@ wheels = [
741
741
 
742
742
  [[package]]
743
743
  name = "tokenol"
744
- version = "0.2.0"
744
+ version = "0.3.1"
745
745
  source = { editable = "." }
746
746
  dependencies = [
747
747
  { name = "duckdb" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes