@caius_kong/ccusage-dashboard 0.2.4 → 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 +10 -3
- package/lib/index.html +2 -2
- package/lib/server.py +181 -9
- package/package.json +1 -1
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 —
|
|
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
|
|
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
|
|
192
|
-
: `<span class="name muted" title="workdir
|
|
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
|
|
198
|
-
|
|
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,28 +220,198 @@ 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
|
-
|
|
222
|
-
|
|
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)
|
|
233
|
+
if disk_cwd:
|
|
234
|
+
cwd = disk_cwd
|
|
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)
|
|
223
242
|
out.append(
|
|
224
243
|
{
|
|
225
244
|
"id": sid or "",
|
|
226
|
-
"agent":
|
|
245
|
+
"agent": agent_name,
|
|
227
246
|
"cost": round(r.get("totalCost", 0) or 0, 4),
|
|
228
247
|
"inputTokens": r.get("inputTokens", 0),
|
|
229
248
|
"outputTokens": r.get("outputTokens", 0),
|
|
230
249
|
"cacheReadTokens": r.get("cacheReadTokens", 0),
|
|
231
250
|
"cacheCreationTokens": r.get("cacheCreationTokens", 0),
|
|
232
251
|
"lastActivity": last_activity,
|
|
233
|
-
"cwd": cwd, #
|
|
234
|
-
"dirName": dir_name, #
|
|
235
|
-
"projectKey": project_raw, # raw ccusage projectPath
|
|
236
|
-
"hasCwd": bool(
|
|
252
|
+
"cwd": cwd, # real workdir when resolved, else best-effort decode
|
|
253
|
+
"dirName": dir_name, # real dir name when resolved, else last path segment
|
|
254
|
+
"projectKey": project_raw, # raw ccusage projectPath
|
|
255
|
+
"hasCwd": bool(cwd),
|
|
237
256
|
}
|
|
238
257
|
)
|
|
239
258
|
out.sort(key=lambda s: s["cost"], reverse=True)
|
|
240
259
|
return {"total": len(out), "sessions": out}
|
|
241
260
|
|
|
242
261
|
|
|
262
|
+
def _pi_cwd_from_disk(project_key: str):
|
|
263
|
+
"""Best-effort real workdir for a pi session project.
|
|
264
|
+
|
|
265
|
+
pi stores sessions under ~/.pi/agent/sessions/<projectKey>/<ts>_<id>.jsonl,
|
|
266
|
+
whose first line carries an authoritative "cwd". Reading it lets the dashboard
|
|
267
|
+
show the REAL dir name (e.g. "fund-tracker") instead of the lossy basename that
|
|
268
|
+
the encoded projectPath produces ("tracker").
|
|
269
|
+
|
|
270
|
+
This only supplies the DISPLAY label — costs/tokens still come from ccusage.
|
|
271
|
+
Returns None (caller falls back) if the dir/file is missing or unreadable.
|
|
272
|
+
"""
|
|
273
|
+
import glob
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
root = Path.home() / ".pi" / "agent" / "sessions" / project_key
|
|
277
|
+
if not root.is_dir():
|
|
278
|
+
return None
|
|
279
|
+
candidates = sorted(glob.glob(str(root / "*.jsonl")))
|
|
280
|
+
for path in candidates:
|
|
281
|
+
cwd = _cwd_from_jsonl(path)
|
|
282
|
+
if cwd:
|
|
283
|
+
return cwd
|
|
284
|
+
except Exception:
|
|
285
|
+
return None
|
|
286
|
+
return None
|
|
287
|
+
|
|
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
|
+
|
|
243
415
|
def _decode_cwd(raw: str) -> str:
|
|
244
416
|
"""Best-effort decode of ccusage's projectPath into a readable path.
|
|
245
417
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caius_kong/ccusage-dashboard",
|
|
3
|
-
"version": "0.2.
|
|
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",
|