@caius_kong/ccusage-dashboard 0.2.5 → 0.2.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.
package/README.md CHANGED
@@ -5,9 +5,15 @@
5
5
  **A tiny local dashboard built specifically for [ccusage](https://github.com/ccusage/ccusage).**
6
6
 
7
7
  > ⚠️ **This is not a re-implementation.** Every number you see comes straight from
8
- > `ccusage ... --json`. No own pricing tables, no re-parsing of session logs — the
9
- > dashboard is a thin view over ccusage's own accurate cost engine. If `ccusage`
8
+ > `ccusage ... --json`. No own pricing tables, no re-parsing of session logs for costs
9
+ > the dashboard is a thin view over ccusage's own accurate cost engine. If `ccusage`
10
10
  > says it, this dashboard shows it.
11
+ >
12
+ > The only exception is the **workdir label** in the Sessions tab: ccusage's session
13
+ > report doesn't expose a real directory name for every agent, so the dashboard reads
14
+ > each agent's own local session file (pi/openclaw/claude/codex stores) to display the
15
+ > actual working directory. This is purely a display label — every cost/token number
16
+ > still comes 100% from ccusage.
11
17
 
12
18
  </div>
13
19
 
@@ -53,9 +59,10 @@ CCUSAGE_UI_NO_OPEN=1 npx @caius_kong/ccusage-dashboard # don't auto-open brows
53
59
  | **Today / This Week / This Month / Custom Range** | totals + tokens + cache breakdown |
54
60
  | **By model** | per-model cost, % of total, in/out/cache-read/cache-write tokens |
55
61
  | **30-day trend** | daily cost bar chart (hover for values, weekends marked) |
62
+ | **Sessions** | per-session rows grouped by workdir name + short session id, filterable 7d/30d/90d/all, sorted by cost |
56
63
  | **Budget alert** | monthly cap (default $300) — green <80%, yellow <100%, red ≥100% |
57
64
 
58
- All costs in USD. Auto-refresh: today every 15s, others every 60s.
65
+ All costs in USD. Auto-refresh every 15s.
59
66
 
60
67
  ## How it works
61
68
 
package/lib/index.html CHANGED
@@ -188,8 +188,8 @@ function renderSessions(data){
188
188
  if(!list.length){$("sessionsList").innerHTML='<div class="empty">no sessions in range</div>';return;}
189
189
  $("sessionsList").innerHTML=list.map(s=>{
190
190
  const label = s.hasCwd
191
- ? `<span class="name" title="workdir from ccusage: ${esc(s.cwd||s.projectKey)}">${esc(s.dirName||s.id.slice(0,8))} <span class="pill sessid">${esc(s.id.slice(0,8))}</span></span>`
192
- : `<span class="name muted" title="workdir not provided by ccusage for ${esc(s.agent)}">${esc(s.id.slice(0,8))}</span>`;
191
+ ? `<span class="name" title="workdir: ${esc(s.cwd||s.projectKey)}">${esc(s.dirName||s.id.slice(0,8))} <span class="pill sessid">${esc(s.id.slice(0,8))}</span></span>`
192
+ : `<span class="name muted" title="no workdir found for this ${esc(s.agent)} session">${esc(s.id.slice(0,8))}</span>`;
193
193
  const last=(s.lastActivity||"").slice(0,10);
194
194
  return `<div class="row">
195
195
  <div class="row-top"><span>${label} <span class="pill">${esc(s.agent)}</span></span><span class="cost">${fmtUsd(s.cost)}</span></div>
package/lib/server.py CHANGED
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  import argparse
20
20
  import json
21
+ import os
21
22
  import re
22
23
  import shutil
23
24
  import subprocess
@@ -194,8 +195,9 @@ def trend(days: int = 30) -> dict:
194
195
 
195
196
 
196
197
  def sessions(since_days: int = 30) -> dict:
197
- """Return ccusage's session report, with each session's cwd (projectPath) when
198
- ccusage provides it. Data comes 100% from ccusage we only re-shape it.
198
+ """Return ccusage's session report, each with its real workdir (dirName) when
199
+ the agent's local session store records one. Every numeric cost/token field
200
+ comes 100% from ccusage; the workdir is only a display label.
199
201
  """
200
202
  data = run_ccusage(
201
203
  ["session", "--by-agent", "--json", "--offline"],
@@ -218,33 +220,39 @@ def sessions(since_days: int = 30) -> dict:
218
220
  if act_date is not None and act_date < cutoff:
219
221
  continue
220
222
  project_raw = meta.get("projectPath") or ""
221
- dir_name = _basename(project_raw)
222
- cwd = _decode_cwd(project_raw)
223
- # For pi sessions the authoritative workdir lives on disk (session file first-line
224
- # "cwd"). Your dashboards want the REAL dir name (e.g. "fund-tracker", not the
225
- # lossy basename "tracker"). This only affects the DISPLAY label all numeric
226
- # cost/token data still comes 100% from ccusage.
227
- if project_raw and dir_name:
228
- disk_cwd = _pi_cwd_from_disk(project_raw)
223
+ # Resolve the authoritative workdir (dir name) for every agent that stores one
224
+ # locally. ccusage's projectPath only covers pi and is lossy for hyphenated names
225
+ # ("fund-tracker" -> "tracker"). Each agent keeps its own session store whose
226
+ # files carry the real cwd, so we read those. This ONLY affects the display label
227
+ # (dirName/cwd) every numeric cost/token field still comes 100% from ccusage.
228
+ agent_name = r.get("agent", "?")
229
+ cwd = ""
230
+ dir_name = ""
231
+ if sid:
232
+ disk_cwd = _cwd_for(agent_name, sid, project_raw)
229
233
  if disk_cwd:
230
234
  cwd = disk_cwd
231
- disk_base = disk_cwd.rstrip("/").split("/")[-1]
232
- if disk_base:
233
- dir_name = disk_base
235
+ cwd_base = disk_cwd.rstrip("/").split("/")[-1]
236
+ if cwd_base:
237
+ dir_name = cwd_base
238
+ if not cwd:
239
+ # fall back to a best-effort decode of ccusage's projectPath (pi only)
240
+ cwd = _decode_cwd(project_raw)
241
+ dir_name = _basename(project_raw)
234
242
  out.append(
235
243
  {
236
244
  "id": sid or "",
237
- "agent": r.get("agent", "?"),
245
+ "agent": agent_name,
238
246
  "cost": round(r.get("totalCost", 0) or 0, 4),
239
247
  "inputTokens": r.get("inputTokens", 0),
240
248
  "outputTokens": r.get("outputTokens", 0),
241
249
  "cacheReadTokens": r.get("cacheReadTokens", 0),
242
250
  "cacheCreationTokens": r.get("cacheCreationTokens", 0),
243
251
  "lastActivity": last_activity,
244
- "cwd": cwd, # real workdir when known (pi), else best-effort decode
245
- "dirName": dir_name, # real dir name when known (pi), else last path segment
252
+ "cwd": cwd, # real workdir when resolved, else best-effort decode
253
+ "dirName": dir_name, # real dir name when resolved, else last path segment
246
254
  "projectKey": project_raw, # raw ccusage projectPath
247
- "hasCwd": bool(project_raw),
255
+ "hasCwd": bool(cwd),
248
256
  }
249
257
  )
250
258
  out.sort(key=lambda s: s["cost"], reverse=True)
@@ -269,29 +277,141 @@ def _pi_cwd_from_disk(project_key: str):
269
277
  if not root.is_dir():
270
278
  return None
271
279
  candidates = sorted(glob.glob(str(root / "*.jsonl")))
272
- if not candidates:
273
- return None
274
280
  for path in candidates:
275
- try:
276
- with open(path, encoding="utf-8") as fh:
277
- for line in fh:
278
- line = line.strip()
279
- if not line:
280
- continue
281
- try:
282
- rec = json.loads(line)
283
- except Exception:
284
- continue
285
- cwd = rec.get("cwd")
286
- if isinstance(cwd, str) and cwd.strip():
287
- return cwd.strip().rstrip("/")
288
- except (OSError, ValueError):
289
- continue
281
+ cwd = _cwd_from_jsonl(path)
282
+ if cwd:
283
+ return cwd
290
284
  except Exception:
291
285
  return None
292
286
  return None
293
287
 
294
288
 
289
+ # --- Unified authoritative-workdir resolution for every agent that stores one ---
290
+ #
291
+ # ccusage's session report only exposes projectPath for pi (and it's lossy for
292
+ # hyphenated dir names). To show the REAL dir name for all sessions we resolve the
293
+ # workdir from each agent's own local session store. This drives only the DISPLAY
294
+ # label (dirName); every numeric cost/token value still comes 100% from ccusage.
295
+
296
+ _CWD_CACHE = {} # "agent\x1fsid" -> cwd | None
297
+ _AGENT_INDEX = {} # agent -> {session_id: [paths]} (built lazily)
298
+
299
+
300
+ def _cwd_from_jsonl(path):
301
+ """Scan a session .jsonl (first few lines) for the first real 'cwd' string."""
302
+ try:
303
+ with open(path, encoding="utf-8") as fh:
304
+ for _ in range(80):
305
+ line = fh.readline()
306
+ if not line:
307
+ break
308
+ line = line.strip()
309
+ if not line:
310
+ continue
311
+ try:
312
+ rec = json.loads(line)
313
+ except Exception:
314
+ continue
315
+ if isinstance(rec, dict):
316
+ cwd = rec.get("cwd")
317
+ if isinstance(cwd, str) and cwd.strip():
318
+ return cwd.strip().rstrip("/")
319
+ except (OSError, ValueError):
320
+ return None
321
+ return None
322
+
323
+
324
+ def _codex_cwd(path):
325
+ """codex stores cwd inside the session_meta payload, not top-level."""
326
+ try:
327
+ with open(path, encoding="utf-8") as fh:
328
+ for _ in range(200):
329
+ line = fh.readline()
330
+ if not line:
331
+ break
332
+ line = line.strip()
333
+ if not line:
334
+ continue
335
+ try:
336
+ rec = json.loads(line)
337
+ except Exception:
338
+ continue
339
+ if isinstance(rec, dict) and rec.get("type") == "session_meta":
340
+ cwd = (rec.get("payload") or {}).get("cwd")
341
+ if isinstance(cwd, str) and cwd.strip():
342
+ return cwd.strip().rstrip("/")
343
+ except (OSError, ValueError):
344
+ return None
345
+ return None
346
+
347
+
348
+ def _agent_index(agent):
349
+ """Build (once) a {session_id: [paths]} for an agent's local session store."""
350
+ if agent in _AGENT_INDEX:
351
+ return _AGENT_INDEX[agent]
352
+ home = Path.home()
353
+ idx = {}
354
+ if agent == "openclaw":
355
+ base = home / ".openclaw" / "agents" / "main" / "sessions"
356
+ for f in (base.glob("*.jsonl") if base.is_dir() else []):
357
+ idx.setdefault(f.name[:-6], []).append(str(f))
358
+ elif agent == "claude":
359
+ proj = home / ".claude" / "projects"
360
+ if proj.is_dir():
361
+ for sub in proj.iterdir():
362
+ if sub.is_dir():
363
+ for f in sub.glob("*.jsonl"):
364
+ idx.setdefault(f.name[:-6], []).append(str(f))
365
+ elif agent == "codex":
366
+ root = home / ".codex" / "sessions"
367
+ if root.is_dir():
368
+ for f in root.glob("*/*/*/rollout-*.jsonl"):
369
+ idx.setdefault(f.name[len("rollout-"):-len(".jsonl")], []).append(str(f))
370
+ for f in root.glob("*/*/rollout-*.jsonl"):
371
+ idx.setdefault(f.name[len("rollout-"):-len(".jsonl")], []).append(str(f))
372
+ _AGENT_INDEX[agent] = idx
373
+ return idx
374
+
375
+
376
+ def _session_to_cwd(agent, sid):
377
+ """Locate an agent's session file by id and return its real cwd (or None)."""
378
+ paths = _agent_index(agent).get(sid)
379
+ if not paths:
380
+ return None
381
+ for p in paths:
382
+ cwd = _codex_cwd(p) if agent == "codex" else _cwd_from_jsonl(p)
383
+ if cwd:
384
+ return cwd
385
+ return None
386
+
387
+
388
+ def _cwd_for(agent, sid, project_key=""):
389
+ """Real workdir for a session. pi resolves by project key; the rest by session id.
390
+ Returns the real cwd (or None if unresolvable). Only drives the DISPLAY label."""
391
+ if agent == "pi":
392
+ if not project_key:
393
+ return None
394
+ key = "pi\x1f" + project_key
395
+ if key in _CWD_CACHE:
396
+ return _CWD_CACHE[key]
397
+ cwd = _pi_cwd_from_disk(project_key)
398
+ _CWD_CACHE[key] = cwd
399
+ return cwd
400
+ if not sid:
401
+ return None
402
+ # codex session ids are date-path prefixed ("2025/10/17/rollout-<id>"); the
403
+ # local file is named "rollout-<id>.jsonl", so match on the final segment.
404
+ lookup_sid = sid.split("/")[-1]
405
+ if lookup_sid.startswith("rollout-") and agent == "codex":
406
+ lookup_sid = lookup_sid[len("rollout-"):]
407
+ key = agent + "\x1f" + lookup_sid
408
+ if key in _CWD_CACHE:
409
+ return _CWD_CACHE[key]
410
+ cwd = _session_to_cwd(agent, lookup_sid)
411
+ _CWD_CACHE[key] = cwd
412
+ return cwd
413
+
414
+
295
415
  def _decode_cwd(raw: str) -> str:
296
416
  """Best-effort decode of ccusage's projectPath into a readable path.
297
417
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caius_kong/ccusage-dashboard",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "One-command local dashboard for ccusage: today/week/month/custom cost by model, 30-day trend, monthly budget alert. Numbers straight from ccusage.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",