claude-finops 0.4.4 → 0.6.0
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 +22 -0
- package/finops/advisor.py +188 -0
- package/finops/analytics.py +149 -0
- package/finops/api.py +13 -0
- package/finops/integrate.py +168 -0
- package/finops/procs.py +3 -1
- package/package.json +1 -1
- package/run.py +42 -0
- package/web/app.js +55 -2
- package/web/styles.css +8 -0
package/README.md
CHANGED
|
@@ -287,6 +287,28 @@ place for you to delete once you are happy.
|
|
|
287
287
|
|
|
288
288
|
---
|
|
289
289
|
|
|
290
|
+
## Live model advice
|
|
291
|
+
|
|
292
|
+
The back-test tells you what to use next time. These tell you mid-session, while
|
|
293
|
+
you can still act on it:
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
claude-finops --advise what every running session should switch to
|
|
297
|
+
claude-finops --install-hook suggest a cheaper model as you send each prompt
|
|
298
|
+
claude-finops --install-statusline model, context pressure and advice in your statusline
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The hook and statusline read the same evidence as the dashboard, cached for an
|
|
302
|
+
hour, and stay quiet unless your own history shows a cheaper model doing that
|
|
303
|
+
category of work without taking more turns. Neither can block or slow a prompt:
|
|
304
|
+
they fail silent and always exit 0. Undo with `--uninstall-hook` /
|
|
305
|
+
`--uninstall-statusline`; your `~/.claude/settings.json` is backed up first.
|
|
306
|
+
|
|
307
|
+
The Running sessions view shows the same line per live session, with the
|
|
308
|
+
`/model` command ready to copy.
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
290
312
|
## Privacy
|
|
291
313
|
|
|
292
314
|
`~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Live model advice: what to switch to, while the session is still running.
|
|
2
|
+
|
|
3
|
+
The back-test in analytics.model_evidence() answers "what should I have used?",
|
|
4
|
+
which is the wrong tense once a session is under way. This module answers it in
|
|
5
|
+
the present: given the prompts a session has actually sent and the model it is
|
|
6
|
+
on, say whether your own history already shows a cheaper model doing this kind
|
|
7
|
+
of work without taking more turns.
|
|
8
|
+
|
|
9
|
+
It is built to run three ways, so the advice is the same wherever you meet it:
|
|
10
|
+
|
|
11
|
+
* the dashboard's Running sessions view (per live session)
|
|
12
|
+
* a UserPromptSubmit hook (per prompt, before the turn runs)
|
|
13
|
+
* a statusline command (continuously, in a few characters)
|
|
14
|
+
|
|
15
|
+
The hook and statusline run on every prompt, so the evidence is computed once
|
|
16
|
+
and cached; a warehouse query per keystroke would be felt. Everything here fails
|
|
17
|
+
soft and silent — advice that breaks your terminal is worse than no advice.
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from .classify import classify
|
|
24
|
+
from .paths import DATA_DIR, DB_PATH, ensure_dirs
|
|
25
|
+
|
|
26
|
+
CACHE = os.path.join(DATA_DIR, "advice_cache.json")
|
|
27
|
+
TTL_S = 3600
|
|
28
|
+
RECENT_PROMPTS = 12 # how much of the session counts as "what it is doing now"
|
|
29
|
+
MIN_SAVING_PCT = 25 # below this, interrupting someone is not worth it
|
|
30
|
+
|
|
31
|
+
# `/model <name>` takes a family name, not the pricing table's id.
|
|
32
|
+
ALIASES = ("opus", "sonnet", "haiku", "fable")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def model_alias(model_id):
|
|
36
|
+
"""'claude-fable-5-1' -> 'fable'. Falls back to the full id we were given."""
|
|
37
|
+
low = str(model_id or "").lower()
|
|
38
|
+
for a in ALIASES:
|
|
39
|
+
if a in low:
|
|
40
|
+
return a
|
|
41
|
+
return model_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------- evidence ----
|
|
45
|
+
|
|
46
|
+
def _build_evidence():
|
|
47
|
+
"""Pull the back-test into the small shape the live surfaces need."""
|
|
48
|
+
from .analytics import Analytics
|
|
49
|
+
a = Analytics(DB_PATH)
|
|
50
|
+
ev = a.model_evidence({})
|
|
51
|
+
out = {}
|
|
52
|
+
for c in ev["categories"]:
|
|
53
|
+
cands = [{"model": x["model"], "name": x["name"], "verdict": x["verdict"],
|
|
54
|
+
"cost_per_prompt": round(x["cost_per_prompt"], 3),
|
|
55
|
+
"savings_pct": x["savings_pct"], "turn_ratio": x["turn_ratio"],
|
|
56
|
+
"prompts": x["prompts"], "why": x["why"]}
|
|
57
|
+
for x in c["candidates"]]
|
|
58
|
+
out[c["category"]] = {"current": c["current"]["model"],
|
|
59
|
+
"current_name": c["current"]["name"],
|
|
60
|
+
"current_cost_per_prompt": round(c["current"]["cost_per_prompt"], 3),
|
|
61
|
+
"agent": c.get("agent", "claude"),
|
|
62
|
+
"candidates": cands}
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def evidence(force=False):
|
|
67
|
+
"""Cached evidence table, keyed by category. Never raises."""
|
|
68
|
+
try:
|
|
69
|
+
with open(CACHE) as fh:
|
|
70
|
+
c = json.load(fh)
|
|
71
|
+
if not force and time.time() - c.get("built_at", 0) < TTL_S:
|
|
72
|
+
return c.get("categories") or {}
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
pass
|
|
75
|
+
try:
|
|
76
|
+
cats = _build_evidence()
|
|
77
|
+
except Exception:
|
|
78
|
+
return {}
|
|
79
|
+
try:
|
|
80
|
+
ensure_dirs()
|
|
81
|
+
tmp = CACHE + ".tmp"
|
|
82
|
+
with open(tmp, "w") as fh:
|
|
83
|
+
json.dump({"built_at": int(time.time()), "categories": cats}, fh)
|
|
84
|
+
os.replace(tmp, CACHE)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
return cats
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- session ----
|
|
91
|
+
|
|
92
|
+
def recent_prompts(transcript, n=RECENT_PROMPTS):
|
|
93
|
+
"""The last n human prompts in a live transcript, newest last.
|
|
94
|
+
|
|
95
|
+
Reads the tail only: an active session's JSONL runs to tens of megabytes and
|
|
96
|
+
this is on the path of every prompt you type.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
size = os.path.getsize(transcript)
|
|
100
|
+
with open(transcript, "rb") as fh:
|
|
101
|
+
fh.seek(max(0, size - 400_000))
|
|
102
|
+
lines = fh.read().decode("utf-8", "replace").splitlines()[1:]
|
|
103
|
+
except OSError:
|
|
104
|
+
return []
|
|
105
|
+
out = []
|
|
106
|
+
for line in reversed(lines):
|
|
107
|
+
if '"type":"user"' not in line and '"type": "user"' not in line:
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
d = json.loads(line)
|
|
111
|
+
except ValueError:
|
|
112
|
+
continue
|
|
113
|
+
if d.get("isMeta") or d.get("isSidechain"):
|
|
114
|
+
continue
|
|
115
|
+
msg = d.get("message") or {}
|
|
116
|
+
content = msg.get("content")
|
|
117
|
+
if isinstance(content, list):
|
|
118
|
+
content = " ".join(b.get("text", "") for b in content
|
|
119
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
120
|
+
if not isinstance(content, str) or not content.strip():
|
|
121
|
+
continue
|
|
122
|
+
if content.lstrip().startswith(("<", "[Request interrupted")):
|
|
123
|
+
continue # tool results and interrupt markers are not prompts
|
|
124
|
+
out.append(content)
|
|
125
|
+
if len(out) >= n:
|
|
126
|
+
break
|
|
127
|
+
return list(reversed(out))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def category_of(texts):
|
|
131
|
+
"""The category this stretch of work is in, by weight of confidence."""
|
|
132
|
+
scores = {}
|
|
133
|
+
for t in texts:
|
|
134
|
+
cat, conf, _ = classify(t)
|
|
135
|
+
scores[cat] = scores.get(cat, 0) + max(conf, 0.1)
|
|
136
|
+
if not scores:
|
|
137
|
+
return "other", 0.0
|
|
138
|
+
cat = max(scores, key=scores.get)
|
|
139
|
+
return cat, round(scores[cat] / sum(scores.values()), 2)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def advise(model=None, transcript=None, prompt=None, ev=None):
|
|
143
|
+
"""What to say about a session that is running right now.
|
|
144
|
+
|
|
145
|
+
model the model the session is on (pricing-table id)
|
|
146
|
+
transcript path to the live JSONL, for reading what it has been doing
|
|
147
|
+
prompt the prompt about to be sent, when we are called from a hook
|
|
148
|
+
Returns None when there is nothing worth saying.
|
|
149
|
+
"""
|
|
150
|
+
ev = evidence() if ev is None else ev
|
|
151
|
+
if not ev:
|
|
152
|
+
return None
|
|
153
|
+
texts = list(recent_prompts(transcript)) if transcript else []
|
|
154
|
+
if prompt:
|
|
155
|
+
# The prompt in hand is what the next turn will cost, so it leads.
|
|
156
|
+
texts = texts[-4:] + [prompt] * 3
|
|
157
|
+
if not texts:
|
|
158
|
+
return None
|
|
159
|
+
cat, conf = category_of(texts)
|
|
160
|
+
row = ev.get(cat)
|
|
161
|
+
if not row:
|
|
162
|
+
return None
|
|
163
|
+
# Only speak when they are on the model the evidence is about. Advising a
|
|
164
|
+
# switch away from a model you already left would be noise.
|
|
165
|
+
if model and row["current"] and model_alias(model) != model_alias(row["current"]):
|
|
166
|
+
return None
|
|
167
|
+
usable = [c for c in row["candidates"]
|
|
168
|
+
if c["verdict"] in ("supported", "caution") and c["savings_pct"] >= MIN_SAVING_PCT]
|
|
169
|
+
if not usable:
|
|
170
|
+
return None
|
|
171
|
+
best = usable[0]
|
|
172
|
+
trial = best["verdict"] == "caution"
|
|
173
|
+
return {
|
|
174
|
+
"category": cat, "confidence": conf,
|
|
175
|
+
"current_model": row["current"], "current_name": row["current_name"],
|
|
176
|
+
"current_cost_per_prompt": row["current_cost_per_prompt"],
|
|
177
|
+
"model": best["model"], "name": best["name"], "alias": model_alias(best["model"]),
|
|
178
|
+
"cost_per_prompt": best["cost_per_prompt"], "savings_pct": best["savings_pct"],
|
|
179
|
+
"turn_ratio": best["turn_ratio"], "prompts": best["prompts"],
|
|
180
|
+
"verdict": best["verdict"], "trial": trial, "why": best["why"],
|
|
181
|
+
"command": f"/model {model_alias(best['model'])}",
|
|
182
|
+
"line": (f"{cat.replace('_', ' ')} on {row['current_name']} "
|
|
183
|
+
f"(${row['current_cost_per_prompt']:.2f}/prompt). Your last {best['prompts']} "
|
|
184
|
+
f"ran on {best['name']} at ${best['cost_per_prompt']:.2f} in "
|
|
185
|
+
f"{best['turn_ratio']}x the turns"
|
|
186
|
+
+ (" — worth a trial here." if trial else ".")),
|
|
187
|
+
"short": f"try {model_alias(best['model'])} (-{best['savings_pct']:.0f}%)",
|
|
188
|
+
}
|
package/finops/analytics.py
CHANGED
|
@@ -886,6 +886,155 @@ class Analytics:
|
|
|
886
886
|
and (provider is None or v.get("provider", "anthropic") == provider)]
|
|
887
887
|
return min(ms, key=lambda m: self.pricing.rates(m).get("output", 1e9)) if ms else None
|
|
888
888
|
|
|
889
|
+
# ---------------- evidence: what the cheaper model actually did ----------------
|
|
890
|
+
# model_switch() reprices your tokens on a cheaper model, which assumes the cheaper
|
|
891
|
+
# model would have done the same work in the same number of turns. Often it would
|
|
892
|
+
# not: a weaker model can take five times the turns on the same task, and the
|
|
893
|
+
# repricing then promises a saving that never arrives.
|
|
894
|
+
#
|
|
895
|
+
# Where you have already run more than one model on the same kind of work, we do not
|
|
896
|
+
# have to assume anything. This compares what each model actually cost per prompt on
|
|
897
|
+
# that category, and how much work it took to get there.
|
|
898
|
+
|
|
899
|
+
MIN_PROMPTS = 8 # below this a per-category average is noise, not evidence
|
|
900
|
+
SAVING_FLOOR_PCT = 20 # smaller gaps are inside the noise of what you happened to ask
|
|
901
|
+
TURN_TOLERANCE = 1.35 # more turns than this and the cheaper model was grinding
|
|
902
|
+
REPEAT_TOLERANCE = 12.0 # percentage points of extra re-asking we will accept
|
|
903
|
+
|
|
904
|
+
def model_evidence(self, f=None):
|
|
905
|
+
"""Back-test a model switch against your own history.
|
|
906
|
+
|
|
907
|
+
For every category where you ran more than one model, report what each one
|
|
908
|
+
actually cost per prompt and what it took: turns, tool calls, and how often you
|
|
909
|
+
had to ask the same thing again. A candidate is only recommended when it was
|
|
910
|
+
genuinely cheaper per prompt *and* did not need materially more work to get
|
|
911
|
+
there — which is the part a repricing cannot see.
|
|
912
|
+
"""
|
|
913
|
+
w, p = self.where(f)
|
|
914
|
+
# The filter is request-scoped, so select the prompts it touches as a subquery.
|
|
915
|
+
# Joining requests directly would repeat each prompt once per request and quietly
|
|
916
|
+
# multiply both the counts and every average by the turn count.
|
|
917
|
+
scope = f"pr.id IN (SELECT r.prompt_id FROM requests r WHERE {w})"
|
|
918
|
+
# Single-model prompts only: a prompt answered by two models cannot be
|
|
919
|
+
# attributed to either, and mixed rows would blur the comparison.
|
|
920
|
+
clean = ("pr.models IS NOT NULL AND pr.models NOT LIKE '%,%' "
|
|
921
|
+
"AND pr.models != '<synthetic>' AND pr.est_cost_usd > 0")
|
|
922
|
+
rows = self.q(f"""SELECT COALESCE(pr.category,'other') category, pr.models model,
|
|
923
|
+
pr.agent agent, COUNT(*) prompts, SUM(pr.est_cost_usd) cost,
|
|
924
|
+
AVG(pr.est_cost_usd) cost_per_prompt,
|
|
925
|
+
AVG(pr.request_count) turns, AVG(pr.tool_calls) tools,
|
|
926
|
+
AVG(pr.output_tokens) out_tokens, AVG(pr.max_context_tokens) ctx
|
|
927
|
+
FROM prompts pr WHERE {scope} AND {clean}
|
|
928
|
+
GROUP BY 1, 2, 3 HAVING prompts >= ?""", p + [self.MIN_PROMPTS])
|
|
929
|
+
|
|
930
|
+
repeats = {(r["category"], r["model"]): r["pct"] for r in self.q(f"""
|
|
931
|
+
SELECT COALESCE(pr.category,'other') category, pr.models model,
|
|
932
|
+
ROUND(100.0 * SUM(CASE WHEN dup.n > 1 THEN 1 ELSE 0 END) / COUNT(*), 1) pct
|
|
933
|
+
FROM prompts pr
|
|
934
|
+
LEFT JOIN (SELECT norm_hash, COUNT(*) n FROM prompts GROUP BY norm_hash) dup
|
|
935
|
+
ON dup.norm_hash = pr.norm_hash
|
|
936
|
+
WHERE {scope} AND {clean}
|
|
937
|
+
GROUP BY 1, 2""", p)}
|
|
938
|
+
|
|
939
|
+
by_cat = defaultdict(list)
|
|
940
|
+
for r in rows:
|
|
941
|
+
r["repeat_pct"] = repeats.get((r["category"], r["model"]), 0.0) or 0.0
|
|
942
|
+
r["name"] = self.pricing.display_name(r["model"])
|
|
943
|
+
r["tier"] = self.pricing.tier(r["model"])
|
|
944
|
+
by_cat[r["category"]].append(r)
|
|
945
|
+
|
|
946
|
+
out, total_save = [], 0.0
|
|
947
|
+
for cat, models in by_cat.items():
|
|
948
|
+
if len(models) < 2:
|
|
949
|
+
continue
|
|
950
|
+
# The incumbent is what you spend the most on here — that is the bill a
|
|
951
|
+
# switch would actually change.
|
|
952
|
+
cur = max(models, key=lambda m: m["cost"])
|
|
953
|
+
rule = self.SWITCH_RULES.get(cat, ("balanced", "low"))[0]
|
|
954
|
+
cands = []
|
|
955
|
+
for m in models:
|
|
956
|
+
if m["model"] == cur["model"] or m["cost_per_prompt"] >= cur["cost_per_prompt"]:
|
|
957
|
+
continue
|
|
958
|
+
# Only models the same agent can run. Telling a Claude Code user to use a
|
|
959
|
+
# GPT model is not a setting change, it is a different tool, and the
|
|
960
|
+
# comparison would be between two different ways of working.
|
|
961
|
+
if m["agent"] != cur["agent"]:
|
|
962
|
+
continue
|
|
963
|
+
save_pct = 100.0 * (1 - m["cost_per_prompt"] / cur["cost_per_prompt"])
|
|
964
|
+
turn_ratio = (m["turns"] / cur["turns"]) if cur["turns"] else 1.0
|
|
965
|
+
repeat_delta = m["repeat_pct"] - cur["repeat_pct"]
|
|
966
|
+
# Observed, not repriced: what your own prompts cost on each side.
|
|
967
|
+
save = (cur["cost_per_prompt"] - m["cost_per_prompt"]) * cur["prompts"]
|
|
968
|
+
if save_pct < self.SAVING_FLOOR_PCT:
|
|
969
|
+
verdict, why = "marginal", (
|
|
970
|
+
f"Only {save_pct:.0f}% cheaper per prompt — inside the noise of what "
|
|
971
|
+
f"you happened to ask each model.")
|
|
972
|
+
elif turn_ratio > self.TURN_TOLERANCE:
|
|
973
|
+
verdict, why = "risky", (
|
|
974
|
+
f"Cost {save_pct:.0f}% less per prompt but took {turn_ratio:.1f}x the "
|
|
975
|
+
f"turns ({m['turns']:.0f} vs {cur['turns']:.0f}). It got there by "
|
|
976
|
+
f"grinding, and that is the cost the headline number misses.")
|
|
977
|
+
elif repeat_delta > self.REPEAT_TOLERANCE:
|
|
978
|
+
verdict, why = "risky", (
|
|
979
|
+
f"{save_pct:.0f}% cheaper per prompt, but you re-asked "
|
|
980
|
+
f"{m['repeat_pct']:.0f}% of these prompts against "
|
|
981
|
+
f"{cur['repeat_pct']:.0f}% on {cur['name']} — rework you paid for twice.")
|
|
982
|
+
elif rule == "keep":
|
|
983
|
+
verdict, why = "caution", (
|
|
984
|
+
f"{save_pct:.0f}% cheaper per prompt and no more turns, but {cat.replace('_',' ')} "
|
|
985
|
+
f"is reasoning-heavy work where a miss is expensive in ways this data "
|
|
986
|
+
f"cannot show. Worth a trial, not a default.")
|
|
987
|
+
else:
|
|
988
|
+
verdict, why = "supported", (
|
|
989
|
+
f"{save_pct:.0f}% cheaper per prompt on {m['prompts']} of your own "
|
|
990
|
+
f"{cat.replace('_',' ')} prompts, in {turn_ratio:.1f}x the turns "
|
|
991
|
+
f"({m['turns']:.0f} vs {cur['turns']:.0f}) with "
|
|
992
|
+
f"{'less' if repeat_delta <= 0 else 'similar'} re-asking. "
|
|
993
|
+
f"This is measured, not modelled.")
|
|
994
|
+
cands.append({
|
|
995
|
+
"model": m["model"], "name": m["name"], "tier": m["tier"],
|
|
996
|
+
"prompts": m["prompts"], "cost_per_prompt": m["cost_per_prompt"],
|
|
997
|
+
"turns": m["turns"], "tools": m["tools"], "repeat_pct": m["repeat_pct"],
|
|
998
|
+
"savings_pct": round(save_pct, 1), "turn_ratio": round(turn_ratio, 2),
|
|
999
|
+
"repeat_delta": round(repeat_delta, 1),
|
|
1000
|
+
"estimated_savings_usd": round(save, 2), "verdict": verdict, "why": why})
|
|
1001
|
+
if not cands:
|
|
1002
|
+
continue
|
|
1003
|
+
cands.sort(key=lambda c: (c["verdict"] != "supported", -c["estimated_savings_usd"]))
|
|
1004
|
+
best = cands[0] if cands[0]["verdict"] == "supported" else None
|
|
1005
|
+
if best:
|
|
1006
|
+
total_save += best["estimated_savings_usd"]
|
|
1007
|
+
out.append({
|
|
1008
|
+
"category": cat,
|
|
1009
|
+
"agent": cur["agent"],
|
|
1010
|
+
"current": {"model": cur["model"], "name": cur["name"], "prompts": cur["prompts"],
|
|
1011
|
+
"cost": cur["cost"], "cost_per_prompt": cur["cost_per_prompt"],
|
|
1012
|
+
"turns": cur["turns"], "tools": cur["tools"],
|
|
1013
|
+
"repeat_pct": cur["repeat_pct"]},
|
|
1014
|
+
"candidates": cands,
|
|
1015
|
+
"recommended": best["model"] if best else None,
|
|
1016
|
+
"recommended_name": best["name"] if best else None,
|
|
1017
|
+
"estimated_savings_usd": best["estimated_savings_usd"] if best else 0.0,
|
|
1018
|
+
"verdict": best["verdict"] if best else cands[0]["verdict"],
|
|
1019
|
+
"why": best["why"] if best else cands[0]["why"],
|
|
1020
|
+
"rule": rule,
|
|
1021
|
+
})
|
|
1022
|
+
out.sort(key=lambda c: -c["estimated_savings_usd"])
|
|
1023
|
+
return {
|
|
1024
|
+
"categories": out,
|
|
1025
|
+
"estimated_savings_usd": round(total_save, 2),
|
|
1026
|
+
"min_prompts": self.MIN_PROMPTS,
|
|
1027
|
+
"basis": "actual",
|
|
1028
|
+
"method": (f"Compares what each model actually cost per prompt on the same category "
|
|
1029
|
+
f"of work, using only categories where you ran both with at least "
|
|
1030
|
+
f"{self.MIN_PROMPTS} prompts each. Turns and re-asked prompts are shown "
|
|
1031
|
+
f"because a cheaper model that needs more of both is not cheaper. "
|
|
1032
|
+
f"Nothing here is repriced or modelled."),
|
|
1033
|
+
"caveat": ("Your prompts were not randomly assigned to models, so a category can "
|
|
1034
|
+
"differ in difficulty between them. Treat this as strong evidence for a "
|
|
1035
|
+
"trial, not proof."),
|
|
1036
|
+
}
|
|
1037
|
+
|
|
889
1038
|
def model_switch(self, f=None):
|
|
890
1039
|
"""Per-request what-if: reprice each request on the model its work needs.
|
|
891
1040
|
|
package/finops/api.py
CHANGED
|
@@ -292,6 +292,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
292
292
|
return self.send_json(a.context_analysis(f))
|
|
293
293
|
if route == "waste":
|
|
294
294
|
return self.send_json(a.waste(f))
|
|
295
|
+
if route == "model_evidence":
|
|
296
|
+
return self.send_json(a.model_evidence(f))
|
|
295
297
|
if route == "model_switch":
|
|
296
298
|
return self.send_json(a.model_switch(f))
|
|
297
299
|
if route == "recommendations":
|
|
@@ -321,6 +323,17 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
321
323
|
others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
|
|
322
324
|
if not want or any(x != "claude" for x in want) else []
|
|
323
325
|
rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
|
|
326
|
+
# Live model advice, while the session can still act on it. One cached
|
|
327
|
+
# evidence read for the whole list, and never fatal to the view.
|
|
328
|
+
try:
|
|
329
|
+
from .advisor import advise, evidence
|
|
330
|
+
ev = evidence()
|
|
331
|
+
for r in rows:
|
|
332
|
+
r["advice"] = advise(model=r.get("model"), transcript=r.get("transcript"),
|
|
333
|
+
ev=ev) if ev else None
|
|
334
|
+
except Exception:
|
|
335
|
+
for r in rows:
|
|
336
|
+
r.setdefault("advice", None)
|
|
324
337
|
return self.send_json({"sessions": rows})
|
|
325
338
|
if route == "cloud":
|
|
326
339
|
from .cloud import report
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Live advice inside Claude Code itself: a prompt hook and a statusline.
|
|
2
|
+
|
|
3
|
+
The dashboard can only advise you if you are looking at it. These two run where
|
|
4
|
+
the decision is actually made — the terminal you are typing in.
|
|
5
|
+
|
|
6
|
+
claude-finops --hook reads a UserPromptSubmit payload on stdin
|
|
7
|
+
claude-finops --statusline reads a statusline payload on stdin
|
|
8
|
+
claude-finops --install-hook / --install-statusline wire them into settings
|
|
9
|
+
|
|
10
|
+
Both are on the path of every prompt, so both are built to be boring: fail
|
|
11
|
+
silent, never block, never take long. A hook that erases someone's prompt
|
|
12
|
+
because a database was locked is a far worse bug than missing advice, so the
|
|
13
|
+
hook never returns a blocking exit code — it exits 0 whatever happens.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
SETTINGS = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _stdin_json():
|
|
24
|
+
try:
|
|
25
|
+
raw = sys.stdin.read()
|
|
26
|
+
return json.loads(raw) if raw.strip() else {}
|
|
27
|
+
except (ValueError, OSError):
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _dig(d, *paths, default=None):
|
|
32
|
+
"""First present value among dotted paths.
|
|
33
|
+
|
|
34
|
+
The payload shapes are not identical across Claude Code versions, and a
|
|
35
|
+
statusline that breaks on upgrade is worse than one that misses a field, so
|
|
36
|
+
every read tries the spellings we know of.
|
|
37
|
+
"""
|
|
38
|
+
for path in paths:
|
|
39
|
+
cur = d
|
|
40
|
+
for part in path.split("."):
|
|
41
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
42
|
+
cur = None
|
|
43
|
+
break
|
|
44
|
+
cur = cur[part]
|
|
45
|
+
if cur not in (None, ""):
|
|
46
|
+
return cur
|
|
47
|
+
return default
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _payload_common(d):
|
|
51
|
+
return (_dig(d, "model", "session.model", "model.id", "model.display_name"),
|
|
52
|
+
_dig(d, "transcript_path", "session.transcript_path", "transcriptPath"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# -------------------------------------------------------------------- hook ----
|
|
56
|
+
|
|
57
|
+
def hook():
|
|
58
|
+
"""UserPromptSubmit: judge the prompt in hand, before it is paid for.
|
|
59
|
+
|
|
60
|
+
Advice goes to the user as `systemMessage`, not into Claude's context: it is
|
|
61
|
+
for the person deciding which model to use, and feeding it to the model
|
|
62
|
+
would just spend tokens telling it about its own price.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
d = _stdin_json()
|
|
66
|
+
prompt = _dig(d, "user_prompt", "prompt", default="")
|
|
67
|
+
model, transcript = _payload_common(d)
|
|
68
|
+
from .advisor import advise
|
|
69
|
+
a = advise(model=model, transcript=transcript, prompt=prompt)
|
|
70
|
+
if a:
|
|
71
|
+
print(json.dumps({"systemMessage": f"finops: {a['line']} → {a['command']}"}))
|
|
72
|
+
except Exception:
|
|
73
|
+
pass # never let advice interfere with the prompt
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# -------------------------------------------------------------- statusline ----
|
|
78
|
+
|
|
79
|
+
def statusline():
|
|
80
|
+
"""One line, refreshed constantly, so: what you are on, and what to try."""
|
|
81
|
+
try:
|
|
82
|
+
d = _stdin_json()
|
|
83
|
+
model, transcript = _payload_common(d)
|
|
84
|
+
pct = _dig(d, "context.percentUsed", "context.percent_used")
|
|
85
|
+
name = _dig(d, "model.display_name", "session.model", "model") or "claude"
|
|
86
|
+
bits = [str(name)]
|
|
87
|
+
if isinstance(pct, (int, float)):
|
|
88
|
+
bits.append(f"{pct:.0f}% ctx")
|
|
89
|
+
from .advisor import advise
|
|
90
|
+
a = advise(model=model, transcript=transcript)
|
|
91
|
+
if a:
|
|
92
|
+
bits.append(a["short"])
|
|
93
|
+
print(" · ".join(bits))
|
|
94
|
+
except Exception:
|
|
95
|
+
print("") # an empty statusline beats a stack trace under the prompt
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --------------------------------------------------------------- installing ----
|
|
100
|
+
|
|
101
|
+
def _command():
|
|
102
|
+
"""How to invoke us from settings: the installed CLI if it is on PATH."""
|
|
103
|
+
exe = shutil.which("claude-finops")
|
|
104
|
+
if exe:
|
|
105
|
+
return exe
|
|
106
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
107
|
+
return f"{sys.executable} {os.path.join(root, 'run.py')}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_settings():
|
|
111
|
+
try:
|
|
112
|
+
with open(SETTINGS) as fh:
|
|
113
|
+
return json.load(fh)
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _save_settings(data):
|
|
119
|
+
os.makedirs(os.path.dirname(SETTINGS), exist_ok=True)
|
|
120
|
+
if os.path.exists(SETTINGS):
|
|
121
|
+
# Their settings file is not ours to lose.
|
|
122
|
+
shutil.copy2(SETTINGS, SETTINGS + ".finops-backup")
|
|
123
|
+
with open(SETTINGS, "w") as fh:
|
|
124
|
+
json.dump(data, fh, indent=2)
|
|
125
|
+
fh.write("\n")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def install_hook(remove=False):
|
|
129
|
+
cmd = f"{_command()} --hook"
|
|
130
|
+
s = _load_settings()
|
|
131
|
+
hooks = s.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
|
|
132
|
+
for group in hooks: # drop any earlier copy of ours
|
|
133
|
+
group["hooks"] = [h for h in group.get("hooks", [])
|
|
134
|
+
if "--hook" not in str(h.get("command", ""))
|
|
135
|
+
or "finops" not in str(h.get("command", ""))]
|
|
136
|
+
hooks[:] = [g for g in hooks if g.get("hooks")]
|
|
137
|
+
if not remove:
|
|
138
|
+
hooks.append({"matcher": "", "hooks": [{"type": "command", "command": cmd,
|
|
139
|
+
"timeout": 10}]})
|
|
140
|
+
if not hooks:
|
|
141
|
+
s["hooks"].pop("UserPromptSubmit", None)
|
|
142
|
+
if not s["hooks"]:
|
|
143
|
+
s.pop("hooks")
|
|
144
|
+
_save_settings(s)
|
|
145
|
+
print(("Removed" if remove else "Installed") + f" the prompt hook in {SETTINGS}")
|
|
146
|
+
if not remove:
|
|
147
|
+
print(" It suggests a cheaper model when your own history backs one, before the turn runs.")
|
|
148
|
+
print(" Start a new Claude Code session to pick it up. Undo: claude-finops --uninstall-hook")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def install_statusline(remove=False):
|
|
152
|
+
cmd = f"{_command()} --statusline"
|
|
153
|
+
s = _load_settings()
|
|
154
|
+
if remove:
|
|
155
|
+
if "finops" in str(s.get("statusLine", "")):
|
|
156
|
+
s.pop("statusLine", None)
|
|
157
|
+
else:
|
|
158
|
+
prev = s.get("statusLine")
|
|
159
|
+
if prev and "finops" not in str(prev):
|
|
160
|
+
print(f"You already have a statusLine configured:\n {prev}")
|
|
161
|
+
print("Leaving it alone. Remove it first if you want ours.")
|
|
162
|
+
return
|
|
163
|
+
s["statusLine"] = cmd
|
|
164
|
+
_save_settings(s)
|
|
165
|
+
print(("Removed" if remove else "Installed") + f" the statusline in {SETTINGS}")
|
|
166
|
+
if not remove:
|
|
167
|
+
print(" Shows the model, context pressure, and a cheaper model when one is warranted.")
|
|
168
|
+
print(" Undo: claude-finops --uninstall-statusline")
|
package/finops/procs.py
CHANGED
|
@@ -85,6 +85,7 @@ def _transcript_stats(session_id, pricing):
|
|
|
85
85
|
steps = tokens = 0
|
|
86
86
|
ctx = None
|
|
87
87
|
cost = 0.0
|
|
88
|
+
model = None # the model of the most recent assistant turn: what you are on now
|
|
88
89
|
with open(fp, errors="replace") as fh:
|
|
89
90
|
for line in fh:
|
|
90
91
|
if '"usage"' not in line:
|
|
@@ -108,7 +109,8 @@ def _transcript_stats(session_id, pricing):
|
|
|
108
109
|
if not (c5 or c1):
|
|
109
110
|
c5 = cw
|
|
110
111
|
cost += pricing.estimate(m.get("model"), inp, out, cr, c5, c1)
|
|
111
|
-
|
|
112
|
+
model = m.get("model") or model
|
|
113
|
+
return {"transcript": fp, "steps": steps, "tokens": tokens, "context": ctx, "model": model,
|
|
112
114
|
"est_cost_usd": cost, "last_write_s": time.time() - os.path.getmtime(fp)}
|
|
113
115
|
|
|
114
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
package/run.py
CHANGED
|
@@ -171,6 +171,9 @@ HELP = """Claude FinOps Command Center
|
|
|
171
171
|
claude-finops --keys list which provider keys are configured
|
|
172
172
|
claude-finops --share write ../claude-finops.zip (code only, never your data)
|
|
173
173
|
claude-finops --version print the installed version, and whether a newer one is out
|
|
174
|
+
claude-finops --advise what to switch to in the sessions running right now
|
|
175
|
+
claude-finops --install-hook suggest a cheaper model in Claude Code, as you send each prompt
|
|
176
|
+
claude-finops --install-statusline show model, context and advice in your statusline
|
|
174
177
|
claude-finops --help this message
|
|
175
178
|
|
|
176
179
|
Environment:
|
|
@@ -182,6 +185,28 @@ Environment:
|
|
|
182
185
|
"""
|
|
183
186
|
|
|
184
187
|
|
|
188
|
+
def advise_now():
|
|
189
|
+
"""What every session running right now should consider switching to."""
|
|
190
|
+
from finops.advisor import advise, evidence
|
|
191
|
+
from finops.procs import list_sessions
|
|
192
|
+
from finops.analytics import Analytics
|
|
193
|
+
ev = evidence()
|
|
194
|
+
if not ev:
|
|
195
|
+
return print("No model evidence yet — you need two models run on the same kind of "
|
|
196
|
+
"work before there is anything to compare.")
|
|
197
|
+
rows = list_sessions(Analytics(DB_PATH).pricing)
|
|
198
|
+
if not rows:
|
|
199
|
+
return print("No Claude Code sessions are running.")
|
|
200
|
+
for r in rows:
|
|
201
|
+
a = advise(model=r.get("model"), transcript=r.get("transcript"), ev=ev)
|
|
202
|
+
head = f" {r.get('project') or r.get('session_id') or 'session'}"
|
|
203
|
+
if a:
|
|
204
|
+
print(f"{head}: {a['line']}")
|
|
205
|
+
print(f"{' ' * len(head)} run {a['command']}")
|
|
206
|
+
else:
|
|
207
|
+
print(f"{head}: nothing to change.")
|
|
208
|
+
|
|
209
|
+
|
|
185
210
|
def _version():
|
|
186
211
|
from finops.update import _installed
|
|
187
212
|
return _installed()
|
|
@@ -292,6 +317,23 @@ def main():
|
|
|
292
317
|
return print(HELP)
|
|
293
318
|
if "--version" in args or "-v" in args or "-V" in args:
|
|
294
319
|
return version()
|
|
320
|
+
# Integration entry points. --hook and --statusline are invoked by Claude Code
|
|
321
|
+
# with a JSON payload on stdin, many times a session; they print one line and
|
|
322
|
+
# never fail loudly.
|
|
323
|
+
if "--hook" in args:
|
|
324
|
+
from finops.integrate import hook
|
|
325
|
+
return sys.exit(hook())
|
|
326
|
+
if "--statusline" in args:
|
|
327
|
+
from finops.integrate import statusline
|
|
328
|
+
return sys.exit(statusline())
|
|
329
|
+
if "--install-hook" in args or "--uninstall-hook" in args:
|
|
330
|
+
from finops.integrate import install_hook
|
|
331
|
+
return install_hook(remove="--uninstall-hook" in args)
|
|
332
|
+
if "--install-statusline" in args or "--uninstall-statusline" in args:
|
|
333
|
+
from finops.integrate import install_statusline
|
|
334
|
+
return install_statusline(remove="--uninstall-statusline" in args)
|
|
335
|
+
if "--advise" in args:
|
|
336
|
+
return advise_now()
|
|
295
337
|
if "--where" in args:
|
|
296
338
|
return where()
|
|
297
339
|
if "--keys" in args:
|
package/web/app.js
CHANGED
|
@@ -1322,12 +1322,53 @@ VIEWS.context = async (page) => {
|
|
|
1322
1322
|
};
|
|
1323
1323
|
|
|
1324
1324
|
/* ---------- model switch ---------- */
|
|
1325
|
+
// Verdicts from the back-test. Wording matters here: "supported" means your own
|
|
1326
|
+
// history backs the switch, not that we modelled it.
|
|
1327
|
+
const VERDICT = {
|
|
1328
|
+
supported: ['🟢', 'Backed by your data', 'healthy'],
|
|
1329
|
+
caution: ['🟠', 'Trial first', 'approaching'],
|
|
1330
|
+
risky: ['🔴', 'Cost more work', 'critical'],
|
|
1331
|
+
marginal: ['⚪', 'Too close to call', 'high'],
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
function evidenceBody(ev) {
|
|
1335
|
+
if (!ev || !ev.categories?.length) {
|
|
1336
|
+
return `<div class="empty">No category yet has ${ev?.min_prompts || 8}+ prompts on two
|
|
1337
|
+
different models of the same agent, so there is nothing to compare. Run a cheaper model on
|
|
1338
|
+
a handful of real tasks and this fills in.</div>`;
|
|
1339
|
+
}
|
|
1340
|
+
const rows = [];
|
|
1341
|
+
ev.categories.forEach(c => c.candidates.forEach((x, i) => rows.push({c, x, first: i === 0})));
|
|
1342
|
+
return table([
|
|
1343
|
+
{h: 'Work', f: r => r.first ? `<b>${esc(r.c.category.replace('_', ' '))}</b>` : ''},
|
|
1344
|
+
{h: 'You use now', f: r => r.first
|
|
1345
|
+
? `${esc(r.c.current.name)} <span class="note">${fmtUSD(r.c.current.cost_per_prompt)}/prompt ·
|
|
1346
|
+
${Math.round(r.c.current.turns)} turns</span>` : ''},
|
|
1347
|
+
{h: 'Instead of', f: r => `<b>${esc(r.x.name)}</b>`},
|
|
1348
|
+
{h: '$ / prompt', num: 1, f: r => fmtUSD(r.x.cost_per_prompt)},
|
|
1349
|
+
{h: 'Turns', num: 1, f: r => `${Math.round(r.x.turns)} <span class="note">(${r.x.turn_ratio}×)</span>`},
|
|
1350
|
+
{h: 'Re-asked', num: 1, f: r => `${fmtPct(r.x.repeat_pct)}<span class="note">${
|
|
1351
|
+
r.x.repeat_delta > 0 ? ' +' + r.x.repeat_delta : ''}</span>`},
|
|
1352
|
+
{h: 'On', num: 1, f: r => `${fmtInt(r.x.prompts)} prompts`},
|
|
1353
|
+
{h: 'Verdict', f: r => `<span title="${esc(r.x.why)}">${
|
|
1354
|
+
statusChip(VERDICT[r.x.verdict][2], VERDICT[r.x.verdict][1])}</span>`},
|
|
1355
|
+
{h: 'Would save', num: 1, f: r => r.x.verdict === 'supported'
|
|
1356
|
+
? `<b>${fmtUSD(r.x.estimated_savings_usd)}</b>` : `<span class="note">${fmtUSD(r.x.estimated_savings_usd)}</span>`},
|
|
1357
|
+
], rows) + `<div class="note" style="padding:10px 14px">${esc(ev.method)}</div>`;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1325
1360
|
VIEWS.modelswitch = async (page) => {
|
|
1326
|
-
const m = await
|
|
1361
|
+
const [m, ev] = await Promise.all([
|
|
1362
|
+
api('model_switch'),
|
|
1363
|
+
api('model_evidence').catch(() => null),
|
|
1364
|
+
]);
|
|
1327
1365
|
const sc = m.savings_by_confidence || {};
|
|
1328
1366
|
const conf = c => `<span class="badge rec">${esc(c)} confidence</span>`;
|
|
1329
1367
|
page.innerHTML = `
|
|
1330
1368
|
<div class="grid g4">
|
|
1369
|
+
${kpi('Backed by your own runs', fmtUSD(ev?.estimated_savings_usd || 0),
|
|
1370
|
+
`${ev?.categories?.filter(c => c.recommended).length || 0} categories where a cheaper model
|
|
1371
|
+
already did the same work for less`, {badge: BADGE.actual})}
|
|
1331
1372
|
${kpi('Potential savings', fmtUSD(m.estimated_savings_usd),
|
|
1332
1373
|
`${fmtPct(m.total_cost_usd ? 100 * m.estimated_savings_usd / m.total_cost_usd : 0)} of ${fmtUSD(m.total_cost_usd)} in range`,
|
|
1333
1374
|
{badge: BADGE.recommendation})}
|
|
@@ -1336,7 +1377,15 @@ VIEWS.modelswitch = async (page) => {
|
|
|
1336
1377
|
${kpi('Stays on current model', fmtUSD(m.blocked_by_context_usd),
|
|
1337
1378
|
`${fmtInt(m.blocked_by_context_requests)} requests too big for the cheaper model's context`, {badge: BADGE.estimated})}
|
|
1338
1379
|
</div>
|
|
1339
|
-
${card('
|
|
1380
|
+
${card('What actually happened when you used a cheaper model',
|
|
1381
|
+
`<div id="ms-ev">${evidenceBody(ev)}</div>`, {badge: BADGE.actual, flush: 1,
|
|
1382
|
+
hint: 'Measured from your own prompts — no repricing, no assumptions about tokens',
|
|
1383
|
+
footer: ev ? esc(ev.caveat) : ''})}
|
|
1384
|
+
<div class="note">The table above is history; the one below is a model. Where they disagree,
|
|
1385
|
+
believe the history: repricing assumes the cheaper model would finish in the same number of
|
|
1386
|
+
turns, and your data shows that is often where the saving goes.</div>
|
|
1387
|
+
${card('Switch these (repriced, not measured)', `<div id="ms-sw"></div>`,
|
|
1388
|
+
{badge: BADGE.recommendation, flush: 1,
|
|
1340
1389
|
hint: 'Each request repriced on the model its work needs, same tokens',
|
|
1341
1390
|
footer: esc(m.caveat)})}
|
|
1342
1391
|
${card('Default model per project', `<div id="ms-pj"></div>`, {badge: BADGE.recommendation, flush: 1,
|
|
@@ -1595,6 +1644,10 @@ VIEWS.live = async (page) => {
|
|
|
1595
1644
|
<div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` · up ${esc(x.uptime)}` : ''} ·
|
|
1596
1645
|
last activity ${x.last_write_s == null ? '—' : dur(x.last_write_s)} ago${x.memory_mb == null ? '' : ` · ${fmtInt(x.memory_mb)} MB`} ·
|
|
1597
1646
|
<span class="note">${esc(x.cwd)}</span></div>
|
|
1647
|
+
${x.advice ? `<div class="dt switch-tip"><b>${x.advice.trial ? 'Worth trying' : 'Cheaper model'}:</b>
|
|
1648
|
+
${esc(x.advice.line)}
|
|
1649
|
+
<button class="act ghost" data-copy="${esc(x.advice.command)}"
|
|
1650
|
+
title="${esc(x.advice.why)}">Copy ${esc(x.advice.command)}</button></div>` : ''}
|
|
1598
1651
|
${x.severity !== 'ok' ? `<div class="dt"><b>Advice:</b> ${x.severity === 'high'
|
|
1599
1652
|
? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
|
|
1600
1653
|
: 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
|
package/web/styles.css
CHANGED
|
@@ -367,6 +367,9 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
367
367
|
.live-msg.ok { color: var(--good-ink); } .live-msg.err { color: var(--critical-ink); }
|
|
368
368
|
.item.gone { opacity: .45; }
|
|
369
369
|
|
|
370
|
+
/* An explicit display beats the hidden attribute, so the panel has to opt back
|
|
371
|
+
out or it is permanently open. */
|
|
372
|
+
.handover[hidden] { display: none; }
|
|
370
373
|
.handover { margin-top: 8px; padding: 10px; border: 1px dashed var(--border); border-radius: 8px; display: grid; gap: 8px; }
|
|
371
374
|
.handover textarea { width: 100%; box-sizing: border-box; font: inherit; font-size: 12.5px; padding: 8px; border-radius: 6px;
|
|
372
375
|
border: 1px solid var(--border); background: transparent; color: inherit; resize: vertical; }
|
|
@@ -417,3 +420,8 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
417
420
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
418
421
|
.who .em, .who .pl { font-size:10px; color:var(--muted); line-height:1.35;
|
|
419
422
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
423
|
+
|
|
424
|
+
/* Live model advice on a running session: it is an opportunity, not a problem,
|
|
425
|
+
so it reads as a note with an accent edge rather than a warning. */
|
|
426
|
+
.switch-tip { border-left: 2px solid var(--accent, #eb6834); padding-left: 9px; }
|
|
427
|
+
.switch-tip .act { margin-left: 8px; }
|