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 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 provider APIs only
14
- if you add a key.
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
  ![Waste detection](https://raw.githubusercontent.com/mohit-raj-purohit/claude-finops/main/docs/img/waste.png)
87
87
 
88
- **Whether the model you are on is the right one.** Per-model cost and efficiency,
89
- plus a switch analysis that prices the same workload on a cheaper model.
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
  ![Model analysis](https://raw.githubusercontent.com/mohit-raj-purohit/claude-finops/main/docs/img/models.png)
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 provider APIs only if you add a key. The warehouse lives
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; a recommendation engine that stays quiet without evidence; and
171
- anomaly detection you can click through to the underlying sessions.
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 as
191
- `unpriced` ($0, flagged as such in the Model analysis view) rather than silently
192
- billed at another model's rate.
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 a provider API key (hidden prompt, 0600)
324
- claude-finops --keys which provider keys are configured
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 provider API keys (0600)
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
- **Hide prompts** (the eye icon in the top bar) hides prompt text and session titles as dots,
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. It only changes what the page shows: the
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`. Provider cost APIs are called only if you
393
- configure a key (with `--set-key` or on the Settings page), and only
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 newest first. Click a session to
453
- list its prompts, and click a prompt for its full details. Search matches your prompt text.
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.
@@ -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"}.get(order, "cost 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.17.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.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
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)' : `Click again to ${b.dataset.a}`;
2260
- setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = label; } }, 4000);
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
- const RECENT = {open: false, offset: 0, limit: 30, q: '', open_ids: new Set()};
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><button class="iconbtn" data-x>Close ${I('x')}</button></header>
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=recent&limit=${RECENT.limit}&offset=${RECENT.offset}`).then(r => r.json())]);
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
- if (live) RECENT.live = live.sessions || [];
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><span class="note recent-caret">${I(RECENT.open_ids.has(r.session_id) ? 'chevDown' : 'chevRight')} prompts</span></div>
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%);