claude-finops 0.17.0 → 0.18.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 +38 -22
- package/finops/analytics.py +25 -2
- package/finops/api.py +8 -1
- package/finops/etl.py +37 -0
- package/package.json +1 -1
- package/web/app.js +142 -18
- package/web/styles.css +6 -0
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@ It answers, in a few clicks:
|
|
|
10
10
|
|
|
11
11
|
Everything runs on `127.0.0.1` with the Python standard library. No dependencies, and
|
|
12
12
|
no data leaves the machine. No outbound calls except a once-a-day version check
|
|
13
|
-
against the npm registry (disable with `--no-update-check`), and
|
|
14
|
-
if you add
|
|
13
|
+
against the npm registry (disable with `--no-update-check`), and Anthropic's cost API
|
|
14
|
+
only if you add an Admin key.
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
npx claude-finops
|
|
@@ -85,8 +85,8 @@ no reason — each with the estimated money attached.
|
|
|
85
85
|
|
|
86
86
|

|
|
87
87
|
|
|
88
|
-
**Whether the model you are on is the right one.** Per-model cost and efficiency,
|
|
89
|
-
|
|
88
|
+
**Whether the model you are on is the right one.** Per-model cost and efficiency, and
|
|
89
|
+
what each model really cost on the same kind of work, measured rather than repriced.
|
|
90
90
|
|
|
91
91
|

|
|
92
92
|
|
|
@@ -108,7 +108,7 @@ set.
|
|
|
108
108
|
or estimated.
|
|
109
109
|
- **Your data stays put.** It binds to `127.0.0.1`. No outbound calls except a
|
|
110
110
|
once-a-day version check against the npm registry (disable with
|
|
111
|
-
`--no-update-check`), and
|
|
111
|
+
`--no-update-check`), and Anthropic's cost API only if you add an Admin key. The warehouse lives
|
|
112
112
|
in `~/.claude-finops`, so upgrading or deleting the package never touches it.
|
|
113
113
|
- **It tells you what to change**, not just what happened — usually with a prompt you
|
|
114
114
|
can paste into Claude Code.
|
|
@@ -135,7 +135,6 @@ rather than guessing:
|
|
|
135
135
|
- Plan tier, allowance, and remaining credits
|
|
136
136
|
- Message / request allowances
|
|
137
137
|
- Billed invoice amounts
|
|
138
|
-
- Assistant response text (only usage metadata is extracted)
|
|
139
138
|
- Lines changed, commits, pull requests, bugs fixed
|
|
140
139
|
|
|
141
140
|
Wherever one of these would appear, you get **"Unavailable from connected Claude
|
|
@@ -149,7 +148,7 @@ labelled as your own configured figures.
|
|
|
149
148
|
|
|
150
149
|
**Command center** — Executive overview (spend, tokens, usage %, remaining, forecast,
|
|
151
150
|
today vs yesterday) opening with the ⚡ Act now strip, running sessions you can
|
|
152
|
-
interrupt, compact, hand over or close, an AI FinOps Advisor that answers "what should I do today?" from live data, and a
|
|
151
|
+
interrupt, compact, hand over, rename or close, an AI FinOps Advisor that answers "what should I do today?" from live data, and a
|
|
153
152
|
0–100 FinOps scorecard with per-dimension reasoning.
|
|
154
153
|
|
|
155
154
|
**Usage** — Interactive timeline across 10 metrics and 6 time ranges with day
|
|
@@ -167,8 +166,9 @@ latency and efficiency; five leaderboards; prompt intelligence (spend by activit
|
|
|
167
166
|
and a Claude Code view (tools, files, branches, cost per repository).
|
|
168
167
|
|
|
169
168
|
**Optimize** — A waste detector with seven rules, each showing the exact prompts or
|
|
170
|
-
sessions it flagged;
|
|
171
|
-
anomaly detection you can click through
|
|
169
|
+
sessions it flagged; Subagent models, measured per subagent type; a recommendation
|
|
170
|
+
engine that stays quiet without evidence; and anomaly detection you can click through
|
|
171
|
+
to the underlying sessions.
|
|
172
172
|
|
|
173
173
|
**Plan** — Forecast with conservative/expected/high scenario bands, and budgets with
|
|
174
174
|
Budget → Actual → Forecast → Variance and configurable alert thresholds.
|
|
@@ -177,6 +177,11 @@ Global search spans prompt text, sessions, projects, models, tools and dates. Gl
|
|
|
177
177
|
filters (date, model, project, category, cost/token thresholds, sandbox toggle) update
|
|
178
178
|
every chart and KPI. Everything exports to CSV/JSON, plus a printable PDF report.
|
|
179
179
|
|
|
180
|
+
Every page, and the side panel when it shows a session or a prompt, has its own URL, so a
|
|
181
|
+
refresh keeps you where you were and Back/Forward work. Next to the version, the header
|
|
182
|
+
says whether the dashboard runs from the npm install or from a source checkout (with its
|
|
183
|
+
commit).
|
|
184
|
+
|
|
180
185
|
---
|
|
181
186
|
|
|
182
187
|
## Configuration
|
|
@@ -187,9 +192,10 @@ Two files, both editable without touching code. The server picks up changes to
|
|
|
187
192
|
### `config/pricing.json`
|
|
188
193
|
|
|
189
194
|
Model prices per million tokens, kept strictly separate from usage data so the table
|
|
190
|
-
can be updated as prices change. A `claude-*` model with no entry is priced
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
can be updated as prices change. A new `claude-*` model with no entry yet is priced
|
|
196
|
+
like the newest listed model of its family and marked as such, instead of showing $0.
|
|
197
|
+
A model that isn't Claude (such as a free local model behind a `claude-qwen` launcher)
|
|
198
|
+
costs $0.
|
|
193
199
|
|
|
194
200
|
### `config/settings.json`
|
|
195
201
|
|
|
@@ -320,8 +326,8 @@ claude-finops start the dashboard
|
|
|
320
326
|
claude-finops --rebuild re-read transcripts, then start
|
|
321
327
|
claude-finops --stop stop it
|
|
322
328
|
claude-finops --where where your data, settings and keys live
|
|
323
|
-
claude-finops --set-key store
|
|
324
|
-
claude-finops --keys
|
|
329
|
+
claude-finops --set-key store the Anthropic Admin API key (hidden prompt, 0600)
|
|
330
|
+
claude-finops --keys whether that key is configured
|
|
325
331
|
claude-finops --install-statusline model, context % and 5-hour limit under every prompt
|
|
326
332
|
claude-finops --uninstall-statusline remove it again
|
|
327
333
|
claude-finops --install-guard warn near, and ask at, your per-session token budget
|
|
@@ -344,7 +350,7 @@ Everything the app writes lives outside the install folder, in one state directo
|
|
|
344
350
|
data/cloud_cache.json cached billing figures
|
|
345
351
|
data/server.pid|.log running server
|
|
346
352
|
settings.local.json budgets and limits set in the UI
|
|
347
|
-
secrets.local.json
|
|
353
|
+
secrets.local.json Anthropic Admin API key (0600)
|
|
348
354
|
```
|
|
349
355
|
|
|
350
356
|
Days, "today" and the billing period follow this computer's time zone (Claude Code
|
|
@@ -380,17 +386,22 @@ if the Claude desktop app is installed, its Cowork transcripts and
|
|
|
380
386
|
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
381
387
|
generate as sensitive.
|
|
382
388
|
|
|
383
|
-
|
|
389
|
+
Claude's replies are **not** stored in the database. A prompt's detail panel reads the reply
|
|
390
|
+
straight from that session's transcript in `~/.claude/projects` when you open it, and says so
|
|
391
|
+
if the transcript has been deleted.
|
|
392
|
+
|
|
393
|
+
**Hide prompts** (the eye icon in the top bar) hides prompt text, Claude's replies and session titles as dots,
|
|
384
394
|
for when you share your screen. Session titles fall back to their short id. The
|
|
385
|
-
setting is remembered in that browser.
|
|
395
|
+
setting is remembered in that browser. The side panel has the same button, and using it there
|
|
396
|
+
keeps the panel open. It only changes what the page shows: the
|
|
386
397
|
database and exports still contain the full text.
|
|
387
398
|
|
|
388
399
|
The app makes exactly one outbound request of its own: once a day it asks
|
|
389
400
|
`registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
|
|
390
401
|
you when an upgrade is out (npm has no way to push one at you). It sends nothing
|
|
391
402
|
about you or your usage. Turn it off with `NO_UPDATE_NOTIFIER=1` or
|
|
392
|
-
`CLAUDE_FINOPS_NO_UPDATE_CHECK=1`.
|
|
393
|
-
|
|
403
|
+
`CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Anthropic's cost API is called only if you
|
|
404
|
+
add an Admin key (with `--set-key` or on the Settings page), and only
|
|
394
405
|
when you click Refresh on Billed vs local.
|
|
395
406
|
|
|
396
407
|
---
|
|
@@ -449,13 +460,18 @@ files on Windows.
|
|
|
449
460
|
Code saves the name itself. In herdr, the agents sidebar gets the same name. Anywhere it can't
|
|
450
461
|
type, the command lands on your clipboard.
|
|
451
462
|
- **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
|
|
452
|
-
with Resume, Compact and Stop (Interrupt), then past sessions
|
|
453
|
-
|
|
463
|
+
with Resume, Compact and Stop (Interrupt), working ones first, then past sessions by their last
|
|
464
|
+
prompt. The sort button at the top puts the last-used past session on top or at the bottom;
|
|
465
|
+
running sessions always stay on top. Click a session to list its prompts, newest first; its own
|
|
466
|
+
sort button flips that list to oldest first. Click a prompt for its full details. Search matches your prompt text.
|
|
454
467
|
It covers all dates but keeps the project filters. Close and Force kill stay on
|
|
455
468
|
Running sessions.
|
|
456
469
|
- **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal
|
|
457
470
|
(in herdr, Interrupt presses Esc in the session's pane instead). Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
|
|
458
|
-
interrupt one console process without hitting the others in its window.
|
|
471
|
+
interrupt one console process without hitting the others in its window. A session that is
|
|
472
|
+
working is marked **Working now**, and closing it, or one used in the last 50 minutes, warns you
|
|
473
|
+
first. **Close selected** closes several idle sessions at once; a session that starts working
|
|
474
|
+
before you confirm is skipped.
|
|
459
475
|
- **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
|
|
460
476
|
`~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
|
|
461
477
|
already have.
|
package/finops/analytics.py
CHANGED
|
@@ -672,11 +672,12 @@ class Analytics:
|
|
|
672
672
|
def sessions(self, f=None, limit=500, order="cost", offset=0):
|
|
673
673
|
w, p = self.where(f)
|
|
674
674
|
ob = {"cost": "cost DESC", "tokens": "tokens DESC", "duration": "s.duration_s DESC",
|
|
675
|
-
"recent": "s.started_at DESC", "prompts": "prompts DESC"
|
|
675
|
+
"recent": "s.started_at DESC", "prompts": "prompts DESC",
|
|
676
|
+
"last_active": "last_ts DESC", "last_active_asc": "last_ts ASC"}.get(order, "cost DESC")
|
|
676
677
|
rows = self.q(f"""
|
|
677
678
|
SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
|
|
678
679
|
s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
|
|
679
|
-
s.agent, s.source_file, pr.path project_path,
|
|
680
|
+
MAX(r.ts) last_ts, s.agent, s.source_file, pr.path project_path,
|
|
680
681
|
s.billable_tokens session_tokens,
|
|
681
682
|
COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
|
|
682
683
|
SUM(r.tool_call_count) tool_calls,
|
|
@@ -817,6 +818,28 @@ class Analytics:
|
|
|
817
818
|
p["category_evidence"] = []
|
|
818
819
|
return p
|
|
819
820
|
|
|
821
|
+
def prompt_reply(self, pid):
|
|
822
|
+
"""Claude's reply to one prompt, read from the transcript when asked for."""
|
|
823
|
+
from . import guard as G
|
|
824
|
+
from .etl import DEFAULT_SOURCE, reply_from_transcript
|
|
825
|
+
p = self.one("SELECT pr.uuid, pr.session_id, s.source_file FROM prompts pr"
|
|
826
|
+
" LEFT JOIN sessions s ON s.id=pr.session_id WHERE pr.id=?", (pid,))
|
|
827
|
+
if not p:
|
|
828
|
+
return {"error": "not found"}
|
|
829
|
+
path = p["source_file"]
|
|
830
|
+
if not path or not os.path.exists(path):
|
|
831
|
+
path = G.find_transcript(p["session_id"],
|
|
832
|
+
[os.environ.get("CLAUDE_PROJECTS") or DEFAULT_SOURCE])
|
|
833
|
+
if not p["uuid"] or not path:
|
|
834
|
+
return {"ok": False, "reason": "The transcript for this session is no longer on disk."}
|
|
835
|
+
try:
|
|
836
|
+
r = reply_from_transcript(path, p["uuid"])
|
|
837
|
+
except OSError:
|
|
838
|
+
return {"ok": False, "reason": "The transcript could not be read."}
|
|
839
|
+
if not r["found"]:
|
|
840
|
+
return {"ok": False, "reason": "This prompt was not found in its transcript."}
|
|
841
|
+
return {"ok": True, **r}
|
|
842
|
+
|
|
820
843
|
def session_detail(self, sid, f=None):
|
|
821
844
|
s = self.one("""SELECT s.*, pr.name project FROM sessions s
|
|
822
845
|
JOIN projects pr ON pr.id=s.project_id WHERE s.id=?""", (sid,))
|
package/finops/api.py
CHANGED
|
@@ -580,7 +580,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
580
580
|
return self.send_json(Diagnoser(a).breakdown(f))
|
|
581
581
|
if route == "live":
|
|
582
582
|
from .procs import list_sessions, ACTIONS
|
|
583
|
-
rows = sorted((dict(s, resume=f"claude --resume {s.get('session_id')}")
|
|
583
|
+
rows = sorted((dict(s, signalable=True, resume=f"claude --resume {s.get('session_id')}")
|
|
584
584
|
for s in list_sessions(a.pricing)), key=lambda x: -(x.get("context") or 0))
|
|
585
585
|
return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
|
|
586
586
|
if route.startswith("session_prompts/"):
|
|
@@ -611,6 +611,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
611
611
|
raise BadRequest("id must be an integer")
|
|
612
612
|
d = a.prompt_detail(pid, f)
|
|
613
613
|
return self.send_json(d, 404 if isinstance(d, dict) and d.get("error") else 200)
|
|
614
|
+
if route.startswith("prompt_reply/"):
|
|
615
|
+
try:
|
|
616
|
+
pid = int(route.split("/")[1])
|
|
617
|
+
except ValueError:
|
|
618
|
+
raise BadRequest("id must be an integer")
|
|
619
|
+
d = a.prompt_reply(pid)
|
|
620
|
+
return self.send_json(d, 404 if d.get("error") else 200)
|
|
614
621
|
if route.startswith("session/"):
|
|
615
622
|
d = a.session_detail(route.split("/", 1)[1], f)
|
|
616
623
|
return self.send_json(d, 404 if isinstance(d, dict) and d.get("error") else 200)
|
package/finops/etl.py
CHANGED
|
@@ -183,6 +183,43 @@ def _text_of(content):
|
|
|
183
183
|
return "\n".join(x for x in out if x)
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
def _is_human_prompt(r):
|
|
187
|
+
"""A user line that load_file counts as a prompt (not a tool result, meta or injected text)."""
|
|
188
|
+
if r.get("type") != "user" or r.get("toolUseResult") is not None or r.get("isMeta"):
|
|
189
|
+
return False
|
|
190
|
+
text = _text_of((r.get("message") or {}).get("content"))
|
|
191
|
+
return bool(text.strip()) and not text.lstrip().startswith(INJECTED_PREFIXES)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
REPLY_MAX_CHARS = 60_000
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def reply_from_transcript(path, prompt_uuid):
|
|
198
|
+
"""Claude's text replies to one prompt, read from its transcript on demand (never stored).
|
|
199
|
+
Returns the text blocks in order, from the prompt up to the next human prompt."""
|
|
200
|
+
parts, found = [], False
|
|
201
|
+
with open(path, errors="replace") as fh:
|
|
202
|
+
for line in fh:
|
|
203
|
+
if not found and prompt_uuid not in line:
|
|
204
|
+
continue
|
|
205
|
+
try:
|
|
206
|
+
r = json.loads(line)
|
|
207
|
+
except json.JSONDecodeError:
|
|
208
|
+
continue
|
|
209
|
+
if not found:
|
|
210
|
+
found = r.get("uuid") == prompt_uuid
|
|
211
|
+
continue
|
|
212
|
+
if _is_human_prompt(r):
|
|
213
|
+
break
|
|
214
|
+
if r.get("type") == "assistant" and not r.get("isSidechain"):
|
|
215
|
+
text = _text_of((r.get("message") or {}).get("content")).strip()
|
|
216
|
+
if text:
|
|
217
|
+
parts.append(text)
|
|
218
|
+
text = "\n\n".join(parts)
|
|
219
|
+
return {"found": found, "parts": len(parts), "text": text[:REPLY_MAX_CHARS],
|
|
220
|
+
"truncated": len(text) > REPLY_MAX_CHARS}
|
|
221
|
+
|
|
222
|
+
|
|
186
223
|
IMAGE_CHARS = 1600 * 4 # an image costs ~1.6K tokens, not its base64 length
|
|
187
224
|
|
|
188
225
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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/web/app.js
CHANGED
|
@@ -237,6 +237,7 @@ const IP = {
|
|
|
237
237
|
terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3M12 15h6"/>',
|
|
238
238
|
message: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
|
239
239
|
sortDesc: '<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/>',
|
|
240
|
+
sortAsc: '<path d="M11 5h4M11 9h7M11 13h10M3 7l3-3 3 3M6 6v14"/>',
|
|
240
241
|
tag: '<path d="M12.6 2.6A2 2 0 0 0 11.2 2H4a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l8.7 8.7a2.4 2.4 0 0 0 3.4 0l6.6-6.6a2.4 2.4 0 0 0 0-3.4z"/><circle cx="7.5" cy="7.5" r="1.5"/>',
|
|
241
242
|
code: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
|
|
242
243
|
scan: '<path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="11.5" cy="11.5" r="3.5"/><path d="m17 17-3-3"/>',
|
|
@@ -319,12 +320,7 @@ function shell() {
|
|
|
319
320
|
themeLabel();
|
|
320
321
|
$('#refresh').onclick = () => { bust(); render(); };
|
|
321
322
|
maskLabel();
|
|
322
|
-
$('#mask').onclick = () =>
|
|
323
|
-
MASKED = !MASKED;
|
|
324
|
-
try { localStorage.setItem(MASK_KEY, MASKED ? '1' : '0'); } catch (_) {}
|
|
325
|
-
maskLabel(); closeDrawer(); render();
|
|
326
|
-
if (RECENT.open) { RECENT.offset = 0; renderRecent(); }
|
|
327
|
-
};
|
|
323
|
+
$('#mask').onclick = () => toggleMask();
|
|
328
324
|
$('#recent-btn').onclick = () => toggleRecent();
|
|
329
325
|
updateChip();
|
|
330
326
|
$('#sync').onclick = runSync;
|
|
@@ -396,6 +392,17 @@ async function scopeStrip() {
|
|
|
396
392
|
cell('Active days', fmtInt(o.active_days));
|
|
397
393
|
}
|
|
398
394
|
|
|
395
|
+
// From a drawer, the drawer is drawn again in the new state instead of being closed.
|
|
396
|
+
function toggleMask(fromDrawer) {
|
|
397
|
+
MASKED = !MASKED;
|
|
398
|
+
try { localStorage.setItem(MASK_KEY, MASKED ? '1' : '0'); } catch (_) {}
|
|
399
|
+
const dr = fromDrawer && S.drawerRoute;
|
|
400
|
+
maskLabel();
|
|
401
|
+
if (!dr) closeDrawer();
|
|
402
|
+
render();
|
|
403
|
+
if (RECENT.open) { RECENT.offset = 0; renderRecent(); }
|
|
404
|
+
if (dr) openDrawerRoute(dr);
|
|
405
|
+
}
|
|
399
406
|
function maskLabel() {
|
|
400
407
|
const b = $('#mask');
|
|
401
408
|
// The icon shows the current state: an open eye while prompts are visible.
|
|
@@ -555,10 +562,14 @@ function drawer(title, bodyHtml, sub) {
|
|
|
555
562
|
const d = h(`<aside class="drawer"><header>
|
|
556
563
|
<h2>${esc(title)}</h2>${sub ? `<span class="crumbs">${sub}</span>` : ''}
|
|
557
564
|
<span class="spacer"></span>
|
|
565
|
+
<button class="iconbtn" data-mask aria-pressed="${MASKED}"
|
|
566
|
+
title="${MASKED ? 'Prompt text is hidden. Click to show it' : 'Hide prompt text and session titles'}"
|
|
567
|
+
>${I(MASKED ? 'eyeOff' : 'eye')} ${MASKED ? 'Show prompts' : 'Hide prompts'}</button>
|
|
558
568
|
<button class="iconbtn" data-x>Close ${I('x')}</button></header>
|
|
559
569
|
<div class="content">${bodyHtml}</div></aside>`);
|
|
560
570
|
scrim.onclick = closeDrawer;
|
|
561
571
|
d.querySelector('[data-x]').onclick = closeDrawer;
|
|
572
|
+
d.querySelector('[data-mask]').onclick = () => toggleMask(true);
|
|
562
573
|
document.body.append(scrim, d);
|
|
563
574
|
return d;
|
|
564
575
|
}
|
|
@@ -597,6 +608,8 @@ async function openPrompt(id) {
|
|
|
597
608
|
<span>${fmtInt(p.char_len)} chars · ${fmtInt(p.word_len)} words</span>
|
|
598
609
|
${p.source ? `<span class="pill">${esc(p.source)}</span>` : ''}
|
|
599
610
|
</div>`, {badge: BADGE.actual})}
|
|
611
|
+
${card('Claude\'s response', '<div id="pr-reply"><div class="note">Reading the transcript…</div></div>',
|
|
612
|
+
{badge: BADGE.actual, hint: 'read from the transcript when you open this; not stored'})}
|
|
600
613
|
${card('Requests in this turn', table([
|
|
601
614
|
{h: 'Time', f: r => `<span class="mono">${esc((r.ts||'').slice(11,19))}</span>`},
|
|
602
615
|
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r.model)}"></span>${esc(modelName(r.model))}`},
|
|
@@ -627,10 +640,22 @@ async function openPrompt(id) {
|
|
|
627
640
|
<dt>Models</dt><dd>${esc(p.models || '—')}</dd>
|
|
628
641
|
<dt>Tool calls</dt><dd>${fmtInt(p.tool_calls)}</dd>
|
|
629
642
|
<dt>Avg latency</dt><dd>${p.latency_ms ? (p.latency_ms/1000).toFixed(1)+'s' : NA()}</dd>
|
|
630
|
-
<dt>Response text</dt><dd>${NA()} — transcripts are parsed for usage metadata, not stored replies</dd>
|
|
631
643
|
</dl>`)}`;
|
|
632
644
|
const link = d.querySelector('[data-sess]');
|
|
633
645
|
if (link) link.onclick = () => openSession(link.dataset.sess);
|
|
646
|
+
drawReply(d.querySelector('#pr-reply'), id);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function drawReply(box, id) {
|
|
650
|
+
let r;
|
|
651
|
+
try { r = await fetch(`/api/prompt_reply/${id}`).then(x => x.json()); }
|
|
652
|
+
catch (e) { r = {ok: false, reason: e.message}; }
|
|
653
|
+
if (!box.isConnected) return; // drawer closed or replaced while reading
|
|
654
|
+
if (!r.ok) { box.innerHTML = `<div class="na">${esc(r.reason || r.error || 'Could not read the reply.')}</div>`; return; }
|
|
655
|
+
box.innerHTML = r.text
|
|
656
|
+
? `<div class="prompt-text">${esc(pt(r.text))}</div>${r.truncated
|
|
657
|
+
? '<div class="note" style="margin-top:6px">Long reply: only the first part is shown.</div>' : ''}`
|
|
658
|
+
: '<div class="na">No text reply: Claude only ran tools for this prompt.</div>';
|
|
634
659
|
}
|
|
635
660
|
|
|
636
661
|
async function openSession(id) {
|
|
@@ -2119,6 +2144,9 @@ VIEWS.live = async (page) => {
|
|
|
2119
2144
|
const canInterrupt = liveActions.includes('interrupt');
|
|
2120
2145
|
const busy = list.filter(x => x.status === 'busy');
|
|
2121
2146
|
const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
|
|
2147
|
+
// Idle, but used in the last 50 minutes: probably still in use, so closing it asks louder.
|
|
2148
|
+
const RECENT_S = 50 * 60;
|
|
2149
|
+
const recentlyUsed = x => x.status !== 'busy' && x.last_write_s != null && x.last_write_s < RECENT_S;
|
|
2122
2150
|
page.innerHTML = `
|
|
2123
2151
|
${usage0 ? card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
|
|
2124
2152
|
{badge: BADGE.actual, hint: 'Claude Code\'s own /usage: plan limits, not cost. Costs no tokens.',
|
|
@@ -2136,10 +2164,18 @@ VIEWS.live = async (page) => {
|
|
|
2136
2164
|
<b>Close</b> exits the session cleanly; copy its resume command to bring it back.
|
|
2137
2165
|
<b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
|
|
2138
2166
|
<span class="spacer"></span><button class="btn pb-copy" id="live-refresh">${I('reload')} Refresh</button></div>
|
|
2167
|
+
${list.some(x => x.signalable) ? `<div class="live-bulk">
|
|
2168
|
+
<label><input type="checkbox" id="live-all"> Select all idle</label>
|
|
2169
|
+
<button class="act warn" id="live-close-sel" disabled>${I('stop')} Close selected</button>
|
|
2170
|
+
<span class="note">Sessions that are working are never closed in bulk.</span>
|
|
2171
|
+
<span class="live-msg" id="live-bulk-msg" role="status"></span></div>` : ''}
|
|
2139
2172
|
<div class="stack">${list.map((x, i) => `
|
|
2140
|
-
<div class="item sev-${sevOf(x)}" data-i="${i}">
|
|
2141
|
-
<div class="hd">${x.
|
|
2173
|
+
<div class="item sev-${sevOf(x)}${x.status === 'busy' ? ' working' : ''}" data-i="${i}">
|
|
2174
|
+
<div class="hd">${x.signalable ? `<input type="checkbox" class="live-pick" aria-label="Select for bulk close"${x.status === 'busy'
|
|
2175
|
+
? ' disabled title="Working now: bulk close skips it"' : ''}>` : ''}
|
|
2176
|
+
${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
|
|
2142
2177
|
<span class="sname">${esc((!MASKED && x.name) || shortId(x.session_id))}</span> <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
|
|
2178
|
+
${x.status === 'busy' ? '<span class="status healthy">Working now</span>' : ''}
|
|
2143
2179
|
${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
|
|
2144
2180
|
${limitBadge(x.session_id)}
|
|
2145
2181
|
<span class="spacer"></span>
|
|
@@ -2186,6 +2222,54 @@ VIEWS.live = async (page) => {
|
|
|
2186
2222
|
</div></div>` : ''}
|
|
2187
2223
|
</div>`).join('') || '<div class="empty">No Claude Code sessions are running</div>'}</div>`;
|
|
2188
2224
|
$('#live-refresh', page).onclick = () => render();
|
|
2225
|
+
const all = $('#live-all', page), closeSel = $('#live-close-sel', page), bulkMsg = $('#live-bulk-msg', page);
|
|
2226
|
+
if (all) {
|
|
2227
|
+
const picks = () => [...page.querySelectorAll('.live-pick')];
|
|
2228
|
+
const chosen = () => picks().filter(c => c.checked && !c.disabled).map(c => list[+c.closest('.item').dataset.i]);
|
|
2229
|
+
const sync = () => {
|
|
2230
|
+
const n = chosen().length, open = picks().filter(c => !c.disabled);
|
|
2231
|
+
closeSel.disabled = !n;
|
|
2232
|
+
closeSel.classList.remove('armed');
|
|
2233
|
+
closeSel.innerHTML = `${I('stop')} Close selected${n ? ` (${n})` : ''}`;
|
|
2234
|
+
all.checked = open.length > 0 && open.every(c => c.checked);
|
|
2235
|
+
all.indeterminate = n > 0 && !all.checked;
|
|
2236
|
+
};
|
|
2237
|
+
all.onchange = () => { picks().forEach(c => { if (!c.disabled) c.checked = all.checked; }); sync(); };
|
|
2238
|
+
picks().forEach(c => c.onchange = sync);
|
|
2239
|
+
closeSel.onclick = async () => {
|
|
2240
|
+
const sel = chosen();
|
|
2241
|
+
if (!sel.length) return;
|
|
2242
|
+
if (!closeSel.classList.contains('armed')) { // two clicks, like each row's Close
|
|
2243
|
+
const recent = sel.filter(recentlyUsed).length;
|
|
2244
|
+
closeSel.classList.add('armed');
|
|
2245
|
+
closeSel.textContent = `Click again to close ${sel.length} session${sel.length > 1 ? 's' : ''}` + (recent
|
|
2246
|
+
? `. ${recent} of them ${recent > 1 ? 'were' : 'was'} used in the last 50 min, maybe still in use` : '');
|
|
2247
|
+
setTimeout(() => { if (closeSel.classList.contains('armed')) sync(); }, recent ? 6000 : 4000);
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
closeSel.classList.remove('armed'); closeSel.disabled = true; closeSel.textContent = 'Closing…';
|
|
2251
|
+
// Status is read again right before closing: a session that started working since the
|
|
2252
|
+
// page was drawn is skipped, not stopped mid-task.
|
|
2253
|
+
let now = [];
|
|
2254
|
+
try { now = (await fetch('/api/live').then(r => r.json())).sessions || []; } catch {}
|
|
2255
|
+
const status = new Map(now.map(s => [s.pid, s.status]));
|
|
2256
|
+
let closed = 0, skipped = 0;
|
|
2257
|
+
const failed = [];
|
|
2258
|
+
for (const x of sel) {
|
|
2259
|
+
if (!status.has(x.pid)) continue; // already gone
|
|
2260
|
+
if (status.get(x.pid) === 'busy') { skipped++; continue; }
|
|
2261
|
+
try {
|
|
2262
|
+
const r = await sessionAction(x.pid, 'close');
|
|
2263
|
+
if (r.ok) closed++; else failed.push(`${(!MASKED && x.name) || shortId(x.session_id)}: ${r.error}`);
|
|
2264
|
+
} catch (e) { failed.push(e.message); }
|
|
2265
|
+
}
|
|
2266
|
+
bulkMsg.className = 'live-msg ' + (failed.length ? 'err' : 'ok');
|
|
2267
|
+
bulkMsg.textContent = [`Closed ${closed}.`, skipped ? `Skipped ${skipped} that started working.` : '',
|
|
2268
|
+
failed.length ? `Failed: ${failed.join('; ')}` : ''].filter(Boolean).join(' ');
|
|
2269
|
+
setTimeout(() => render(), failed.length ? 4000 : 1500);
|
|
2270
|
+
};
|
|
2271
|
+
sync();
|
|
2272
|
+
}
|
|
2189
2273
|
const ub = $('#usage-refresh', page);
|
|
2190
2274
|
if (ub) ub.onclick = async () => {
|
|
2191
2275
|
ub.disabled = true; ub.textContent = 'Reading…';
|
|
@@ -2255,9 +2339,15 @@ VIEWS.live = async (page) => {
|
|
|
2255
2339
|
if (!b.classList.contains('armed')) {
|
|
2256
2340
|
row.querySelectorAll('button.armed').forEach(o => { o.classList.remove('armed'); o.textContent = o.dataset.label; });
|
|
2257
2341
|
b.dataset.label = label; b.classList.add('armed');
|
|
2342
|
+
const stopping = ['close', 'kill'].includes(b.dataset.a);
|
|
2343
|
+
const loud = stopping && (x.status === 'busy' || recentlyUsed(x));
|
|
2258
2344
|
b.textContent = x.hosts_dashboard && !['interrupt', 'compact'].includes(b.dataset.a)
|
|
2259
|
-
? 'Sure? This is the session that launched the dashboard (the dashboard keeps running)'
|
|
2260
|
-
|
|
2345
|
+
? 'Sure? This is the session that launched the dashboard (the dashboard keeps running)'
|
|
2346
|
+
: stopping && x.status === 'busy' ? 'It is working right now. Click again to stop it mid-task'
|
|
2347
|
+
: loud ? `Used ${dur(x.last_write_s)} ago, maybe still in use. Click again to ${b.dataset.a} anyway`
|
|
2348
|
+
: `Click again to ${b.dataset.a}`;
|
|
2349
|
+
setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = label; } },
|
|
2350
|
+
loud ? 6000 : 4000);
|
|
2261
2351
|
return;
|
|
2262
2352
|
}
|
|
2263
2353
|
b.classList.remove('armed'); b.disabled = true; b.textContent = 'Working…';
|
|
@@ -3562,7 +3652,11 @@ async function render() {
|
|
|
3562
3652
|
Past sessions and their prompts from any page, without leaving it. Resume and
|
|
3563
3653
|
stop act on sessions, so the list is grouped by session; running ones sit on
|
|
3564
3654
|
top. Close and Force kill stay on Running sessions, one click away from here. */
|
|
3565
|
-
|
|
3655
|
+
// The header sort button orders past sessions: last used on top, or (oldest) at the bottom.
|
|
3656
|
+
// Running sessions always stay on top.
|
|
3657
|
+
// A session's prompts list newest first; its sort button flips that session to oldest first.
|
|
3658
|
+
const RECENT = {open: false, offset: 0, limit: 30, q: '', open_ids: new Set(), oldest: false, oldest_ids: new Set()};
|
|
3659
|
+
const oldestFirst = sid => RECENT.oldest_ids.has(sid);
|
|
3566
3660
|
// Recent means all time: keep project filters, drop the date range and thresholds.
|
|
3567
3661
|
const recentQs = () => { const p = new URLSearchParams(qs());
|
|
3568
3662
|
['start', 'end', 'min_cost', 'min_tokens'].forEach(k => p.delete(k)); return p.toString(); };
|
|
@@ -3578,12 +3672,25 @@ function toggleRecent(force) {
|
|
|
3578
3672
|
const scrim = h('<div class="recent-scrim"></div>');
|
|
3579
3673
|
const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
|
|
3580
3674
|
<h2>Recent</h2><span class="note">all time · project filters apply</span>
|
|
3581
|
-
<span class="spacer"></span
|
|
3675
|
+
<span class="spacer"></span>
|
|
3676
|
+
<button class="iconbtn sq" data-sort-all aria-label="Session order"></button>
|
|
3677
|
+
<button class="iconbtn" data-x>Close ${I('x')}</button></header>
|
|
3582
3678
|
<div class="recent-search"><span class="mag">${I('search')}</span>
|
|
3583
3679
|
<input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
|
|
3584
3680
|
<div class="content"><div class="loading">Loading…</div></div></aside>`);
|
|
3585
3681
|
scrim.onclick = () => toggleRecent(false);
|
|
3586
3682
|
p.querySelector('[data-x]').onclick = () => toggleRecent(false);
|
|
3683
|
+
const sortAll = p.querySelector('[data-sort-all]');
|
|
3684
|
+
const sortAllLabel = () => {
|
|
3685
|
+
sortAll.innerHTML = I(RECENT.oldest ? 'sortAsc' : 'sortDesc');
|
|
3686
|
+
sortAll.title = RECENT.oldest ? 'Sessions: last used at the bottom. Click to put it on top'
|
|
3687
|
+
: 'Sessions: last used on top. Click to put it at the bottom';
|
|
3688
|
+
};
|
|
3689
|
+
sortAllLabel();
|
|
3690
|
+
sortAll.onclick = () => {
|
|
3691
|
+
RECENT.oldest = !RECENT.oldest; sortAllLabel();
|
|
3692
|
+
RECENT.offset = 0; renderRecent();
|
|
3693
|
+
};
|
|
3587
3694
|
let t;
|
|
3588
3695
|
p.querySelector('#recent-q').oninput = e => { clearTimeout(t);
|
|
3589
3696
|
t = setTimeout(() => { RECENT.q = e.target.value.trim(); RECENT.offset = 0; renderRecent(); }, 260); };
|
|
@@ -3601,11 +3708,13 @@ async function renderRecent() {
|
|
|
3601
3708
|
.then(r => r.json()).catch(() => ({sessions: []})),
|
|
3602
3709
|
RECENT.q
|
|
3603
3710
|
? fetch(`/api/prompts?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}&q=${encodeURIComponent(RECENT.q)}`).then(r => r.json())
|
|
3604
|
-
: fetch(`/api/sessions?${recentQs()}&order
|
|
3711
|
+
: fetch(`/api/sessions?${recentQs()}&order=${RECENT.oldest ? 'last_active_asc' : 'last_active'}&limit=${RECENT.limit}&offset=${RECENT.offset}`).then(r => r.json())]);
|
|
3605
3712
|
if (!$('.recent')) return; // closed while loading
|
|
3606
3713
|
const rows = res.rows || [];
|
|
3607
3714
|
const liveIds = new Set(((live || {}).sessions || RECENT.live || []).map(x => x.session_id));
|
|
3608
|
-
|
|
3715
|
+
// Working sessions first, then the one written to most recently.
|
|
3716
|
+
if (live) RECENT.live = (live.sessions || []).slice().sort((a, b) =>
|
|
3717
|
+
(b.status === 'busy') - (a.status === 'busy') || (a.last_write_s ?? 1e12) - (b.last_write_s ?? 1e12));
|
|
3609
3718
|
const html = RECENT.q ? recentPrompts(rows) : recentSessions(rows, liveIds);
|
|
3610
3719
|
const shown = RECENT.offset + rows.length;
|
|
3611
3720
|
const moreBtn = shown < (res.total || 0)
|
|
@@ -3640,14 +3749,19 @@ const recentSessions = (rows, liveIds) => rows.map(r => `
|
|
|
3640
3749
|
<span class="recent-title">${esc(stitle(r))}</span><span class="spacer"></span>
|
|
3641
3750
|
<span class="note">${fmtUSD(r.cost)}</span></div>
|
|
3642
3751
|
<div class="mt"><span>${esc(r.project || '')}</span><span>${fmtInt(r.prompts)} prompts</span>
|
|
3643
|
-
<span>${fmtWhen(r.started_at)}</span></div>
|
|
3752
|
+
<span title="Last prompt">${fmtWhen(r.last_ts || r.started_at)}</span></div>
|
|
3644
3753
|
<div class="live-actions">
|
|
3645
3754
|
${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">${I('copy')} Resume</button>` : ''}
|
|
3646
3755
|
<button class="act ghost" data-open-sess="${esc(r.session_id)}">Details</button>
|
|
3647
|
-
<span class="spacer"></span
|
|
3756
|
+
<span class="spacer"></span>${sortBtn(r.session_id)}<span class="note recent-caret">${I(RECENT.open_ids.has(r.session_id) ? 'chevDown' : 'chevRight')} prompts</span></div>
|
|
3648
3757
|
<div class="recent-prompts"${RECENT.open_ids.has(r.session_id) ? '' : ' hidden'}></div></div>`).join('')
|
|
3649
3758
|
|| '<div class="empty">No sessions for these filters</div>';
|
|
3650
3759
|
|
|
3760
|
+
const sortBtn = sid => { const old = oldestFirst(sid);
|
|
3761
|
+
return `<button class="act ghost recent-sort" data-sort title="${old
|
|
3762
|
+
? 'Prompts: oldest first. Click for newest first' : 'Prompts: newest first. Click for oldest first'}"
|
|
3763
|
+
aria-label="Prompt order">${I(old ? 'sortAsc' : 'sortDesc')}</button>`; };
|
|
3764
|
+
|
|
3651
3765
|
const promptLine = x => `<a href="#" class="recent-p" data-prompt="${x.prompt_id}" title="${esc(pt(x.preview))}">
|
|
3652
3766
|
<span class="mono sub">${esc((x.ts || '').slice(5, 16).replace('T', ' '))}</span>
|
|
3653
3767
|
<span class="recent-pv">${esc(pt(x.preview) || '—')}</span>
|
|
@@ -3663,7 +3777,8 @@ async function loadSessionPrompts(host, sid) {
|
|
|
3663
3777
|
host.innerHTML = '<div class="note">Loading…</div>';
|
|
3664
3778
|
try {
|
|
3665
3779
|
const s = await fetch(`/api/session/${encodeURIComponent(sid)}`).then(r => r.json());
|
|
3666
|
-
const ps = s.prompts || [];
|
|
3780
|
+
const ps = s.prompts || []; // oldest first from the API
|
|
3781
|
+
if (!oldestFirst(sid)) ps.reverse();
|
|
3667
3782
|
host.innerHTML = ps.length ? ps.map(promptLine).join('') : '<div class="note">No prompts recorded</div>';
|
|
3668
3783
|
wireRecent(host);
|
|
3669
3784
|
} catch (e) { host.innerHTML = `<div class="live-msg err">${esc(e.message)}</div>`; }
|
|
@@ -3682,6 +3797,15 @@ function wireRecent(root) {
|
|
|
3682
3797
|
root.querySelectorAll('.item[data-sess]').forEach(it => {
|
|
3683
3798
|
const host = it.querySelector('.recent-prompts'), sid = it.dataset.sess;
|
|
3684
3799
|
if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
|
|
3800
|
+
const sb = it.querySelector('[data-sort]');
|
|
3801
|
+
sb.onclick = e => {
|
|
3802
|
+
e.stopPropagation();
|
|
3803
|
+
RECENT.oldest_ids.has(sid) ? RECENT.oldest_ids.delete(sid) : RECENT.oldest_ids.add(sid);
|
|
3804
|
+
const t = h(sortBtn(sid));
|
|
3805
|
+
sb.innerHTML = t.innerHTML; sb.title = t.title;
|
|
3806
|
+
if (host.hidden) it.click(); // sorting a closed list opens it
|
|
3807
|
+
else loadSessionPrompts(host, sid);
|
|
3808
|
+
};
|
|
3685
3809
|
it.onclick = e => {
|
|
3686
3810
|
if (e.target.closest('button, a, .recent-prompts')) return;
|
|
3687
3811
|
host.hidden = !host.hidden;
|
package/web/styles.css
CHANGED
|
@@ -488,6 +488,10 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
488
488
|
.live-msg { font-size: 11.5px; }
|
|
489
489
|
.live-msg.ok { color: var(--good-ink); } .live-msg.err { color: var(--critical-ink); }
|
|
490
490
|
.item.gone { opacity: .45; }
|
|
491
|
+
.item.working { outline: 2px solid var(--good); outline-offset: -1px; }
|
|
492
|
+
.live-pick { margin: 0; cursor: pointer; }
|
|
493
|
+
.live-bulk { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin: 4px 0 8px; }
|
|
494
|
+
.live-bulk label { display: inline-flex; gap: 6px; align-items: center; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
491
495
|
|
|
492
496
|
/* An explicit display beats the hidden attribute, so the panel has to opt back
|
|
493
497
|
out or it is permanently open. */
|
|
@@ -574,7 +578,9 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
574
578
|
.recent > header { padding:13px 16px; background:var(--surface); border-bottom:1px solid var(--border);
|
|
575
579
|
display:flex; align-items:center; gap:10px; }
|
|
576
580
|
.recent > header h2 { margin:0; font-size:14.5px; font-weight:640; }
|
|
581
|
+
.recent > header .iconbtn.sq { width:32px; padding:0; justify-content:center; }
|
|
577
582
|
.recent-search { position:relative; padding:10px 16px 0; }
|
|
583
|
+
.recent-sort { padding:3px 6px; margin-right:6px; }
|
|
578
584
|
.recent-search input { width:100%; box-sizing:border-box; padding:7px 10px 7px 28px; border-radius:8px;
|
|
579
585
|
font-size:12.5px; border:1px solid var(--border); background:var(--surface-2); color:var(--text); }
|
|
580
586
|
.recent-search .mag { position:absolute; left:26px; top:calc(50% + 5px); transform:translateY(-50%);
|