claude-finops 0.17.0 → 0.17.1

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.
@@ -149,7 +149,7 @@ labelled as your own configured figures.
149
149
 
150
150
  **Command center** — Executive overview (spend, tokens, usage %, remaining, forecast,
151
151
  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
152
+ interrupt, compact, hand over, rename or close, an AI FinOps Advisor that answers "what should I do today?" from live data, and a
153
153
  0–100 FinOps scorecard with per-dimension reasoning.
154
154
 
155
155
  **Usage** — Interactive timeline across 10 metrics and 6 time ranges with day
@@ -167,8 +167,9 @@ latency and efficiency; five leaderboards; prompt intelligence (spend by activit
167
167
  and a Claude Code view (tools, files, branches, cost per repository).
168
168
 
169
169
  **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.
170
+ sessions it flagged; Subagent models, measured per subagent type; a recommendation
171
+ engine that stays quiet without evidence; and anomaly detection you can click through
172
+ to the underlying sessions.
172
173
 
173
174
  **Plan** — Forecast with conservative/expected/high scenario bands, and budgets with
174
175
  Budget → Actual → Forecast → Variance and configurable alert thresholds.
@@ -177,6 +178,11 @@ Global search spans prompt text, sessions, projects, models, tools and dates. Gl
177
178
  filters (date, model, project, category, cost/token thresholds, sandbox toggle) update
178
179
  every chart and KPI. Everything exports to CSV/JSON, plus a printable PDF report.
179
180
 
181
+ Every page, and the side panel when it shows a session or a prompt, has its own URL, so a
182
+ refresh keeps you where you were and Back/Forward work. Next to the version, the header
183
+ says whether the dashboard runs from the npm install or from a source checkout (with its
184
+ commit).
185
+
180
186
  ---
181
187
 
182
188
  ## Configuration
@@ -187,9 +193,10 @@ Two files, both editable without touching code. The server picks up changes to
187
193
  ### `config/pricing.json`
188
194
 
189
195
  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.
196
+ can be updated as prices change. A new `claude-*` model with no entry yet is priced
197
+ like the newest listed model of its family and marked as such, instead of showing $0.
198
+ A model that isn't Claude (such as a free local model behind a `claude-qwen` launcher)
199
+ costs $0.
193
200
 
194
201
  ### `config/settings.json`
195
202
 
@@ -320,8 +327,8 @@ claude-finops start the dashboard
320
327
  claude-finops --rebuild re-read transcripts, then start
321
328
  claude-finops --stop stop it
322
329
  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
330
+ claude-finops --set-key store the Anthropic Admin API key (hidden prompt, 0600)
331
+ claude-finops --keys whether that key is configured
325
332
  claude-finops --install-statusline model, context % and 5-hour limit under every prompt
326
333
  claude-finops --uninstall-statusline remove it again
327
334
  claude-finops --install-guard warn near, and ask at, your per-session token budget
@@ -344,7 +351,7 @@ Everything the app writes lives outside the install folder, in one state directo
344
351
  data/cloud_cache.json cached billing figures
345
352
  data/server.pid|.log running server
346
353
  settings.local.json budgets and limits set in the UI
347
- secrets.local.json provider API keys (0600)
354
+ secrets.local.json Anthropic Admin API key (0600)
348
355
  ```
349
356
 
350
357
  Days, "today" and the billing period follow this computer's time zone (Claude Code
@@ -382,15 +389,16 @@ generate as sensitive.
382
389
 
383
390
  **Hide prompts** (the eye icon in the top bar) hides prompt text and session titles as dots,
384
391
  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
392
+ setting is remembered in that browser. The side panel has the same button, and using it there
393
+ keeps the panel open. It only changes what the page shows: the
386
394
  database and exports still contain the full text.
387
395
 
388
396
  The app makes exactly one outbound request of its own: once a day it asks
389
397
  `registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
390
398
  you when an upgrade is out (npm has no way to push one at you). It sends nothing
391
399
  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
400
+ `CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Anthropic's cost API is called only if you
401
+ add an Admin key (with `--set-key` or on the Settings page), and only
394
402
  when you click Refresh on Billed vs local.
395
403
 
396
404
  ---
@@ -449,13 +457,18 @@ files on Windows.
449
457
  Code saves the name itself. In herdr, the agents sidebar gets the same name. Anywhere it can't
450
458
  type, the command lands on your clipboard.
451
459
  - **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.
460
+ with Resume, Compact and Stop (Interrupt), working ones first, then past sessions by their last
461
+ prompt. The sort button at the top puts the last-used past session on top or at the bottom;
462
+ running sessions always stay on top. Click a session to list its prompts, newest first; its own
463
+ sort button flips that list to oldest first. Click a prompt for its full details. Search matches your prompt text.
454
464
  It covers all dates but keeps the project filters. Close and Force kill stay on
455
465
  Running sessions.
456
466
  - **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal
457
467
  (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.
468
+ interrupt one console process without hitting the others in its window. A session that is
469
+ working is marked **Working now**, and closing it, or one used in the last 50 minutes, warns you
470
+ first. **Close selected** closes several idle sessions at once; a session that starts working
471
+ before you confirm is skipped.
459
472
  - **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
460
473
  `~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
461
474
  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,
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/"):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
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
  }
@@ -2119,6 +2130,9 @@ VIEWS.live = async (page) => {
2119
2130
  const canInterrupt = liveActions.includes('interrupt');
2120
2131
  const busy = list.filter(x => x.status === 'busy');
2121
2132
  const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
2133
+ // Idle, but used in the last 50 minutes: probably still in use, so closing it asks louder.
2134
+ const RECENT_S = 50 * 60;
2135
+ const recentlyUsed = x => x.status !== 'busy' && x.last_write_s != null && x.last_write_s < RECENT_S;
2122
2136
  page.innerHTML = `
2123
2137
  ${usage0 ? card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
2124
2138
  {badge: BADGE.actual, hint: 'Claude Code\'s own /usage: plan limits, not cost. Costs no tokens.',
@@ -2136,10 +2150,18 @@ VIEWS.live = async (page) => {
2136
2150
  <b>Close</b> exits the session cleanly; copy its resume command to bring it back.
2137
2151
  <b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
2138
2152
  <span class="spacer"></span><button class="btn pb-copy" id="live-refresh">${I('reload')} Refresh</button></div>
2153
+ ${list.some(x => x.signalable) ? `<div class="live-bulk">
2154
+ <label><input type="checkbox" id="live-all"> Select all idle</label>
2155
+ <button class="act warn" id="live-close-sel" disabled>${I('stop')} Close selected</button>
2156
+ <span class="note">Sessions that are working are never closed in bulk.</span>
2157
+ <span class="live-msg" id="live-bulk-msg" role="status"></span></div>` : ''}
2139
2158
  <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>'}
2159
+ <div class="item sev-${sevOf(x)}${x.status === 'busy' ? ' working' : ''}" data-i="${i}">
2160
+ <div class="hd">${x.signalable ? `<input type="checkbox" class="live-pick" aria-label="Select for bulk close"${x.status === 'busy'
2161
+ ? ' disabled title="Working now: bulk close skips it"' : ''}>` : ''}
2162
+ ${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
2142
2163
  <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>
2164
+ ${x.status === 'busy' ? '<span class="status healthy">Working now</span>' : ''}
2143
2165
  ${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
2144
2166
  ${limitBadge(x.session_id)}
2145
2167
  <span class="spacer"></span>
@@ -2186,6 +2208,54 @@ VIEWS.live = async (page) => {
2186
2208
  </div></div>` : ''}
2187
2209
  </div>`).join('') || '<div class="empty">No Claude Code sessions are running</div>'}</div>`;
2188
2210
  $('#live-refresh', page).onclick = () => render();
2211
+ const all = $('#live-all', page), closeSel = $('#live-close-sel', page), bulkMsg = $('#live-bulk-msg', page);
2212
+ if (all) {
2213
+ const picks = () => [...page.querySelectorAll('.live-pick')];
2214
+ const chosen = () => picks().filter(c => c.checked && !c.disabled).map(c => list[+c.closest('.item').dataset.i]);
2215
+ const sync = () => {
2216
+ const n = chosen().length, open = picks().filter(c => !c.disabled);
2217
+ closeSel.disabled = !n;
2218
+ closeSel.classList.remove('armed');
2219
+ closeSel.innerHTML = `${I('stop')} Close selected${n ? ` (${n})` : ''}`;
2220
+ all.checked = open.length > 0 && open.every(c => c.checked);
2221
+ all.indeterminate = n > 0 && !all.checked;
2222
+ };
2223
+ all.onchange = () => { picks().forEach(c => { if (!c.disabled) c.checked = all.checked; }); sync(); };
2224
+ picks().forEach(c => c.onchange = sync);
2225
+ closeSel.onclick = async () => {
2226
+ const sel = chosen();
2227
+ if (!sel.length) return;
2228
+ if (!closeSel.classList.contains('armed')) { // two clicks, like each row's Close
2229
+ const recent = sel.filter(recentlyUsed).length;
2230
+ closeSel.classList.add('armed');
2231
+ closeSel.textContent = `Click again to close ${sel.length} session${sel.length > 1 ? 's' : ''}` + (recent
2232
+ ? `. ${recent} of them ${recent > 1 ? 'were' : 'was'} used in the last 50 min, maybe still in use` : '');
2233
+ setTimeout(() => { if (closeSel.classList.contains('armed')) sync(); }, recent ? 6000 : 4000);
2234
+ return;
2235
+ }
2236
+ closeSel.classList.remove('armed'); closeSel.disabled = true; closeSel.textContent = 'Closing…';
2237
+ // Status is read again right before closing: a session that started working since the
2238
+ // page was drawn is skipped, not stopped mid-task.
2239
+ let now = [];
2240
+ try { now = (await fetch('/api/live').then(r => r.json())).sessions || []; } catch {}
2241
+ const status = new Map(now.map(s => [s.pid, s.status]));
2242
+ let closed = 0, skipped = 0;
2243
+ const failed = [];
2244
+ for (const x of sel) {
2245
+ if (!status.has(x.pid)) continue; // already gone
2246
+ if (status.get(x.pid) === 'busy') { skipped++; continue; }
2247
+ try {
2248
+ const r = await sessionAction(x.pid, 'close');
2249
+ if (r.ok) closed++; else failed.push(`${(!MASKED && x.name) || shortId(x.session_id)}: ${r.error}`);
2250
+ } catch (e) { failed.push(e.message); }
2251
+ }
2252
+ bulkMsg.className = 'live-msg ' + (failed.length ? 'err' : 'ok');
2253
+ bulkMsg.textContent = [`Closed ${closed}.`, skipped ? `Skipped ${skipped} that started working.` : '',
2254
+ failed.length ? `Failed: ${failed.join('; ')}` : ''].filter(Boolean).join(' ');
2255
+ setTimeout(() => render(), failed.length ? 4000 : 1500);
2256
+ };
2257
+ sync();
2258
+ }
2189
2259
  const ub = $('#usage-refresh', page);
2190
2260
  if (ub) ub.onclick = async () => {
2191
2261
  ub.disabled = true; ub.textContent = 'Reading…';
@@ -2255,9 +2325,15 @@ VIEWS.live = async (page) => {
2255
2325
  if (!b.classList.contains('armed')) {
2256
2326
  row.querySelectorAll('button.armed').forEach(o => { o.classList.remove('armed'); o.textContent = o.dataset.label; });
2257
2327
  b.dataset.label = label; b.classList.add('armed');
2328
+ const stopping = ['close', 'kill'].includes(b.dataset.a);
2329
+ const loud = stopping && (x.status === 'busy' || recentlyUsed(x));
2258
2330
  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);
2331
+ ? 'Sure? This is the session that launched the dashboard (the dashboard keeps running)'
2332
+ : stopping && x.status === 'busy' ? 'It is working right now. Click again to stop it mid-task'
2333
+ : loud ? `Used ${dur(x.last_write_s)} ago, maybe still in use. Click again to ${b.dataset.a} anyway`
2334
+ : `Click again to ${b.dataset.a}`;
2335
+ setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = label; } },
2336
+ loud ? 6000 : 4000);
2261
2337
  return;
2262
2338
  }
2263
2339
  b.classList.remove('armed'); b.disabled = true; b.textContent = 'Working…';
@@ -3562,7 +3638,11 @@ async function render() {
3562
3638
  Past sessions and their prompts from any page, without leaving it. Resume and
3563
3639
  stop act on sessions, so the list is grouped by session; running ones sit on
3564
3640
  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()};
3641
+ // The header sort button orders past sessions: last used on top, or (oldest) at the bottom.
3642
+ // Running sessions always stay on top.
3643
+ // A session's prompts list newest first; its sort button flips that session to oldest first.
3644
+ const RECENT = {open: false, offset: 0, limit: 30, q: '', open_ids: new Set(), oldest: false, oldest_ids: new Set()};
3645
+ const oldestFirst = sid => RECENT.oldest_ids.has(sid);
3566
3646
  // Recent means all time: keep project filters, drop the date range and thresholds.
3567
3647
  const recentQs = () => { const p = new URLSearchParams(qs());
3568
3648
  ['start', 'end', 'min_cost', 'min_tokens'].forEach(k => p.delete(k)); return p.toString(); };
@@ -3578,12 +3658,25 @@ function toggleRecent(force) {
3578
3658
  const scrim = h('<div class="recent-scrim"></div>');
3579
3659
  const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
3580
3660
  <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>
3661
+ <span class="spacer"></span>
3662
+ <button class="iconbtn sq" data-sort-all aria-label="Session order"></button>
3663
+ <button class="iconbtn" data-x>Close ${I('x')}</button></header>
3582
3664
  <div class="recent-search"><span class="mag">${I('search')}</span>
3583
3665
  <input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
3584
3666
  <div class="content"><div class="loading">Loading…</div></div></aside>`);
3585
3667
  scrim.onclick = () => toggleRecent(false);
3586
3668
  p.querySelector('[data-x]').onclick = () => toggleRecent(false);
3669
+ const sortAll = p.querySelector('[data-sort-all]');
3670
+ const sortAllLabel = () => {
3671
+ sortAll.innerHTML = I(RECENT.oldest ? 'sortAsc' : 'sortDesc');
3672
+ sortAll.title = RECENT.oldest ? 'Sessions: last used at the bottom. Click to put it on top'
3673
+ : 'Sessions: last used on top. Click to put it at the bottom';
3674
+ };
3675
+ sortAllLabel();
3676
+ sortAll.onclick = () => {
3677
+ RECENT.oldest = !RECENT.oldest; sortAllLabel();
3678
+ RECENT.offset = 0; renderRecent();
3679
+ };
3587
3680
  let t;
3588
3681
  p.querySelector('#recent-q').oninput = e => { clearTimeout(t);
3589
3682
  t = setTimeout(() => { RECENT.q = e.target.value.trim(); RECENT.offset = 0; renderRecent(); }, 260); };
@@ -3601,11 +3694,13 @@ async function renderRecent() {
3601
3694
  .then(r => r.json()).catch(() => ({sessions: []})),
3602
3695
  RECENT.q
3603
3696
  ? 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())]);
3697
+ : fetch(`/api/sessions?${recentQs()}&order=${RECENT.oldest ? 'last_active_asc' : 'last_active'}&limit=${RECENT.limit}&offset=${RECENT.offset}`).then(r => r.json())]);
3605
3698
  if (!$('.recent')) return; // closed while loading
3606
3699
  const rows = res.rows || [];
3607
3700
  const liveIds = new Set(((live || {}).sessions || RECENT.live || []).map(x => x.session_id));
3608
- if (live) RECENT.live = live.sessions || [];
3701
+ // Working sessions first, then the one written to most recently.
3702
+ if (live) RECENT.live = (live.sessions || []).slice().sort((a, b) =>
3703
+ (b.status === 'busy') - (a.status === 'busy') || (a.last_write_s ?? 1e12) - (b.last_write_s ?? 1e12));
3609
3704
  const html = RECENT.q ? recentPrompts(rows) : recentSessions(rows, liveIds);
3610
3705
  const shown = RECENT.offset + rows.length;
3611
3706
  const moreBtn = shown < (res.total || 0)
@@ -3640,14 +3735,19 @@ const recentSessions = (rows, liveIds) => rows.map(r => `
3640
3735
  <span class="recent-title">${esc(stitle(r))}</span><span class="spacer"></span>
3641
3736
  <span class="note">${fmtUSD(r.cost)}</span></div>
3642
3737
  <div class="mt"><span>${esc(r.project || '')}</span><span>${fmtInt(r.prompts)} prompts</span>
3643
- <span>${fmtWhen(r.started_at)}</span></div>
3738
+ <span title="Last prompt">${fmtWhen(r.last_ts || r.started_at)}</span></div>
3644
3739
  <div class="live-actions">
3645
3740
  ${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">${I('copy')} Resume</button>` : ''}
3646
3741
  <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>
3742
+ <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
3743
  <div class="recent-prompts"${RECENT.open_ids.has(r.session_id) ? '' : ' hidden'}></div></div>`).join('')
3649
3744
  || '<div class="empty">No sessions for these filters</div>';
3650
3745
 
3746
+ const sortBtn = sid => { const old = oldestFirst(sid);
3747
+ return `<button class="act ghost recent-sort" data-sort title="${old
3748
+ ? 'Prompts: oldest first. Click for newest first' : 'Prompts: newest first. Click for oldest first'}"
3749
+ aria-label="Prompt order">${I(old ? 'sortAsc' : 'sortDesc')}</button>`; };
3750
+
3651
3751
  const promptLine = x => `<a href="#" class="recent-p" data-prompt="${x.prompt_id}" title="${esc(pt(x.preview))}">
3652
3752
  <span class="mono sub">${esc((x.ts || '').slice(5, 16).replace('T', ' '))}</span>
3653
3753
  <span class="recent-pv">${esc(pt(x.preview) || '—')}</span>
@@ -3663,7 +3763,8 @@ async function loadSessionPrompts(host, sid) {
3663
3763
  host.innerHTML = '<div class="note">Loading…</div>';
3664
3764
  try {
3665
3765
  const s = await fetch(`/api/session/${encodeURIComponent(sid)}`).then(r => r.json());
3666
- const ps = s.prompts || [];
3766
+ const ps = s.prompts || []; // oldest first from the API
3767
+ if (!oldestFirst(sid)) ps.reverse();
3667
3768
  host.innerHTML = ps.length ? ps.map(promptLine).join('') : '<div class="note">No prompts recorded</div>';
3668
3769
  wireRecent(host);
3669
3770
  } catch (e) { host.innerHTML = `<div class="live-msg err">${esc(e.message)}</div>`; }
@@ -3682,6 +3783,15 @@ function wireRecent(root) {
3682
3783
  root.querySelectorAll('.item[data-sess]').forEach(it => {
3683
3784
  const host = it.querySelector('.recent-prompts'), sid = it.dataset.sess;
3684
3785
  if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
3786
+ const sb = it.querySelector('[data-sort]');
3787
+ sb.onclick = e => {
3788
+ e.stopPropagation();
3789
+ RECENT.oldest_ids.has(sid) ? RECENT.oldest_ids.delete(sid) : RECENT.oldest_ids.add(sid);
3790
+ const t = h(sortBtn(sid));
3791
+ sb.innerHTML = t.innerHTML; sb.title = t.title;
3792
+ if (host.hidden) it.click(); // sorting a closed list opens it
3793
+ else loadSessionPrompts(host, sid);
3794
+ };
3685
3795
  it.onclick = e => {
3686
3796
  if (e.target.closest('button, a, .recent-prompts')) return;
3687
3797
  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%);