fuzzyworld

fuzzyworld-tools / commit

fw usage: Claude Code token use per session

03124f411918c9ad2cf04355e5bd223e39b185f0 · Pavel Safronov · 2026-10-31 11:09:45 -0700 · parents a49971565086

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 6 tally 152 in / 45.1K out / 3.5M cached · usage by commit and session

fw usage: Claude Code token use per session

Reads ~/.claude/projects transcripts (subagents included in totals); --now gives
one line for the newest session, --all covers every project.

FW-Tokens-In: 152
FW-Tokens-Out: 45056
FW-Tokens-Cached: 3521698
FW-Session: 32ba7c5b-8c34-4faa-9aea-21f0f239f505
FW-Rtk-Saved: 2610442
FW-Rtk-Cmds: 6251

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 README.md    |  1 +
 bin/fw       |  3 +++
 lib/usage.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 68 insertions(+)

diff --git a/README.md b/README.md
index 5c6dba8..7678beb 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@ On the laptop, `bin/fw` runs `deploy` locally and forwards repo commands to Drea
 |---|---|
 | `fw deploy [--ref B] [--here] [--dry-run]` | build and publish this repo's app (above) |
 | `fw repoint [DIR] [--https] [--dry-run]` | for each repo in DIR whose `origin` is on gitlab.com: rename it `gitlab`, add `origin` → `dh:git/<repo>.git` (or read-only `https://code.fuzzyworld.net/<repo>.git` with `--https`, public repos only), fetch, and retarget branch tracking. Changes only remotes; checks that uncommitted work, HEAD and stashes are unchanged |
+| `fw usage [--all\|--now]` | Claude Code token use per session from `~/.claude/projects` (runs locally): messages, current context size, cache-read, output (cache-read and output include subagents). `--now` prints one line for the newest session, to decide when to put it down |
 | `fw list` | every repo, its visibility and last commit |
 | `fw new <name> [--public] [--description TEXT]` | create `~/git/<name>.git` on DreamHost; **private unless `--public`** |
 | `fw public <name>` | publish the source on code.fuzzyworld.net right away |
diff --git a/bin/fw b/bin/fw
index 43cfa89..1cd79dd 100755
--- a/bin/fw
+++ b/bin/fw
@@ -3,6 +3,7 @@
 #   fw deploy [--ref <branch>] [--here] [--dry-run]   build and publish this repo's app (see lib/deploy.mjs)
 #   fw repoint [DIR] [--https] [--dry-run]            move working copies' origin from GitLab to DreamHost
 #   fw list | new <name> [--public] [--description TEXT] | public <name> | private <name>
+#   fw usage [--all|--now]                            Claude Code token use per session (see lib/usage.py)
 #   fw describe <name> TEXT | publish <name> | publish-all | ci <name> [branch] | ci-list | ci-watch
 #                                                     (run on DreamHost over ssh)
 set -euo pipefail
@@ -13,6 +14,8 @@ HERE=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)
 case "${1:-help}" in
   deploy)
     shift; exec node "$HERE/lib/deploy.mjs" "$@" ;;
+  usage)
+    shift; exec python3 "$HERE/lib/usage.py" "$@" ;;
   repoint)
     shift; exec bash "$HERE/lib/repoint.sh" "$@" ;;
   list|new|public|private|describe|publish|publish-all|ci|ci-list|ci-watch|help|-h|--help)
diff --git a/lib/usage.py b/lib/usage.py
new file mode 100644
index 0000000..d9a7831
--- /dev/null
+++ b/lib/usage.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""fw usage: token use per Claude Code session, from ~/.claude/projects/*/*.jsonl.
+
+  fw usage              sessions of the current directory's project, newest first
+  fw usage --all        every project
+  fw usage --now        one line about the newest session here: messages, current
+                        context size, model share (use it to decide on "put down")
+"""
+import glob, json, os, re, sys, time
+
+root = os.path.expanduser('~/.claude/projects')
+
+def scan(path):
+    """Totals include the session's subagent transcripts (<session>/subagents/*.jsonl);
+    the context size and message count are the main conversation's."""
+    msgs, tot, ctx, models = scan_one(path)
+    for sub in glob.glob(path[:-6] + '/subagents/*.jsonl'):
+        _, t, _, _ = scan_one(sub)
+        tot = [a + b for a, b in zip(tot, t)]
+    return msgs, tot, ctx, models
+
+def scan_one(path):
+    seen, msgs, tot, ctx, models = set(), 0, [0, 0, 0, 0], 0, {}
+    for line in open(path, errors='ignore'):
+        try: d = json.loads(line)
+        except ValueError: continue
+        m = d.get('message')
+        u = m.get('usage') if isinstance(m, dict) else None
+        if not u or d.get('type') != 'assistant': continue
+        k = m.get('id') or d.get('uuid')
+        if k in seen: continue
+        seen.add(k)
+        v = [u.get('input_tokens', 0), u.get('output_tokens', 0),
+             u.get('cache_read_input_tokens', 0), u.get('cache_creation_input_tokens', 0)]
+        tot = [a + b for a, b in zip(tot, v)]
+        ctx = v[0] + v[2] + v[3]
+        msgs += 1
+        models[m.get('model', '?')] = models.get(m.get('model', '?'), 0) + 1
+    return msgs, tot, ctx, models
+
+def fmt(n): return f'{n:,}'
+
+args = sys.argv[1:]
+proj = os.path.join(root, re.sub(r'[^a-zA-Z0-9]', '-', os.getcwd()))
+dirs = sorted(glob.glob(root + '/*')) if '--all' in args else [proj]
+files = [f for d in dirs for f in glob.glob(d + '/*.jsonl')]
+files.sort(key=os.path.getmtime, reverse=True)
+if not files:
+    sys.exit('no sessions found for ' + proj)
+
+if '--now' in args:
+    f = files[0]
+    msgs, tot, ctx, models = scan(f)
+    share = ', '.join(f'{k.replace("claude-", "")} {v}' for k, v in sorted(models.items()) if k != '<synthetic>')
+    print(f'{os.path.basename(f)[:8]}: {msgs} msgs, context now {fmt(ctx)} tokens, '
+          f'cache-read so far {fmt(tot[2])}, output {fmt(tot[1])}; models: {share}')
+    sys.exit()
+
+print(f'{"session":9}{"project":28}{"msgs":>6}{"context":>12}{"cache-read":>16}{"output":>12}  last used')
+for f in files:
+    msgs, tot, ctx, _ = scan(f)
+    p = os.path.basename(os.path.dirname(f)).replace('-Users-pavel-', '')[:27]
+    print(f'{os.path.basename(f)[:8]:9}{p:28}{msgs:>6}{fmt(ctx):>12}{fmt(tot[2]):>16}{fmt(tot[1]):>12}  '
+          + time.strftime('%Y-%m-%d %H:%M', time.localtime(os.path.getmtime(f))))