From b2f5edfb33edc3e50c25b1dcce5d298a5bf15532 Mon Sep 17 00:00:00 2001 From: Christopher Jon Pitzi Date: Wed, 17 Jun 2026 18:50:38 -0400 Subject: [PATCH] feat(cost-export): standard local-session "underway" heartbeat hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the local-session in-flight heartbeat into the claude-cost-export component so every bootstrapped workstation installs it. Adds session-heartbeat.sh — a throttled (~30s) PostToolUse + UserPromptSubmit hook that fire-and-forgets a `session_running` event to the homelab Loki receiver while a session is actively working, driving the "Local sessions underway" pane on the Claude Runner Fleet dashboard. The metric is active tool use (idle/open sessions don't beat). install.sh now deploys the script and wires both hooks idempotently alongside the existing SessionEnd hook (one backup, no duplicates); the four setup-*.sh scripts already call install.sh, so it propagates to every platform with no further changes. README documents the signal, its semantics, and COST_HEARTBEAT_SEC. Prompt-Origin: Chris liked the local-session underway heartbeat built ad-hoc on the laptop and wanted it folded into workstation-bootstrap as an operational standard, confirming active tool use is the correct metric. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude-cost-export/README.md | 28 +++++++++++++- claude-cost-export/install.sh | 50 ++++++++++++++++--------- claude-cost-export/session-heartbeat.sh | 45 ++++++++++++++++++++++ 3 files changed, 104 insertions(+), 19 deletions(-) create mode 100755 claude-cost-export/session-heartbeat.sh diff --git a/claude-cost-export/README.md b/claude-cost-export/README.md index 7358c27..20e84d7 100644 --- a/claude-cost-export/README.md +++ b/claude-cost-export/README.md @@ -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=`). +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. @@ -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/` 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 @@ -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 @@ -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 diff --git a/claude-cost-export/install.sh b/claude-cost-export/install.sh index ec7f6ff..5c1c27b 100755 --- a/claude-cost-export/install.sh +++ b/claude-cost-export/install.sh @@ -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. @@ -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 @@ -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() { # → 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() { # (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 --------------------------- @@ -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 diff --git a/claude-cost-export/session-heartbeat.sh b/claude-cost-export/session-heartbeat.sh new file mode 100755 index 0000000..4cc7296 --- /dev/null +++ b/claude-cost-export/session-heartbeat.sh @@ -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