Skip to content
This repository was archived by the owner on Jul 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions claude-cost-export/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ Grafana **"Claude Runner Fleet"** dashboard, so a workstation shows up next to
the [bullpen](https://github.com/PitziLabs/bullpen) agents under a **Local
sessions** row (`source="local"`, `worker=<hostname>`).

It also emits a lightweight **`session_running`** heartbeat while a session is
actively working (throttled to ~30s, keyed on **active tool use**), so the
dashboard's **"Local sessions underway"** pane shows in real time which
workstations are busy — the live counterpart to the per-session cost shipped at
the end.

The bullpen gets `total_cost_usd` for free from headless `claude -p --output-format
json`. Interactive sessions have no such result blob, so this tool reconstructs
per-session cost from the transcript and ships one event per finished session.
Expand Down Expand Up @@ -42,8 +48,9 @@ serialized with `flock` so a timer tick can't race a manual run.
| `cost-export.mjs` | sweep transcripts → spool, and drain spool → Loki. Phases: `sweep`, `ship`, `seed`, `all` (default). |
| `pricing.json` | per-MTok USD price table + cache multipliers. **Drifts — keep current** (source: platform.claude.com/docs/en/about-claude/pricing). |
| `cost-hook.sh` | `SessionEnd` hook: drops a `done/<session_id>` marker so finished sessions ship promptly. |
| `session-heartbeat.sh` | `PostToolUse` + `UserPromptSubmit` hook: throttled (~30s) `session_running` beat on active tool use → the "Local sessions underway" pane. Fire-and-forget; off-LAN no-ops. |
| `claude-cost-export.{service,timer}` | systemd `--user` units; the timer runs `sweep + ship` every 5 min (backstop + off-LAN drainer). |
| `install.sh` | idempotent install: copy assets → render units → merge hook → **seed** → start timer. |
| `install.sh` | idempotent install: copy assets → render units → merge hooks → **seed** → start timer. |

## Install

Expand All @@ -60,6 +67,22 @@ COST_LOOKBACK_DAYS=90 flock -n ~/.claude/cost-export/.lock \
node ~/.claude/cost-export/cost-export.mjs sweep
```

## Live "underway" signal

The `session_running` heartbeat is **separate from cost shipping**.
`session-heartbeat.sh` fires on every `PostToolUse` (and `UserPromptSubmit`),
throttles to one push per session per `COST_HEARTBEAT_SEC` (default 30s), and
fire-and-forgets a tiny `session_running` event straight to the Loki receiver. It
is **never spooled** (a stale "running" shipped later would be misleading) and
off-LAN it just times out and vanishes — so it adds no latency and needs no state.

**The metric is active tool use, not session liveness.** A session that's open
but idle (you're reading, or away) emits no beats, so its band drops out — the
pane answers "which workstations are *working* right now", not "which sessions
are merely open". A continuously-busy session draws a solid band; long pauses show
as gaps. The dashboard query (`count_over_time(... session_running [2m]) > bool 0`,
stacked by `worker`) bridges beats within a 2-minute window.

## Cost basis

Cost is **computed** (CC doesn't write dollars to the transcript) as
Expand All @@ -73,7 +96,8 @@ same API-list-price basis the fleet reports — an estimate, not a billed amount

`COST_STATE_DIR` · `COST_PROJECTS_DIR` · `COST_PRICING` · `COST_LOKI_URL`
(default `http://192.168.139.20:3100/loki/api/v1/push`) · `COST_IDLE_MIN` (30) ·
`COST_LOOKBACK_DAYS` (14) · `COST_WORKER` (hostname) · `COST_REJECT_OLD_H` (160).
`COST_LOOKBACK_DAYS` (14) · `COST_WORKER` (hostname) · `COST_REJECT_OLD_H` (160) ·
`COST_HEARTBEAT_SEC` (30, heartbeat throttle).

## Known limits

Expand Down
50 changes: 33 additions & 17 deletions claude-cost-export/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
# Idempotent: safe to re-run (re-syncs assets, won't duplicate the hook).
#
# Installs:
# ~/.claude/cost-export/{cost-export.mjs,pricing.json,cost-hook.sh} (assets + state)
# ~/.claude/cost-export/{cost-export.mjs,pricing.json,cost-hook.sh,session-heartbeat.sh}
# ~/.config/systemd/user/claude-cost-export.{service,timer} (5-min sweep+ship)
# a SessionEnd hook in ~/.claude/settings.json (prompt finalize)
# settings.json hooks: SessionEnd (finalize) + PostToolUse/UserPromptSubmit (underway)
#
# Run standalone: claude-cost-export/install.sh
# Called by the workstation-bootstrap setup-*.sh scripts after repos are cloned.
Expand All @@ -30,8 +30,9 @@ c_info "node: $NODE_BIN ($($NODE_BIN --version))"

# --- 2. Copy assets --------------------------------------------------------
mkdir -p "$INSTALL_DIR/done"
install -m 0755 "$SRC_DIR/cost-export.mjs" "$INSTALL_DIR/cost-export.mjs"
install -m 0755 "$SRC_DIR/cost-hook.sh" "$INSTALL_DIR/cost-hook.sh"
install -m 0755 "$SRC_DIR/cost-export.mjs" "$INSTALL_DIR/cost-export.mjs"
install -m 0755 "$SRC_DIR/cost-hook.sh" "$INSTALL_DIR/cost-hook.sh"
install -m 0755 "$SRC_DIR/session-heartbeat.sh" "$INSTALL_DIR/session-heartbeat.sh"
# pricing.json: install fresh on first run; on re-run keep the local copy if the
# operator has hand-edited it (compare against the shipped one and warn on drift).
if [ ! -f "$INSTALL_DIR/pricing.json" ]; then
Expand All @@ -52,24 +53,38 @@ install -m 0644 "$SRC_DIR/claude-cost-export.timer" "$UNIT_DIR/claude-cost-expor
systemctl --user daemon-reload
c_ok "units installed (timer not started yet)"

# --- 4. SessionEnd hook in settings.json (idempotent merge) ----------------
HOOK_CMD="$INSTALL_DIR/cost-hook.sh"
# --- 4. settings.json hooks (idempotent merge) -----------------------------
# SessionEnd → cost-hook.sh (prompt-finalize a session for the sweep)
# PostToolUse → session-heartbeat.sh ┐ throttled (~30s) "session_running" beat
# UserPromptSubmit→ session-heartbeat.sh ┘ on active tool use / prompt — drives the
# dashboard's "Local sessions underway" pane.
COST_HOOK="$INSTALL_DIR/cost-hook.sh"
HB_HOOK="$INSTALL_DIR/session-heartbeat.sh"
mkdir -p "$(dirname "$SETTINGS")"
[ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
jq -e . "$SETTINGS" >/dev/null 2>&1 || c_fail "$SETTINGS is not valid JSON — refusing to touch it."
exists="$(jq -r --arg c "$HOOK_CMD" \
'[(.hooks.SessionEnd // [])[].hooks[]?.command] | index($c) != null' "$SETTINGS" 2>/dev/null || echo false)"
if [ "$exists" != "true" ]; then
cp -p "$SETTINGS" "$SETTINGS.bak.$(date +%Y%m%d%H%M%S)" # backup before edit
tmp="$(mktemp)"
jq --arg c "$HOOK_CMD" '
.hooks //= {} |
.hooks.SessionEnd //= [] |
.hooks.SessionEnd += [ { "hooks": [ { "type": "command", "command": $c } ] } ]

hook_present() { # <event> <command> → true if already wired
[ "$(jq -r --arg e "$1" --arg c "$2" \
'[(.hooks[$e] // [])[].hooks[]?.command] | index($c) != null' "$SETTINGS" 2>/dev/null || echo false)" = "true" ]
}
add_hook() { # <event> <command> (caller guarantees a backup already exists)
local tmp; tmp="$(mktemp)"
jq --arg e "$1" --arg c "$2" '
.hooks //= {} | .hooks[$e] //= [] |
.hooks[$e] += [ { "hooks": [ { "type": "command", "command": $c } ] } ]
' "$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"
c_ok "added SessionEnd hook to $SETTINGS (backup saved)"
c_ok "added $1 hook → $(basename "$2")"
}
if hook_present SessionEnd "$COST_HOOK" && hook_present PostToolUse "$HB_HOOK" \
&& hook_present UserPromptSubmit "$HB_HOOK"; then
c_ok "settings.json hooks already present"
else
c_ok "SessionEnd hook already present"
cp -p "$SETTINGS" "$SETTINGS.bak.$(date +%Y%m%d%H%M%S)" # one backup before edits
hook_present SessionEnd "$COST_HOOK" || add_hook SessionEnd "$COST_HOOK"
hook_present PostToolUse "$HB_HOOK" || add_hook PostToolUse "$HB_HOOK"
hook_present UserPromptSubmit "$HB_HOOK" || add_hook UserPromptSubmit "$HB_HOOK"
c_ok "settings.json hooks wired in $SETTINGS (backup saved)"
fi

# --- 5. SEED the guard BEFORE starting the timer ---------------------------
Expand Down Expand Up @@ -105,4 +120,5 @@ $(c_ok "Claude Code cost export installed.")
Run now: flock -n $INSTALL_DIR/.lock $NODE_BIN $INSTALL_DIR/cost-export.mjs # sweep + ship
Backfill: COST_LOOKBACK_DAYS=90 flock -n $INSTALL_DIR/.lock $NODE_BIN $INSTALL_DIR/cost-export.mjs sweep
Dashboard: "Claude Runner Fleet" → Local sessions row (source="local")
Underway: ~30s heartbeat on active tool use → "Local sessions underway" pane
EOF
45 changes: 45 additions & 0 deletions claude-cost-export/session-heartbeat.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Claude Code heartbeat hook: while a local session is actively working, push a
# lightweight event="session_running" to the homelab Loki receiver, throttled to
# one push per session per ~30s, so the "Local sessions underway" Grafana panel
# has an in-flight signal. The cost-export sweep still ships the authoritative
# session_complete at the end — this only adds the live band.
#
# Wired via ~/.claude/settings.json hooks.PostToolUse + hooks.UserPromptSubmit.
# Reads the hook payload (JSON) on stdin; needs session_id (+ cwd for a project
# label). Fire-and-forget: never blocks the session, never fails it; off-LAN the
# POST just times out and vanishes. Shares COST_* env with cost-export.mjs.
set -uo pipefail

STATE_DIR="${COST_STATE_DIR:-$HOME/.claude/cost-export}"
HB_DIR="$STATE_DIR/heartbeat"
LOKI_URL="${COST_LOKI_URL:-http://192.168.139.20:3100/loki/api/v1/push}"
WORKER="${COST_WORKER:-$(hostname)}"
THROTTLE="${COST_HEARTBEAT_SEC:-30}"

# Need jq (valid JSON) and curl (push); without either, no-op rather than risk junk.
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0

payload="$(cat 2>/dev/null || true)"
sid="$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null || true)"
[ -z "$sid" ] && exit 0
cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)"

# Throttle: at most one push per session per $THROTTLE seconds.
mkdir -p "$HB_DIR" 2>/dev/null || exit 0
marker="$HB_DIR/$sid"
now="$(date +%s)"
last="$(stat -c %Y "$marker" 2>/dev/null || echo 0)"
[ "$(( now - last ))" -lt "$THROTTLE" ] && exit 0
: > "$marker" 2>/dev/null || true

project="local"; [ -n "$cwd" ] && project="$(basename "$cwd")"
line="$(jq -cn --arg sid "$sid" --arg w "$WORKER" --arg p "$project" --arg cwd "$cwd" \
'{event:"session_running",session_id:$sid,source:"local",worker:$w,project:$p,cwd:$cwd}')"
body="$(jq -cn --arg w "$WORKER" --arg p "$project" --arg ts "${now}000000000" --arg line "$line" \
'{streams:[{stream:{job:"claude_local",service:"claude_runner",source:"local",worker:$w,project:$p,status:"running"},values:[[$ts,$line]]}]}')"

# Detach so the hook returns immediately and never adds latency to the session.
( curl -sf -m 5 -XPOST "$LOKI_URL" -H 'Content-Type: application/json' --data-binary "$body" >/dev/null 2>&1 & ) >/dev/null 2>&1
exit 0