usage-cli 0.29.32__py3-none-any.whl

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 (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
pricing.py ADDED
@@ -0,0 +1,452 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import tempfile
15
+ import threading
16
+ import time
17
+ import urllib.request
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import Any, Literal, Protocol
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ LITELLM_PRICING_URL = (
25
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
26
+ )
27
+ DEFAULT_CACHE_PATH = Path(os.path.expanduser("~/.usage/pricing_cache.json"))
28
+ DEFAULT_LEGACY_CACHE_PATH = Path(os.path.expanduser("~/.claude/pricing_cache.json"))
29
+ CACHE_PATH = DEFAULT_CACHE_PATH
30
+ LEGACY_CACHE_PATH = DEFAULT_LEGACY_CACHE_PATH
31
+ CACHE_TTL_DAYS = 7
32
+ FALLBACK_RETRY_SECONDS = 600
33
+ MISSING_MODEL_REFRESH_SECONDS = FALLBACK_RETRY_SECONDS
34
+ USER_AGENT = "usage/0.9"
35
+ # The date when the hard-coded fallback table was last manually checked upstream.
36
+ FALLBACK_PRICING_AS_OF: str = "2026-08-14"
37
+ # Anthropic's official cache write/read prices relative to input pricing. These
38
+ # are only fallbacks for missing upstream fields and may be wrong for other providers.
39
+ CACHE_WRITE_COST_MULTIPLIER = 1.25
40
+ CACHE_READ_COST_MULTIPLIER = 0.1
41
+ # The upstream table is a few MB; cap the read so a broken or hostile mirror
42
+ # cannot make us buffer an unbounded response.
43
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
44
+ PROVIDER_PREFIXES = (
45
+ "openai/",
46
+ "anthropic/",
47
+ "bedrock/",
48
+ "azure/",
49
+ "vertex_ai/",
50
+ "vertex/",
51
+ "google/",
52
+ )
53
+ DATE_SUFFIX_RE = re.compile(r"-(?:\d{8}|\d{4}-\d{2}-\d{2})$")
54
+
55
+ PricingTable = dict[str, dict[str, float]]
56
+ PricingSource = Literal["cache", "stale", "fetched", "fallback"]
57
+
58
+ _pricing_cache: tuple[PricingTable, PricingSource, float] | None = None
59
+ _pricing_cache_lock = threading.Lock()
60
+ _pricing_warm_up_in_progress = False
61
+ _pricing_miss_refresh_at: float | None = None
62
+ _pricing_miss_refresh_lock = threading.Lock()
63
+ _model_key_cache_lock = threading.Lock()
64
+ _model_key_cache: dict[int, tuple[PricingTable, dict[str, str | None]]] = {}
65
+ _fallback_warning_lock = threading.Lock()
66
+ _fallback_warning_emitted = False
67
+
68
+
69
+ class _CostEntry(Protocol):
70
+ model: str
71
+ input_tokens: int
72
+ output_tokens: int
73
+ cache_creation_tokens: int
74
+ cache_read_tokens: int
75
+ cost_usd: float | None
76
+
77
+
78
+ def calculate_cost(entry: _CostEntry) -> float:
79
+ if entry.cost_usd is not None:
80
+ return entry.cost_usd
81
+
82
+ pricing = get_pricing()
83
+ model_key = _resolve_model_key_cached(entry.model, pricing)
84
+ if model_key is None:
85
+ _request_pricing_refresh_for_missing_model()
86
+ return 0.0
87
+
88
+ model_pricing = pricing[model_key]
89
+ input_cost = model_pricing.get("input_cost_per_token", 0.0)
90
+ output_cost = model_pricing.get("output_cost_per_token", 0.0)
91
+ cache_creation_cost = model_pricing.get(
92
+ "cache_creation_input_token_cost",
93
+ input_cost * CACHE_WRITE_COST_MULTIPLIER,
94
+ )
95
+ cache_read_cost = model_pricing.get(
96
+ "cache_read_input_token_cost",
97
+ input_cost * CACHE_READ_COST_MULTIPLIER,
98
+ )
99
+
100
+ cost = (
101
+ entry.input_tokens * input_cost
102
+ + entry.output_tokens * output_cost
103
+ + entry.cache_creation_tokens * cache_creation_cost
104
+ + entry.cache_read_tokens * cache_read_cost
105
+ )
106
+ return cost
107
+
108
+
109
+ def is_model_priced(model: str) -> bool:
110
+ """Check if a model has pricing information available."""
111
+ pricing = get_pricing()
112
+ model_key = _resolve_model_key_cached(model, pricing)
113
+ if model_key is None:
114
+ _request_pricing_refresh_for_missing_model()
115
+ return False
116
+ return True
117
+
118
+
119
+ def _resolve_model_key_cached(model: str, pricing: PricingTable) -> str | None:
120
+ with _model_key_cache_lock:
121
+ per_table_cache = _model_key_cache.get(id(pricing))
122
+ if per_table_cache is None or per_table_cache[0] is not pricing:
123
+ model_cache: dict[str, str | None] = {}
124
+ _model_key_cache[id(pricing)] = (pricing, model_cache)
125
+ else:
126
+ model_cache = per_table_cache[1]
127
+
128
+ if model in model_cache:
129
+ return model_cache[model]
130
+
131
+ resolved = _resolve_model_key(model, pricing)
132
+ model_cache[model] = resolved
133
+ return resolved
134
+
135
+
136
+ def get_pricing() -> PricingTable:
137
+ global _pricing_cache
138
+ now = time.monotonic()
139
+ with _pricing_cache_lock:
140
+ cached_entry = _pricing_cache
141
+ if cached_entry is not None:
142
+ pricing, source, cached_at = cached_entry
143
+ if _memory_cache_is_fresh(source, cached_at, now):
144
+ return pricing
145
+ warm_up_pricing()
146
+ return pricing
147
+
148
+ pricing, source = _load_pricing_with_source()
149
+ with _pricing_cache_lock:
150
+ _pricing_cache = (pricing, source, now)
151
+ if source in {"stale", "fallback"}:
152
+ warm_up_pricing()
153
+ return pricing
154
+
155
+
156
+ def _memory_cache_is_fresh(source: PricingSource, cached_at: float, now: float) -> bool:
157
+ if source == "fallback":
158
+ return (now - cached_at) <= FALLBACK_RETRY_SECONDS
159
+ if source == "stale":
160
+ return False
161
+ return (now - cached_at) <= CACHE_TTL_DAYS * 86400
162
+
163
+
164
+ def warm_up_pricing(
165
+ on_ready: Callable[[], None] | None = None,
166
+ *,
167
+ force: bool = False,
168
+ ) -> None:
169
+ global _pricing_warm_up_in_progress
170
+ with _pricing_cache_lock:
171
+ if _pricing_warm_up_in_progress:
172
+ return
173
+ _pricing_warm_up_in_progress = True
174
+
175
+ thread = threading.Thread(
176
+ target=_warm_up_pricing_worker,
177
+ args=(on_ready, force),
178
+ daemon=True,
179
+ )
180
+ thread.start()
181
+
182
+
183
+ def _warm_up_pricing_worker(
184
+ on_ready: Callable[[], None] | None,
185
+ force: bool,
186
+ ) -> None:
187
+ global _pricing_cache, _pricing_warm_up_in_progress
188
+ try:
189
+ with _pricing_cache_lock:
190
+ baseline = _pricing_cache
191
+ if baseline is None:
192
+ baseline_pricing, baseline_source = _load_pricing_with_source()
193
+ baseline = (baseline_pricing, baseline_source, time.monotonic())
194
+
195
+ _, source, cached_at = baseline
196
+ if (
197
+ not force
198
+ and source == "cache"
199
+ and _memory_cache_is_fresh(source, cached_at, time.monotonic())
200
+ ):
201
+ return
202
+
203
+ fetched = _fetch_pricing()
204
+ if not fetched:
205
+ return
206
+
207
+ _write_cache(fetched)
208
+ now = time.monotonic()
209
+ with _pricing_cache_lock:
210
+ previous = _pricing_cache or baseline
211
+ pricing, source, _ = previous
212
+ should_notify = source in {"stale", "fallback"} or pricing != fetched
213
+ _pricing_cache = (fetched, "fetched", now)
214
+
215
+ if should_notify and on_ready is not None:
216
+ on_ready()
217
+ except Exception:
218
+ if os.environ.get("USAGE_DEBUG") == "1":
219
+ logger.warning("failed to warm up pricing", exc_info=True)
220
+ finally:
221
+ with _pricing_cache_lock:
222
+ _pricing_warm_up_in_progress = False
223
+
224
+
225
+ def _set_pricing_cache_for_test(
226
+ value: tuple[PricingTable, PricingSource, float] | None,
227
+ ) -> None:
228
+ global _pricing_cache
229
+ with _pricing_cache_lock:
230
+ _pricing_cache = value
231
+
232
+
233
+ def _get_pricing_cache_for_test() -> tuple[PricingTable, PricingSource, float] | None:
234
+ with _pricing_cache_lock:
235
+ return _pricing_cache
236
+
237
+
238
+ def _reset_pricing_warm_up_for_test() -> None:
239
+ global _fallback_warning_emitted, _pricing_miss_refresh_at, _pricing_warm_up_in_progress
240
+ with _pricing_cache_lock:
241
+ _pricing_warm_up_in_progress = False
242
+ with _pricing_miss_refresh_lock:
243
+ _pricing_miss_refresh_at = None
244
+ with _fallback_warning_lock:
245
+ _fallback_warning_emitted = False
246
+
247
+
248
+ def _load_pricing() -> PricingTable:
249
+ pricing, _ = _load_pricing_with_source()
250
+ return pricing
251
+
252
+
253
+ def _load_pricing_with_source() -> tuple[PricingTable, PricingSource]:
254
+ cached = _read_cache()
255
+ if cached:
256
+ return cached, "cache"
257
+
258
+ stale_cached = _read_cache(allow_stale=True)
259
+ if stale_cached:
260
+ return stale_cached, "stale"
261
+
262
+ _warn_fallback_pricing_once()
263
+ return _fallback_pricing(), "fallback"
264
+
265
+
266
+ def _warn_fallback_pricing_once() -> None:
267
+ global _fallback_warning_emitted
268
+ with _fallback_warning_lock:
269
+ if _fallback_warning_emitted:
270
+ return
271
+ _fallback_warning_emitted = True
272
+ logger.warning(
273
+ "using offline fallback pricing table last verified %s",
274
+ FALLBACK_PRICING_AS_OF,
275
+ )
276
+
277
+
278
+ def _read_cache(*, allow_stale: bool = False) -> PricingTable | None:
279
+ use_legacy = CACHE_PATH == DEFAULT_CACHE_PATH or LEGACY_CACHE_PATH != DEFAULT_LEGACY_CACHE_PATH
280
+ path = CACHE_PATH if CACHE_PATH.exists() or not use_legacy else LEGACY_CACHE_PATH
281
+ cache_mtime: float | None = None
282
+ with contextlib.suppress(OSError):
283
+ cache_mtime = path.stat().st_mtime
284
+ if cache_mtime is None:
285
+ return None
286
+ if not allow_stale and (time.time() - cache_mtime) > CACHE_TTL_DAYS * 86400:
287
+ return None
288
+
289
+ with contextlib.suppress(OSError), path.open(encoding="utf-8") as file:
290
+ try:
291
+ return _normalize_pricing(json.load(file))
292
+ except (UnicodeDecodeError, json.JSONDecodeError):
293
+ if os.environ.get("USAGE_DEBUG") == "1":
294
+ logger.warning("failed to decode pricing cache %s", path, exc_info=True)
295
+ return None
296
+ return None
297
+
298
+
299
+ def _fetch_pricing() -> PricingTable | None:
300
+ request = urllib.request.Request(LITELLM_PRICING_URL, headers={"User-Agent": USER_AGENT})
301
+ try:
302
+ with urllib.request.urlopen(request, timeout=10) as response:
303
+ raw = response.read(MAX_RESPONSE_BYTES + 1)
304
+ if len(raw) > MAX_RESPONSE_BYTES:
305
+ raise ValueError("pricing response exceeds the size limit")
306
+ payload = json.loads(raw.decode("utf-8"))
307
+ except (OSError, UnicodeDecodeError, ValueError, TimeoutError):
308
+ if os.environ.get("USAGE_DEBUG") == "1":
309
+ logger.warning("failed to fetch pricing from %s", LITELLM_PRICING_URL, exc_info=True)
310
+ return None
311
+ return _normalize_pricing(payload)
312
+
313
+
314
+ def _write_cache(pricing: PricingTable) -> None:
315
+ tmp_path: str | None = None
316
+ try:
317
+ with contextlib.suppress(OSError):
318
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
319
+ fd, tmp_path = tempfile.mkstemp(dir=CACHE_PATH.parent, suffix=".tmp")
320
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
321
+ json.dump(pricing, file, ensure_ascii=False, indent=2, sort_keys=True)
322
+ os.replace(tmp_path, CACHE_PATH)
323
+ tmp_path = None
324
+ except OSError as exc:
325
+ logger.warning("failed to write pricing cache: %s", exc)
326
+ return
327
+ finally:
328
+ if tmp_path and os.path.exists(tmp_path):
329
+ with contextlib.suppress(OSError):
330
+ os.unlink(tmp_path)
331
+
332
+
333
+ def _normalize_pricing(payload: Any) -> PricingTable | None:
334
+ if not isinstance(payload, dict):
335
+ return None
336
+
337
+ pricing: PricingTable = {}
338
+ for model, raw_info in payload.items():
339
+ if not isinstance(model, str) or not isinstance(raw_info, dict):
340
+ continue
341
+
342
+ info: dict[str, float] = {}
343
+ for key in (
344
+ "input_cost_per_token",
345
+ "output_cost_per_token",
346
+ "cache_creation_input_token_cost",
347
+ "cache_read_input_token_cost",
348
+ ):
349
+ value = raw_info.get(key)
350
+ if isinstance(value, int | float):
351
+ info[key] = float(value)
352
+
353
+ if info:
354
+ pricing[model] = info
355
+
356
+ return pricing or None
357
+
358
+
359
+ def _resolve_model_key(model: str, pricing: PricingTable) -> str | None:
360
+ if model in pricing:
361
+ return model
362
+
363
+ normalized = _normalize_model_name(model)
364
+ if normalized in pricing:
365
+ return normalized
366
+
367
+ prefix_matches = [
368
+ key
369
+ for key in pricing
370
+ if key.startswith(normalized)
371
+ and (len(key) == len(normalized) or key[len(normalized)] == "-")
372
+ ]
373
+ if prefix_matches:
374
+ return sorted(prefix_matches, key=lambda key: (len(key), key))[0]
375
+
376
+ logger.debug("pricing: no match for model=%s", model)
377
+ return None
378
+
379
+
380
+ def _normalize_model_name(model: str) -> str:
381
+ normalized = model.strip().lower()
382
+ for prefix in PROVIDER_PREFIXES:
383
+ if normalized.startswith(prefix):
384
+ normalized = normalized[len(prefix) :]
385
+ break
386
+ return DATE_SUFFIX_RE.sub("", normalized)
387
+
388
+
389
+ def _fallback_pricing() -> PricingTable:
390
+ return {
391
+ "claude-opus-4-6": {
392
+ "input_cost_per_token": 5e-6,
393
+ "output_cost_per_token": 25e-6,
394
+ "cache_creation_input_token_cost": 6.25e-6,
395
+ "cache_read_input_token_cost": 0.5e-6,
396
+ },
397
+ "claude-opus-4-7": {
398
+ "input_cost_per_token": 5e-6,
399
+ "output_cost_per_token": 25e-6,
400
+ "cache_creation_input_token_cost": 6.25e-6,
401
+ "cache_read_input_token_cost": 0.5e-6,
402
+ },
403
+ "claude-opus-4-8": {
404
+ "input_cost_per_token": 5e-6,
405
+ "output_cost_per_token": 25e-6,
406
+ "cache_creation_input_token_cost": 6.25e-6,
407
+ "cache_read_input_token_cost": 0.5e-6,
408
+ },
409
+ "claude-opus-5": {
410
+ "input_cost_per_token": 5e-6,
411
+ "output_cost_per_token": 25e-6,
412
+ "cache_creation_input_token_cost": 6.25e-6,
413
+ "cache_read_input_token_cost": 0.5e-6,
414
+ },
415
+ "claude-sonnet-4-6": {
416
+ "input_cost_per_token": 3e-6,
417
+ "output_cost_per_token": 15e-6,
418
+ "cache_creation_input_token_cost": 3.75e-6,
419
+ "cache_read_input_token_cost": 0.3e-6,
420
+ },
421
+ "claude-sonnet-5": {
422
+ "input_cost_per_token": 2e-6,
423
+ "output_cost_per_token": 10e-6,
424
+ "cache_creation_input_token_cost": 2.5e-6,
425
+ "cache_read_input_token_cost": 0.2e-6,
426
+ },
427
+ "claude-fable-5": {
428
+ "input_cost_per_token": 10e-6,
429
+ "output_cost_per_token": 50e-6,
430
+ "cache_creation_input_token_cost": 12.5e-6,
431
+ "cache_read_input_token_cost": 1e-6,
432
+ },
433
+ "claude-haiku-4-5-20251001": {
434
+ "input_cost_per_token": 1e-6,
435
+ "output_cost_per_token": 5e-6,
436
+ "cache_creation_input_token_cost": 1.25e-6,
437
+ "cache_read_input_token_cost": 0.1e-6,
438
+ },
439
+ }
440
+
441
+
442
+ def _request_pricing_refresh_for_missing_model() -> None:
443
+ global _pricing_miss_refresh_at
444
+ now = time.monotonic()
445
+ with _pricing_miss_refresh_lock:
446
+ if (
447
+ _pricing_miss_refresh_at is not None
448
+ and (now - _pricing_miss_refresh_at) < MISSING_MODEL_REFRESH_SECONDS
449
+ ):
450
+ return
451
+ _pricing_miss_refresh_at = now
452
+ warm_up_pricing(force=True)
project_resolver.py ADDED
@@ -0,0 +1,112 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ __all__ = ["project_from_encoded_path", "resolve_project_name"]
15
+
16
+
17
+ @lru_cache(maxsize=256)
18
+ def resolve_project_name(cwd: str | Path) -> str:
19
+ """Resolve a cwd to its canonical project name, including git worktrees."""
20
+ if not str(cwd):
21
+ return "unknown"
22
+ path = Path(os.path.expanduser(str(cwd))).resolve(strict=False)
23
+ return _resolve_project_name(str(path))
24
+
25
+
26
+ @lru_cache(maxsize=256)
27
+ def _resolve_project_name(normalized_cwd: str) -> str:
28
+ fallback = Path(normalized_cwd).name or "unknown"
29
+ try:
30
+ result = subprocess.run(
31
+ ["git", "-C", normalized_cwd, "worktree", "list", "--porcelain"],
32
+ capture_output=True,
33
+ check=False,
34
+ text=True,
35
+ # Force UTF-8 instead of the locale default: a .app launched via
36
+ # LaunchServices has no LANG set, so text=True would decode git's
37
+ # output as ASCII and crash on non-ASCII (e.g. Chinese) repo paths.
38
+ encoding="utf-8",
39
+ errors="replace",
40
+ timeout=3,
41
+ )
42
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
43
+ return fallback
44
+
45
+ if result.returncode != 0 or result.stderr or not result.stdout:
46
+ return fallback
47
+
48
+ lines = result.stdout.splitlines()
49
+ first_line = lines[0] if lines else ""
50
+ prefix = "worktree "
51
+ if not first_line.startswith(prefix):
52
+ return fallback
53
+
54
+ main_path = first_line.removeprefix(prefix).strip()
55
+ if not main_path:
56
+ return fallback
57
+ return Path(main_path).name or fallback
58
+
59
+
60
+ def project_from_encoded_path(jsonl_path: Path, projects_dir: Path) -> str:
61
+ """Decode a Claude Code project name from a sessions JSONL path under projects_dir."""
62
+ try:
63
+ project_dir = jsonl_path.relative_to(projects_dir).parts[0]
64
+ except (IndexError, ValueError):
65
+ return "unknown"
66
+
67
+ parts = [part for part in project_dir.split("-") if part]
68
+ if not parts:
69
+ return "unknown"
70
+
71
+ root, start = _encoded_path_root(parts)
72
+ slash_candidate = root.joinpath(*parts[start:])
73
+ if slash_candidate.is_dir():
74
+ return slash_candidate.name or "unknown"
75
+
76
+ existing_project = _existing_encoded_project_path(parts)
77
+ if existing_project is not None:
78
+ return existing_project.name or "unknown"
79
+
80
+ fallback = project_dir.removeprefix("-")
81
+ return fallback or "unknown"
82
+
83
+
84
+ def _existing_encoded_project_path(parts: list[str]) -> Path | None:
85
+ def search(index: int, current: Path) -> Path | None:
86
+ for end in range(index + 1, len(parts) + 1):
87
+ candidate = current / "-".join(parts[index:end])
88
+ if not candidate.is_dir():
89
+ continue
90
+ if end == len(parts):
91
+ return candidate
92
+ result = search(end, candidate)
93
+ if result is not None:
94
+ return result
95
+ return None
96
+
97
+ root, start = _encoded_path_root(parts)
98
+ return search(start, root)
99
+
100
+
101
+ def _encoded_path_root(parts: list[str]) -> tuple[Path, int]:
102
+ """Return the filesystem root and first encoded component to search."""
103
+ if os.sep == "\\":
104
+ drive = parts[0]
105
+ # Claude Code encodes every non-alphanumeric character as "-", so
106
+ # "C:\Users\me" arrives as "C--Users-me" and the drive survives only
107
+ # as a bare letter; accept "C:" too for robustness.
108
+ if len(drive) == 1 and drive.isalpha():
109
+ return Path(f"{drive}:{os.sep}"), 1
110
+ if len(drive) == 2 and drive[0].isalpha() and drive[1] == ":":
111
+ return Path(drive + os.sep), 1
112
+ return Path(os.sep), 0