Skip to content

Commit d6a7a1e

Browse files
feat: v0.11 — efficiency insights, daemon alerts, day-over-day history
- viewer: estimated cost per session + ~$/month run rate; ⚠ flag for sessions that burned tokens with no output (no edits/files/bash); live session count in the dashboard header - daemon alerts: edge-triggered notifications on session/week thresholds and burn-rate overrun projection; pluggable delivery (log, --notify-url for ntfy/webhook, --notify-cmd via sh -c). New internal/service/alert - rate-limit history: daemon persists per-poll samples locally (internal/service/history), prunes on startup, and reports day-over-day (~24h) deltas through the status payload; viewer shows ↑/↓ pp on the bars - domain: Window.PrevDayPct + Status.HasHistory (additive, schema stays 5) - tests for alert eval/edge-trigger and history At/Prune
1 parent 41aa8db commit d6a7a1e

11 files changed

Lines changed: 754 additions & 27 deletions

File tree

README.md

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,22 +108,26 @@ common gotchas around SSH alias users and copy-pasted long URLs) is in
108108

109109
Six tabs, or one dense dashboard view (default when terminal is ≥80×18):
110110

111-
1. **Limits** — 5h and 7d rate-limit windows with reset countdowns.
111+
1. **Limits** — 5h and 7d rate-limit windows with reset countdowns, burn-rate
112+
projection, and a **day-over-day delta** (↑/↓ pp vs ~24h ago) once the
113+
daemon has built up local history.
112114
2. **Projects** — which working directories ate your token budget over
113115
the chosen window; per-host attribution when two or more machines
114116
contributed to the same project; session count per project.
115117
3. **Models** — Opus vs Sonnet vs Haiku split, input/output/cache
116118
columns, **cache hit rate**, distinct session count per model, and an
117-
**estimated USD cost** per model and overall (list price — override with
118-
`--pricing`).
119+
**estimated USD cost** per model, overall, and as a **~$/month run rate**
120+
(list price — override with `--pricing`).
119121
4. **Hosts** — per-machine totals: tokens contributed, distinct
120122
projects, distinct sessions, freshness.
121123
5. **Sessions** — top 10 most expensive conversations in the window, each
122124
with its **AI title** (what it was actually about), a **live dot** for
123125
sessions touched in the last 5 minutes, per-session **cache hit rate**,
124-
and an **action breakdown** (edits, files touched, reads, bash) so you
125-
see what was done, not just how many tokens it cost. Account-level
126-
edit/read/bash totals sit in the section header.
126+
**estimated cost**, and an **action breakdown** (edits, files touched,
127+
reads, bash) so you see what was done, not just how many tokens it cost.
128+
A **⚠ flag** marks sessions that burned tokens but produced no output
129+
(no edits/files/bash — usually the model spinning). Account-level
130+
edit/read/bash totals and a live-session count sit in the headers.
127131
6. **Hourly** — 24h sparkline plus 7d daily breakdown.
128132

129133
Press `f` to filter to a single host (cycles through all → omen → pop-os
@@ -216,11 +220,39 @@ attacker gets numbers, not the OAuth token.
216220
| `--once` | `false` | probe once and exit (smoke test) |
217221
| `--local-only` | `false` | print to stdout instead of writing |
218222
| `--skip-probe` | `false` | skip the Anthropic call, aggregate transcripts only |
223+
| `--alert-session` | `0` (off) | alert when the 5h window reaches this percent |
224+
| `--alert-week` | `0` (off) | alert when the 7d window reaches this percent |
225+
| `--alert-project` | `false` | alert when burn rate projects hitting 100% before reset |
226+
| `--notify-url` | "" | POST alerts to this URL (ntfy topic or generic webhook) |
227+
| `--notify-cmd` | "" | run this command (`sh -c`) per alert; details in `$CLAWTOP_ALERT_*` |
228+
| `--history-dir` | `~/.local/share/clawtop` | local rate-limit history dir (empty disables) |
229+
| `--history-keep` | `720h` (30d) | how long to retain history samples |
219230

220231
`clawtopd doctor` accepts the same flags and runs four preflight checks:
221232
credentials are readable, Anthropic responds, SSH alias is reachable
222233
(if remote), destination is writable.
223234

235+
### Alerts
236+
237+
The daemon can notify you before you hit a wall, so you don't have to watch
238+
the TUI. Alerts **edge-trigger** — each condition fires once when it starts,
239+
then re-arms only after it clears (e.g. the window resets). Every alert is
240+
logged; set `--notify-url` and/or `--notify-cmd` to also push it out.
241+
242+
```bash
243+
# Warn at 80% session / 90% week, and when burn rate projects an overrun,
244+
# pushing to an ntfy topic and a desktop notification.
245+
clawtopd --alert-session=80 --alert-week=90 --alert-project \
246+
--notify-url=https://ntfy.sh/my-private-topic \
247+
--notify-cmd='notify-send "$CLAWTOP_ALERT_TITLE" "$CLAWTOP_ALERT_MESSAGE"'
248+
```
249+
250+
`--notify-cmd` runs with `$CLAWTOP_ALERT_TITLE`, `$CLAWTOP_ALERT_MESSAGE`,
251+
`$CLAWTOP_ALERT_LEVEL` (warning/urgent), and `$CLAWTOP_ALERT_KEY` set.
252+
History is local daemon state; the derived day-over-day deltas travel to the
253+
viewer inside the status payload, so a central viewer shows them without
254+
reading any history file.
255+
224256
`clawtop` (viewer):
225257

226258
| flag | default | what |
@@ -251,8 +283,9 @@ quit.
251283
"schema": 5,
252284
"machine": "omen",
253285
"ts": 1716688320,
254-
"session": { "pct": 31.0, "reset_at": 1716700000 },
255-
"week": { "pct": 5.0, "reset_at": 1717100000 },
286+
"session": { "pct": 31.0, "reset_at": 1716700000, "prev_day_pct": 22.0 },
287+
"week": { "pct": 5.0, "reset_at": 1717100000, "prev_day_pct": 4.0 },
288+
"has_history": true,
256289
"limit": "allowed",
257290
"subscription": "team",
258291
"window": "7d",

cmd/clawtop/main.go

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"os"
1414
"path/filepath"
1515
"sort"
16+
"strconv"
1617
"strings"
1718
"time"
1819

@@ -400,7 +401,7 @@ func (m model) viewDashboard() string {
400401
cols := lipgloss.JoinHorizontal(lipgloss.Top, leftCol, " ", rightCol)
401402

402403
hosts := dashboardHosts(merged, innerW)
403-
sessions := dashboardSessions(merged, innerW)
404+
sessions := dashboardSessions(merged, innerW, m.pricing)
404405
trends := dashboardTrends(merged, innerW)
405406

406407
keys := dimStyle.Render("t tabbed · f filter host · j/k scroll · g/G top/end · r reload · q quit")
@@ -424,7 +425,7 @@ func (m model) viewDashboard() string {
424425

425426
// dashboardSessions is a compact view of the top 3 most expensive sessions in
426427
// the window: live dot + title, with tokens/actions/model/age as dim meta.
427-
func dashboardSessions(m merger.Merged, w int) string {
428+
func dashboardSessions(m merger.Merged, w int, pricing pricingTable) string {
428429
header := labelStyle.Render("TOP SESSIONS")
429430
if a := actionsSummary(m); a != "" {
430431
header += dimStyle.Render(" · " + a)
@@ -447,14 +448,22 @@ func dashboardSessions(m merger.Merged, w int) string {
447448
label = s.Project
448449
}
449450
meta := []string{fmtTokens(s.Total())}
451+
if c, ok := pricing.sessionCost(s); ok {
452+
meta = append(meta, fmtUSD(c))
453+
}
450454
if act := sessionActions(s); act != "" {
451455
meta = append(meta, act)
452456
}
453457
meta = append(meta, prettyModel(s.Model), short(now.Sub(time.Unix(s.LastAt, 0)))+" ago")
454-
rows = append(rows, fmt.Sprintf(" %s %-*s %s",
458+
marker := " "
459+
if isSpinning(s) {
460+
marker = warnStyle.Render("⚠")
461+
}
462+
rows = append(rows, fmt.Sprintf(" %s %-*s %s %s",
455463
liveDot(s.LastAt),
456464
labelW, truncate(label, labelW),
457-
dimStyle.Render(truncate(strings.Join(meta, " · "), w-labelW-5)),
465+
marker,
466+
dimStyle.Render(truncate(strings.Join(meta, " · "), w-labelW-7)),
458467
))
459468
}
460469
return strings.Join(rows, "\n")
@@ -468,11 +477,15 @@ func (m model) dashboardHeader(w int) string {
468477
if m.machineFilter != "" {
469478
filterPart = " · filter " + warnStyle.Render(m.machineFilter)
470479
}
480+
livePart := ""
481+
if n := liveCount(merged); n > 0 {
482+
livePart = dimStyle.Render(" · ") + okStyle.Render(fmt.Sprintf("%d live", n))
483+
}
471484
left := titleStyle.Render("clawtop") +
472485
dimStyle.Render(" · "+time.Now().Format("15:04:05")+
473486
" · hosts "+fmt.Sprintf("%d (%s)", len(merged.Machines), joinHosts(merged.Machines))+
474487
" · plan "+fallback(merged.Subscription, "?")+
475-
" · window "+fallback(merged.Window, "?")) + filterPart + dimStyle.Render(" ")
488+
" · window "+fallback(merged.Window, "?")) + livePart + filterPart + dimStyle.Render(" ")
476489
right := freshnessLabel(merged.Machines)
477490
gap := w - lipgloss.Width(left) - lipgloss.Width(right)
478491
if gap < 1 {
@@ -569,19 +582,26 @@ func (m model) dashboardLimits(w int) string {
569582
if barW < 10 {
570583
barW = 10
571584
}
585+
sessDelta, weekDelta := "", ""
586+
if merged.HasHistory {
587+
sessDelta = " " + dayDeltaLabel(merged.Session)
588+
weekDelta = " " + dayDeltaLabel(merged.Week)
589+
}
572590
return strings.Join([]string{
573-
fmt.Sprintf("%-8s %s %s %s",
591+
fmt.Sprintf("%-8s %s %s %s%s",
574592
labelStyle.Render("SESSION"),
575593
barRanked(merged.Session.Pct, barW),
576594
pct(merged.Session.Pct),
577595
dimStyle.Render("· resets "+short(merged.Session.ResetIn())+
578-
projectionLine(merged.Session.Pct, sessR, merged.Session.ResetIn()))),
579-
fmt.Sprintf("%-8s %s %s %s",
596+
projectionLine(merged.Session.Pct, sessR, merged.Session.ResetIn())),
597+
sessDelta),
598+
fmt.Sprintf("%-8s %s %s %s%s",
580599
labelStyle.Render("WEEK"),
581600
barRanked(merged.Week.Pct, barW),
582601
pct(merged.Week.Pct),
583602
dimStyle.Render("· resets "+short(merged.Week.ResetIn())+
584-
projectionLine(merged.Week.Pct, weekR, merged.Week.ResetIn()))),
603+
projectionLine(merged.Week.Pct, weekR, merged.Week.ResetIn())),
604+
weekDelta),
585605
}, "\n")
586606
}
587607

@@ -648,7 +668,11 @@ func dashboardProjects(m merger.Merged, w, visible, scroll int) string {
648668
func dashboardModels(m merger.Merged, w int, pricing pricingTable) string {
649669
header := labelStyle.Render("MODELS")
650670
if total := pricing.totalCost(m.ByModel); total > 0 {
651-
header += dimStyle.Render(" · est. " + fmtUSD(total))
671+
suffix := " · est. " + fmtUSD(total)
672+
if mo, ok := monthlyCost(total, m.Window); ok {
673+
suffix += " · ~" + fmtUSD(mo) + "/mo"
674+
}
675+
header += dimStyle.Render(suffix)
652676
}
653677
if len(m.ByModel) == 0 {
654678
return strings.Join([]string{header, dimStyle.Render("no data")}, "\n")
@@ -695,7 +719,7 @@ func (m model) viewTabbed() string {
695719
case tabHosts:
696720
body = viewHosts(m.merged, innerW)
697721
case tabSessions:
698-
body = viewSessions(m.merged, innerW)
722+
body = viewSessions(m.merged, innerW, m.pricing)
699723
case tabHeatmap:
700724
body = viewHeatmap(m.merged, innerW)
701725
case tabHourly:
@@ -745,14 +769,19 @@ func (m model) viewLimits(w int) string {
745769
merged := m.merged
746770
sessR, weekR := m.burnRate()
747771
barW := w - 12
772+
sessDelta, weekDelta := "", ""
773+
if merged.HasHistory {
774+
sessDelta = " " + dayDeltaLabel(merged.Session)
775+
weekDelta = " " + dayDeltaLabel(merged.Week)
776+
}
748777
return strings.Join([]string{
749778
labelStyle.Render("SESSION (5h)"),
750-
barRanked(merged.Session.Pct, barW) + " " + pct(merged.Session.Pct),
779+
barRanked(merged.Session.Pct, barW) + " " + pct(merged.Session.Pct) + sessDelta,
751780
dimStyle.Render("resets in " + short(merged.Session.ResetIn()) +
752781
projectionLine(merged.Session.Pct, sessR, merged.Session.ResetIn())),
753782
"",
754783
labelStyle.Render("WEEK (7d)"),
755-
barRanked(merged.Week.Pct, barW) + " " + pct(merged.Week.Pct),
784+
barRanked(merged.Week.Pct, barW) + " " + pct(merged.Week.Pct) + weekDelta,
756785
dimStyle.Render("resets in " + short(merged.Week.ResetIn()) +
757786
projectionLine(merged.Week.Pct, weekR, merged.Week.ResetIn())),
758787
"",
@@ -839,7 +868,11 @@ func viewModels(m merger.Merged, w int, pricing pricingTable) string {
839868
}
840869
header := labelStyle.Render(fmt.Sprintf("MODELS (last %s)", fallback(m.Window, "?")))
841870
if total := pricing.totalCost(m.ByModel); total > 0 {
842-
header += dimStyle.Render(" est. " + fmtUSD(total) + " (list price, validate against billing)")
871+
suffix := " est. " + fmtUSD(total)
872+
if mo, ok := monthlyCost(total, m.Window); ok {
873+
suffix += " · ~" + fmtUSD(mo) + "/mo"
874+
}
875+
header += dimStyle.Render(suffix + " (list price, validate against billing)")
843876
}
844877
var maxT int64
845878
for _, mm := range m.ByModel {
@@ -1005,15 +1038,15 @@ func trendLabel(curr, prev int64) string {
10051038
// session takes two lines: a live-dot + title headline, then a dim meta line
10061039
// with project, model, tokens, cache hit rate, action breakdown, duration and
10071040
// last-seen. Useful to spot what each runaway conversation was actually doing.
1008-
func viewSessions(m merger.Merged, w int) string {
1041+
func viewSessions(m merger.Merged, w int, pricing pricingTable) string {
10091042
header := labelStyle.Render(fmt.Sprintf("TOP SESSIONS (last %s)", fallback(m.Window, "?")))
10101043
if a := actionsSummary(m); a != "" {
10111044
header += dimStyle.Render(" · " + a)
10121045
}
10131046
if len(m.TopSessions) == 0 {
10141047
return strings.Join([]string{header, "", dimStyle.Render("no transcript data yet")}, "\n")
10151048
}
1016-
rows := []string{header, "", dimStyle.Render("● live (touched <5m) actions: e=edits f=files r=reads b=bash"), ""}
1049+
rows := []string{header, "", dimStyle.Render("● live (touched <5m) actions: e=edits f=files r=reads b=bash ⚠ tokens but no output"), ""}
10171050
now := time.Now()
10181051
for _, s := range m.TopSessions {
10191052
dur := time.Duration(s.LastAt-s.StartedAt) * time.Second
@@ -1022,11 +1055,18 @@ func viewSessions(m merger.Merged, w int) string {
10221055
if label == "" {
10231056
label = s.Project
10241057
}
1058+
title := truncate(label, w-2)
1059+
if isSpinning(s) {
1060+
title += " " + warnStyle.Render("⚠")
1061+
}
10251062
meta := []string{
10261063
truncate(s.Project, 20),
10271064
truncate(prettyModel(s.Model), 16),
10281065
fmtTokens(s.Total()),
10291066
}
1067+
if c, ok := pricing.sessionCost(s); ok {
1068+
meta = append(meta, fmtUSD(c))
1069+
}
10301070
if s.In+s.CacheR > 0 {
10311071
meta = append(meta, fmt.Sprintf("cache %.0f%%", s.CacheHitRate()))
10321072
}
@@ -1035,7 +1075,7 @@ func viewSessions(m merger.Merged, w int) string {
10351075
}
10361076
meta = append(meta, short(dur), short(age)+" ago")
10371077
rows = append(rows,
1038-
fmt.Sprintf("%s %s", liveDot(s.LastAt), truncate(label, w-2)),
1078+
fmt.Sprintf("%s %s", liveDot(s.LastAt), title),
10391079
dimStyle.Render(" "+truncate(strings.Join(meta, " · "), w-2)),
10401080
)
10411081
}
@@ -1163,6 +1203,58 @@ func pct(v float64) string {
11631203
return pctStyle.Render(fmt.Sprintf("%5.1f%%", v))
11641204
}
11651205

1206+
// dayDeltaLabel renders the percentage-point change of a rate-limit window vs
1207+
// ~24h ago. Rising utilization is bad (warn), falling is good (ok).
1208+
func dayDeltaLabel(w domain.Window) string {
1209+
d := w.DayDelta()
1210+
switch {
1211+
case d > 0.05:
1212+
return warnStyle.Render(fmt.Sprintf("↑%.1fpp/24h", d))
1213+
case d < -0.05:
1214+
return okStyle.Render(fmt.Sprintf("↓%.1fpp/24h", -d))
1215+
default:
1216+
return dimStyle.Render("≈flat/24h")
1217+
}
1218+
}
1219+
1220+
// windowDuration parses the daemon's window label ("24h", "7d", "30d", or a
1221+
// Go duration string) into a duration. Zero when unparseable.
1222+
func windowDuration(s string) time.Duration {
1223+
if s == "" {
1224+
return 0
1225+
}
1226+
if n, err := strconv.Atoi(strings.TrimSuffix(s, "d")); err == nil && strings.HasSuffix(s, "d") {
1227+
return time.Duration(n) * 24 * time.Hour
1228+
}
1229+
if d, err := time.ParseDuration(s); err == nil {
1230+
return d
1231+
}
1232+
return 0
1233+
}
1234+
1235+
// monthlyCost extrapolates the window's total cost to a 30-day run rate. ok is
1236+
// false when the window is unknown or there's no cost yet.
1237+
func monthlyCost(total float64, window string) (float64, bool) {
1238+
d := windowDuration(window)
1239+
if d <= 0 || total <= 0 {
1240+
return 0, false
1241+
}
1242+
month := 30 * 24 * time.Hour
1243+
return total * float64(month) / float64(d), true
1244+
}
1245+
1246+
// liveCount returns how many of the top sessions were touched in the last 5
1247+
// minutes. Note: scoped to the top sessions list, not every session.
1248+
func liveCount(m merger.Merged) int {
1249+
n := 0
1250+
for _, s := range m.TopSessions {
1251+
if s.LastAt > 0 && time.Since(time.Unix(s.LastAt, 0)) < 5*time.Minute {
1252+
n++
1253+
}
1254+
}
1255+
return n
1256+
}
1257+
11661258
// liveDot returns a green ● for sessions touched within the last 5 minutes
11671259
// (still active), else a dim ○ (idle/done).
11681260
func liveDot(lastAt int64) string {
@@ -1191,6 +1283,18 @@ func modelCostSuffix(pricing pricingTable, mm domain.Model) string {
11911283
return ""
11921284
}
11931285

1286+
// spinThreshold is the token count above which a session that produced no
1287+
// concrete output is considered to be "spinning" (stuck exploring/discussing
1288+
// without acting). Picked to ignore small read-only Q&A sessions.
1289+
const spinThreshold = 200_000
1290+
1291+
// isSpinning flags a session that burned significant tokens but made no edits,
1292+
// touched no files, and ran no shell commands — usually the model stuck in a
1293+
// loop rather than doing work.
1294+
func isSpinning(s domain.SessionStat) bool {
1295+
return s.Total() >= spinThreshold && s.Edits == 0 && s.FilesTouched == 0 && s.Bash == 0
1296+
}
1297+
11941298
// sessionActions renders a per-session action breakdown like "12e 3f 40r 8b"
11951299
// (edits, files touched, reads, bash). Empty when the session did nothing.
11961300
func sessionActions(s domain.SessionStat) string {

cmd/clawtop/pricing.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ func (t pricingTable) modelCost(m domain.Model) (float64, bool) {
9494
return p.Cost(m.In, m.Out, m.CacheR, m.CacheC), true
9595
}
9696

97+
// sessionCost estimates the USD cost of one session's usage. ok is false when
98+
// the session's model family is unknown to the table.
99+
func (t pricingTable) sessionCost(s domain.SessionStat) (float64, bool) {
100+
p, ok := t.priceFor(s.Model)
101+
if !ok {
102+
return 0, false
103+
}
104+
return p.Cost(s.In, s.Out, s.CacheR, s.CacheC), true
105+
}
106+
97107
// totalCost sums the estimated cost across all models with a known family.
98108
func (t pricingTable) totalCost(models []domain.Model) float64 {
99109
var sum float64

0 commit comments

Comments
 (0)