ccdeck 3.39.4 → 3.40.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.
@@ -66,8 +66,8 @@
66
66
  document.documentElement.setAttribute("data-color-scheme", lightThemes.indexOf(theme) >= 0 ? "light" : "dark");
67
67
  })();
68
68
  </script>
69
- <script type="module" crossorigin src="/assets/index-CxIdVCPz.js"></script>
70
- <link rel="stylesheet" crossorigin href="/assets/index-TDFtmv7q.css">
69
+ <script type="module" crossorigin src="/assets/index-BJpK26-r.js"></script>
70
+ <link rel="stylesheet" crossorigin href="/assets/index-B_uYtqRM.css">
71
71
  </head>
72
72
  <body>
73
73
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.39.4",
3
+ "version": "3.40.1",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,7 +74,26 @@
74
74
  "3.38.1",
75
75
  "3.38.2",
76
76
  "3.38.6",
77
- "3.39.4"
77
+ "3.39.4",
78
+ "3.40.1"
79
+ ],
80
+ "3.40.0": [
81
+ {
82
+ "title": "📻 Explore radio around the world",
83
+ "body": "Open Radio from the right sidebar to discover stations on a world map, filter by country, sound or language, and save your favorites. Recent listening, a daily pick and Surprise me make it easier to find something to play. Music keeps playing when you close the explorer. Volume now adjusts in 1% steps, and an unavailable stream automatically advances to the next station after one second."
84
+ },
85
+ {
86
+ "title": "🦊 Choose your canvas companion",
87
+ "body": "Choose Deck companion, Fox or Penguin in Settings › Appearance. Your companion shares the existing canvas movements and music controls, and your choice stays saved."
88
+ },
89
+ {
90
+ "title": "📊 See what each session used",
91
+ "body": "Open a session’s usage details to inspect tokens, models and estimated cost. For Codex, compare Automatic, Standard, Fast and Flex pricing estimates without changing your Codex settings or billing."
92
+ },
93
+ {
94
+ "title": "A clearer canvas",
95
+ "body": "Session cards, tool traces and connections are easier to scan. The canvas reframes more reliably when resizing to portrait or rearranging sessions, and model cost estimates use updated pricing."
96
+ }
78
97
  ],
79
98
  "3.39.3": [
80
99
  {
@@ -33,7 +33,7 @@ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from ".
33
33
  // and a session's card cannot disagree about which lines billed anything, nor
34
34
  // about the speed they were billed at — see transcript-scan.mjs.
35
35
  import {
36
- billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
36
+ billedSpeed, isLongContextRequest, hasSpend, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
37
37
  requestBilling, newRequestLedger, noteRequestRecord, streamingRequestId,
38
38
  BILL_LINE, REBILL_REQUEST, SKIP_LINE,
39
39
  } from "./transcript-scan.mjs";
@@ -43,7 +43,7 @@ import {
43
43
  * a per-account total is never quietly inflated by work that is not that
44
44
  * account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
45
45
  export const UNATTRIBUTED = "\u0000unattributed";
46
- const STATE_VERSION = 6;
46
+ const STATE_VERSION = 7;
47
47
 
48
48
  /** Where the tally and cursors live, beside cswap-auto's own state. */
49
49
  export function statePath(home = homedir()) {
@@ -102,6 +102,7 @@ function asUsageTotals(c) {
102
102
  function addInto(dst, src) {
103
103
  dst.i += src.i; dst.o += src.o; dst.cr += src.cr;
104
104
  dst.cc += src.cc; dst.c1h += src.c1h; dst.c5m += src.c5m;
105
+ if (src.l) addInto(dst.l ??= zero(), src.l);
105
106
  if (src.s) for (const [speed, share] of Object.entries(src.s)) addInto(speedShareOf(dst, speed), share);
106
107
  return dst;
107
108
  }
@@ -282,22 +283,29 @@ export function foldLine(tally, line, timeline, from) {
282
283
  const speed = billedSpeed(obj);
283
284
  const share = speed ? speedShareKey(counters, speed) : null;
284
285
  if (share) addInto(counters.s[share], c);
286
+ const long = isLongContextRequest(model, usage);
287
+ if (long) {
288
+ addInto(counters.l ??= zero(), c);
289
+ if (share) addInto(counters.s[share].l ??= zero(), c);
290
+ }
285
291
  if (billing === REBILL_REQUEST) dropIfEmpty(tally, ledger.streaming);
286
292
  if (billing === BILL_LINE || !requests) return;
287
293
  // What a request still streaming was charged, and where, so its final
288
294
  // record can take that back, in this pass or a later one.
289
295
  const id = streamingRequestId(obj);
290
- requests[from.path].streaming = id === null ? null : { id, at: [key, cwd, day, model], s: share, c };
296
+ requests[from.path].streaming = id === null ? null : { id, at: [key, cwd, day, model], s: share, long, c };
291
297
  }
292
298
 
293
299
  /** Take a streaming request's charge back out of the row it was charged to,
294
300
  * for its later record to charge in its place. A row pruned since has
295
301
  * nothing left to take back. */
296
- function refund(tally, { at: [key, cwd, day, model], s, c }) {
302
+ function refund(tally, { at: [key, cwd, day, model], s, long, c }) {
297
303
  const counters = tally[key]?.[cwd]?.[day]?.[model];
298
304
  if (!counters) return;
299
305
  takeFrom(counters, c);
300
306
  if (s && counters.s?.[s]) takeFrom(counters.s[s], c);
307
+ if (long && counters.l) takeFrom(counters.l, c);
308
+ if (long && s && counters.s?.[s]?.l) takeFrom(counters.s[s].l, c);
301
309
  }
302
310
 
303
311
  /** Remove what a refund left at zero — a speed share, or a whole row whose
@@ -500,14 +508,15 @@ export function createProjectRollup({
500
508
  state = disk;
501
509
  state.folders ??= {};
502
510
  state.requests ??= {};
503
- } else if (disk && [1, 2, 3, 4, 5].includes(disk.version)) {
511
+ } else if (disk && [1, 2, 3, 4, 5, 6].includes(disk.version)) {
504
512
  // Version 1 counted every API content block; version 2 keyed each row
505
513
  // by the line's own cwd, a row per folder the agent cd'd into (#1278);
506
514
  // version 3 kept no speed, so every fast turn read at the standard
507
515
  // rate; version 4 billed a subagent's request from its streaming
508
516
  // snapshot, most of its output and its speed missing (#1883); version
509
517
  // 5 billed a resumed subagent's request again each time its file
510
- // restated its history. None of them can be re-keyed, since it no
518
+ // restated its history; version 6 kept no per-request context tiers.
519
+ // None of them can be re-keyed, since it no
511
520
  // longer knows which transcript a count came from. Keep the heartbeat
512
521
  // so a restart gap remains fenced, but rebuild the tally from
513
522
  // transcripts.
@@ -0,0 +1,60 @@
1
+ // ccusage's two session formats, reduced to the figures the inspector needs.
2
+ // No prompts, tool arguments or transcript text cross this boundary.
3
+ const number = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
4
+ const counters = value => ({
5
+ inputTokens: number(value?.inputTokens), outputTokens: number(value?.outputTokens),
6
+ cacheReadTokens: number(value?.cacheReadTokens), cacheCreationTokens: number(value?.cacheCreationTokens),
7
+ reasoningOutputTokens: number(value?.reasoningOutputTokens),
8
+ });
9
+ const tokenTotal = row => row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheCreationTokens;
10
+ const date = value => typeof value === "string" && Number.isFinite(Date.parse(value)) ? value : null;
11
+
12
+ export function validSessionLookup(agent, id, speed = "auto") {
13
+ if (agent !== "claude" && agent !== "codex") return false;
14
+ if (!["auto", "standard", "fast", "flex"].includes(speed) || (agent !== "codex" && speed !== "auto")) return false;
15
+ if (typeof id !== "string" || id.includes("..")) return false;
16
+ return agent === "codex"
17
+ ? /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(id)
18
+ : /^[a-z\d][a-z\d._:-]{0,255}$/i.test(id);
19
+ }
20
+
21
+ export function sessionDetail(raw, agent, sessionId, speed = "auto") {
22
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
23
+ if (typeof raw.sessionId !== "string" || !Number.isFinite(raw.totalTokens)
24
+ || !Number.isFinite(raw.totalCost) || raw.totalTokens < 0 || raw.totalCost < 0) return null;
25
+ // Codex answers with its rollout ID (path + UUID); Claude answers with the
26
+ // requested filename. Never display another session as the one requested.
27
+ const uuid = raw.sessionId.match(/[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}(?:\.jsonl(?:\.zst)?)?$/i)?.[0]
28
+ .replace(/\.jsonl(?:\.zst)?$/i, "");
29
+ if (agent === "codex" ? uuid?.toLowerCase() !== sessionId.toLowerCase() : raw.sessionId !== sessionId) return null;
30
+ const models = new Map();
31
+ let usage = counters(raw), firstActivity = date(raw.firstActivity), lastActivity = date(raw.lastActivity);
32
+ let entries = null;
33
+ if (agent === "claude" && Array.isArray(raw.entries)) {
34
+ usage = counters({});
35
+ entries = raw.entries.length;
36
+ for (const entry of raw.entries) {
37
+ const row = counters(entry);
38
+ for (const key of Object.keys(usage)) usage[key] += row[key];
39
+ const model = typeof entry?.model === "string" ? entry.model : "unknown";
40
+ const bucket = models.get(model) ?? { model, ...counters({}), missingPricing: false };
41
+ for (const key of Object.keys(usage)) bucket[key] += row[key];
42
+ models.set(model, bucket);
43
+ const at = date(entry?.timestamp);
44
+ if (at && (!firstActivity || Date.parse(at) < Date.parse(firstActivity))) firstActivity = at;
45
+ if (at && (!lastActivity || Date.parse(at) > Date.parse(lastActivity))) lastActivity = at;
46
+ }
47
+ } else if (agent === "codex" && raw.models && typeof raw.models === "object" && !Array.isArray(raw.models)) {
48
+ for (const [model, value] of Object.entries(raw.models)) {
49
+ models.set(model, { model, ...counters(value), missingPricing: value?.missingPricing === true });
50
+ }
51
+ } else return null;
52
+ // Per-model dollars are absent in Codex's JSON and Claude's entry costUSD
53
+ // may omit calculated costs. Keep the reported total; don't invent a split.
54
+ return {
55
+ sessionId, agent, speed, totalCost: raw.totalCost, totalTokens: raw.totalTokens,
56
+ ...usage, firstActivity, lastActivity, entries,
57
+ models: [...models.values()].map(row => ({ ...row, totalTokens: tokenTotal(row) }))
58
+ .sort((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)),
59
+ };
60
+ }
@@ -12,6 +12,7 @@ import {
12
12
  backgroundUpdateDue, installsDisabled, maybeBackgroundUpdate, resolveEntry, startInstall,
13
13
  } from "./ccusage-install.mjs";
14
14
  import { overrideIsThere, runCcusage, userCcusage } from "./ccusage-runner.mjs";
15
+ import { sessionDetail, validSessionLookup } from "./ccusage-session.mjs";
15
16
 
16
17
  // 60s, and it is the panel's poll interval rather than a number of its own: the
17
18
  // Usage panel asks once a minute and expects a reading that has actually moved,
@@ -295,6 +296,45 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
295
296
  return run;
296
297
  }
297
298
 
299
+ /** A manual, whole-session read. Shares the history queue and its limits. */
300
+ export async function fetchCcusageSession({ agent, sessionId, speed = "auto", force = false } = {}) {
301
+ if (!validSessionLookup(agent, sessionId, speed)) {
302
+ return { ok: false, reason: "bad_session", error: "Choose a Claude or Codex session and a supported pricing mode." };
303
+ }
304
+ const now = Date.now();
305
+ const key = `session:${agent}:${speed}:${sessionId}`;
306
+ const cached = _cache.get(key);
307
+ if (!force && cached && now - cached.at < CACHE_MS) return cached.result;
308
+ if (_inflight.has(key)) return _inflight.get(key);
309
+ if (_outstanding >= MAX_OUTSTANDING) {
310
+ return { ok: false, reason: "busy", error: "Usage reports are already being read. Try again in a moment." };
311
+ }
312
+ const run = queued(async () => {
313
+ let result;
314
+ try {
315
+ const args = [agent, "session", "--id", sessionId, "--json"];
316
+ // Auto is ccusage's default. Omitting the flag preserves older copies;
317
+ // explicit modes are scenarios and must never silently fall back.
318
+ if (agent === "codex" && speed !== "auto") args.push("--speed", speed);
319
+ const ran = await runCcusage(args);
320
+ const raw = ran.out.trim() === "null" ? null : extractJson(ran.out);
321
+ const detail = sessionDetail(raw, agent, sessionId, speed);
322
+ result = detail ? { ok: true, detail, fetchedAt: Date.now() }
323
+ : { ok: false, reason: raw == null ? "not_found" : "bad_output",
324
+ error: raw == null ? "No local usage was found for this session." : "ccusage returned an unsupported session report.", fetchedAt: Date.now() };
325
+ } catch (err) {
326
+ const error = String(err?.message ?? err);
327
+ const reason = err?.reason ?? (/No Codex session found with ID:/i.test(error) ? "not_found"
328
+ : /Codex session ID .* is ambiguous/i.test(error) ? "ambiguous" : "run_failed");
329
+ result = { ok: false, reason, error, fetchedAt: Date.now() };
330
+ }
331
+ rememberRange(key, result, Date.now());
332
+ return result;
333
+ }).finally(() => { _inflight.delete(key); });
334
+ _inflight.set(key, run);
335
+ return run;
336
+ }
337
+
298
338
  /**
299
339
  * The same range, grouped by session rather than by day.
300
340
  *
@@ -162,9 +162,9 @@ import { cswapAutoModule, getProjectRollup, handleAccountLoginState, handleAccou
162
162
  // Browser Watch's three routes — see browser-watch-routes.mjs.
163
163
  import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettings } from "./browser-watch-routes.mjs";
164
164
  // The music routes, every one behind AGENTS_DECK_NO_MUSIC — see music-routes.mjs.
165
- import { handleBestOfNostalgia, handleCafeMusicBgm, handleClaudeFm, handleFmStation, handleGoodLifeRadio, handleLiveRadioMix, handleLofiGirl } from "./music-routes.mjs";
165
+ import { handleBestOfNostalgia, handleCafeMusicBgm, handleClaudeFm, handleFmStation, handleGoodLifeRadio, handleLiveRadioMix, handleLofiGirl, handleRadioStations, handleRadioClick, handleMusicConfig } from "./music-routes.mjs";
166
166
  // The usage panel's quota and history reads — see usage-routes.mjs.
167
- import { handleCcusage, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota } from "./usage-routes.mjs";
167
+ import { handleCcusage, handleCcusageSession, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota } from "./usage-routes.mjs";
168
168
  // The port fallback, one listen attempt, and the words for a failed one — see
169
169
  // listen.mjs. The loop that uses them is startServer's.
170
170
  import { listenFailure, portRetryable, randomPort, tryListen } from "./listen.mjs";
@@ -371,6 +371,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
371
371
  }
372
372
  if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
373
373
  if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
374
+ if (req.method === "GET" && url.pathname === "/api/ccusage/session") return guard(handleCcusageSession(req, res), res);
374
375
  if (req.method === "GET" && url.pathname === "/api/provider-status") return guard(handleProviderStatus(req, res), res);
375
376
  if (req.method === "GET" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatch(req, res), res);
376
377
  if (req.method === "POST" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatchSettings(req, res), res);
@@ -383,6 +384,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
383
384
  if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
384
385
  if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
385
386
  if (req.method === "GET" && url.pathname === "/api/claude-fm") return guard(handleClaudeFm(req, res), res);
387
+ if (req.method === "GET" && url.pathname === "/api/music-config") return handleMusicConfig(req, res);
388
+ if (req.method === "GET" && url.pathname === "/api/radio-stations") return guard(handleRadioStations(req, res), res);
389
+ if (req.method === "GET" && url.pathname === "/api/radio-click") return guard(handleRadioClick(req, res), res);
386
390
  if (req.method === "GET" && url.pathname === "/api/fm-station") return guard(handleFmStation(req, res), res);
387
391
  if (req.method === "GET" && url.pathname === "/api/lofi-girl") return guard(handleLofiGirl(req, res), res);
388
392
  if (req.method === "GET" && url.pathname === "/api/live-radio-mix") return guard(handleLiveRadioMix(req, res), res);
@@ -132,3 +132,26 @@ export async function handleCafeMusicBgm(req, res) {
132
132
  const answer = await fetchCafeMusicBgm();
133
133
  send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Cafe Music BGM is not live" });
134
134
  }
135
+
136
+ export async function handleRadioStations(req, res) {
137
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, off: true, stations: [] });
138
+ const { radioDirectory } = await import(pathToFileURL(join(PKG_ROOT, "src/server/radio-directory.mjs")).href);
139
+ const query = new URL(req.url, "http://localhost").searchParams;
140
+ try {
141
+ const stations = await radioDirectory.search({ name: query.get("name") ?? "", country: query.get("country") ?? "", tag: query.get("tag") ?? "", language: query.get("language") ?? "" });
142
+ send(res, 200, { ok: true, stations });
143
+ } catch { send(res, 503, { ok: false, error: "directory_unavailable", stations: [] }); }
144
+ }
145
+
146
+ export async function handleRadioClick(req, res) {
147
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, off: true });
148
+ const id = new URL(req.url, "http://localhost").searchParams.get("id") ?? "";
149
+ if (!/^radio-[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(id)) return send(res, 400, { ok: false });
150
+ const { radioDirectory } = await import(pathToFileURL(join(PKG_ROOT, "src/server/radio-directory.mjs")).href);
151
+ await radioDirectory.click(id);
152
+ send(res, 200, { ok: true });
153
+ }
154
+
155
+ export function handleMusicConfig(req, res) {
156
+ send(res, 200, { enabled: process.env.AGENTS_DECK_NO_MUSIC !== "1" });
157
+ }
@@ -65,6 +65,7 @@ const PINNED_MODULES = [
65
65
  "browser-watch-store.mjs",
66
66
  "claude-fm.mjs",
67
67
  "fm-station.mjs",
68
+ "radio-directory.mjs",
68
69
  "lofi-girl.mjs",
69
70
  "live-radio-mix.mjs",
70
71
  "best-of-nostalgia.mjs",
@@ -0,0 +1,100 @@
1
+ import { resolveSrv } from "node:dns/promises";
2
+
3
+ const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
4
+ const FALLBACK = ["de1.api.radio-browser.info", "nl1.api.radio-browser.info", "at1.api.radio-browser.info"];
5
+ const TTL = 10 * 60_000;
6
+
7
+ export function normalizeRadioStation(row) {
8
+ if (!row || typeof row !== "object" || !UUID.test(row.stationuuid)) return null;
9
+ let url;
10
+ try { url = new URL(row.url_resolved || row.url); } catch { return null; }
11
+ if (url.protocol !== "https:" || url.username || url.password || /\.(pls|m3u)$/i.test(url.pathname)) return null;
12
+ const name = String(row.name ?? "").trim().slice(0, 80);
13
+ if (!name) return null;
14
+ const coordinate = (value, limit) => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= limit ? value : null;
15
+ return {
16
+ id: `radio-${row.stationuuid}`, name, url: url.href,
17
+ country: String(row.country ?? "").slice(0, 80), countryCode: String(row.countrycode ?? "").slice(0, 2),
18
+ region: String(row.state ?? "").slice(0, 80), tags: String(row.tags ?? "").slice(0, 180),
19
+ language: String(row.language ?? "").slice(0, 80), codec: String(row.codec ?? "").slice(0, 12),
20
+ bitrate: Math.max(0, Math.min(1000, Number(row.bitrate) || 0)),
21
+ ...(row.hls === 1 ? { hls: true } : {}),
22
+ lat: coordinate(row.geo_lat, 90), lon: coordinate(row.geo_long, 180),
23
+ };
24
+ }
25
+
26
+ export function createRadioDirectory({ fetchImpl = fetch, discover = resolveSrv, now = Date.now, random = Math.random } = {}) {
27
+ const cache = new Map();
28
+ const pending = new Map();
29
+ const clicks = new Set();
30
+ let hosts;
31
+ let hostTime = 0;
32
+ async function servers() {
33
+ if (hosts && now() - hostTime < TTL) return hosts;
34
+ try {
35
+ const rows = await Promise.race([
36
+ discover("_api._tcp.radio-browser.info"),
37
+ new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error("dns_timeout")), 2000); timer.unref?.(); }),
38
+ ]);
39
+ hosts = rows.map(row => row.name.replace(/\.$/, "")).filter(host => /^[a-z0-9-]+\.api\.radio-browser\.info$/i.test(host));
40
+ } catch { hosts = []; }
41
+ hosts = [...new Set([...hosts, ...FALLBACK])];
42
+ for (let i = hosts.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [hosts[i], hosts[j]] = [hosts[j], hosts[i]]; }
43
+ hostTime = now();
44
+ return hosts;
45
+ }
46
+ async function request(path) {
47
+ for (const host of (await servers()).slice(0, 3)) {
48
+ try {
49
+ const response = await fetchImpl(`https://${host}${path}`, {
50
+ headers: { "User-Agent": "ccdeck-radio/1.0 (+https://ccdeck.dev)", Accept: "application/json" },
51
+ signal: AbortSignal.timeout(4500), redirect: "error",
52
+ });
53
+ if (!response.ok) throw new Error("directory_unavailable");
54
+ const reader = response.body?.getReader();
55
+ if (!reader) return await response.json();
56
+ const chunks = []; let size = 0;
57
+ try {
58
+ for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > 2_000_000) throw new Error("directory_too_large"); chunks.push(value); }
59
+ } finally { await reader.cancel().catch(() => {}); }
60
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
61
+ } catch { /* Try another directory mirror, never another stream. */ }
62
+ }
63
+ throw new Error("directory_unavailable");
64
+ }
65
+ async function search({ name = "", country = "", tag = "", language = "" } = {}) {
66
+ const params = new URLSearchParams({ limit: "300", hidebroken: "true", order: "clickcount", reverse: "true", is_https: "true" });
67
+ if (name) params.set("name", name.trim().slice(0, 80));
68
+ if (country) params.set("countrycode", country.toUpperCase().slice(0, 2));
69
+ if (tag) params.set("tag", tag.trim().slice(0, 40));
70
+ if (language) params.set("language", language.trim().slice(0, 40));
71
+ const key = params.toString();
72
+ const cached = cache.get(key);
73
+ if (cached && now() - cached.at < TTL) return cached.stations;
74
+ if (pending.has(key)) return pending.get(key);
75
+ const promise = request(`/json/stations/search?${key}`).then(rows => {
76
+ if (!Array.isArray(rows)) throw new Error("directory_invalid");
77
+ const seen = new Set();
78
+ const stations = rows.slice(0, 300).map(normalizeRadioStation).filter(station => {
79
+ if (!station || seen.has(station.id)) return false;
80
+ seen.add(station.id); return true;
81
+ });
82
+ if (cache.size >= 64) cache.delete(cache.keys().next().value);
83
+ cache.set(key, { at: now(), stations });
84
+ return stations;
85
+ }).finally(() => pending.delete(key));
86
+ pending.set(key, promise);
87
+ return promise;
88
+ }
89
+ async function click(id) {
90
+ const uuid = id.replace(/^radio-/, "");
91
+ if (!UUID.test(uuid)) return false;
92
+ if (clicks.has(uuid)) return true;
93
+ if (clicks.size >= 1000) clicks.clear();
94
+ clicks.add(uuid);
95
+ try { await request(`/json/url/${uuid}`); return true; } catch { clicks.delete(uuid); return false; }
96
+ }
97
+ return { search, click };
98
+ }
99
+
100
+ export const radioDirectory = createRadioDirectory();
@@ -455,6 +455,7 @@ export const GUARDED_READS = new Set([
455
455
  // the desktop app, bin/ and the hook never read them.
456
456
  "/api/quota",
457
457
  "/api/ccusage",
458
+ "/api/ccusage/session",
458
459
  "/api/codex-usage",
459
460
  "/api/cswap-auto",
460
461
  // Not a secret, and here for the other reason a read can be dangerous: it is
@@ -464,6 +465,8 @@ export const GUARDED_READS = new Set([
464
465
  // only caller that needs it is the deck's own canvas, which sends
465
466
  // Sec-Fetch-Site: same-origin on every fetch.
466
467
  "/api/fm-station",
468
+ "/api/radio-stations",
469
+ "/api/radio-click",
467
470
  ]);
468
471
 
469
472
  export function isAuthorizedMutation(req) {
@@ -193,6 +193,17 @@ function billedSpeed(record) {
193
193
  return typeof speed === "string" && SPEED_VALUE_RE.test(speed) ? speed : UNRECOGNISED_SPEED;
194
194
  }
195
195
 
196
+ /** Haiku 5.5's request tier includes uncached input, cache reads and writes.
197
+ * The threshold is strictly greater than 100K, independently for each request.
198
+ * https://platform.claude.com/docs/en/about-claude/pricing (2026-10-12). */
199
+ function isLongContextRequest(model, usage) {
200
+ const bare = typeof model === "string"
201
+ ? model.replace(/^(?:(?:us-gov|global|apac|us|eu|jp|au)\.)?anthropic\./i, "") : "";
202
+ if (!/^claude[-_]haiku[-_]5[-_.]5\b(?![-_.]\d{1,7}(?!\d))/i.test(bare)) return false;
203
+ return tokenCount(usage?.input_tokens) + tokenCount(usage?.cache_read_input_tokens)
204
+ + tokenCount(usage?.cache_creation_input_tokens) > 100_000;
205
+ }
206
+
196
207
  /** The share of `bucket` billed at `speed`, created on first sight. Null for
197
208
  * the standard speed or a bucket we could not attribute. `Object.hasOwn`
198
209
  * rather than a plain read, because `constructor` passes SPEED_VALUE_RE and
@@ -268,10 +279,14 @@ function tokenCount(v) {
268
279
  /** Add one bucket's counters, and its speed shares, into `dst`. */
269
280
  function addUsageBucket(dst, src) {
270
281
  for (const k of USAGE_TOTAL_FIELDS) dst[k] += src[k] ?? 0;
282
+ if (src.longContext) {
283
+ const into = dst.longContext ??= newUsageTotals();
284
+ for (const k of USAGE_TOTAL_FIELDS) into[k] += src.longContext[k] ?? 0;
285
+ }
271
286
  if (!src.speeds) return dst;
272
287
  for (const [speed, share] of Object.entries(src.speeds)) {
273
288
  const into = speedBucketFor(dst, speed);
274
- for (const k of USAGE_TOTAL_FIELDS) into[k] += share[k] ?? 0;
289
+ addUsageBucket(into, share);
275
290
  }
276
291
  return dst;
277
292
  }
@@ -617,6 +632,8 @@ function foldUsageLine(state, line, record) {
617
632
  // and the two would stop summing to each other.
618
633
  const bucket = usageBucketFor(state, state.lastModel);
619
634
  const speedBucket = speedBucketFor(bucket, billedSpeed(record));
635
+ const longBuckets = isLongContextRequest(state.lastModel, record?.message?.usage)
636
+ ? [bucket, speedBucket].filter(Boolean).map(b => b.longContext ??= newUsageTotals()) : [];
620
637
  const charged = newUsageTotals();
621
638
  if (billing === BILL_LINE) {
622
639
  const billed = billedUsageText(line);
@@ -626,23 +643,26 @@ function foldUsageLine(state, line, record) {
626
643
  for (const m of billed.replace(ITERATIONS_ARRAY_RE, "").matchAll(CACHE_CREATION_BLOCK_RE)) {
627
644
  chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS, speedBucket, charged);
628
645
  }
646
+ for (const into of longBuckets) addUsageBucket(into, charged);
629
647
  return;
630
648
  }
631
649
  chargeRequestUsage(state, bucket, record.message?.usage, speedBucket, charged);
650
+ for (const into of longBuckets) addUsageBucket(into, charged);
632
651
  // What this request was charged, and where, kept until its final record
633
652
  // replaces it.
634
653
  const id = streamingRequestId(record);
635
- ledger.streaming = id === null ? null : { id, bucket, speedBucket, charged };
654
+ ledger.streaming = id === null ? null : { id, bucket, speedBucket, longBuckets, charged };
636
655
  }
637
656
 
638
657
  /** Take back what a request was charged, from every counter it was charged
639
658
  * to, for its later record to charge in its place. */
640
- function refundUsage(state, { bucket, speedBucket, charged }) {
659
+ function refundUsage(state, { bucket, speedBucket, longBuckets = [], charged }) {
641
660
  for (const k of USAGE_TOTAL_FIELDS) {
642
661
  const n = charged[k];
643
662
  state.usage[k] -= n;
644
663
  if (bucket) bucket[k] -= n;
645
664
  if (speedBucket) speedBucket[k] -= n;
665
+ for (const into of longBuckets) into[k] -= n;
646
666
  }
647
667
  }
648
668
 
@@ -828,5 +848,5 @@ function scanTranscript(path) {
828
848
  export {
829
849
  scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, copyUsageBucket,
830
850
  requestBilling, newRequestLedger, noteRequestRecord, streamingRequestId,
831
- BILL_LINE, REBILL_REQUEST, SKIP_LINE, billedSpeed, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
851
+ BILL_LINE, REBILL_REQUEST, SKIP_LINE, billedSpeed, isLongContextRequest, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
832
852
  };
@@ -12,6 +12,7 @@ import { dirname, join, resolve } from "node:path";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
  import { deckProviders } from "./deck-scope.mjs";
14
14
  import { send } from "./http-io.mjs";
15
+ import { validSessionLookup } from "./ccusage-session.mjs";
15
16
 
16
17
  // Resolved the way pinned-build.mjs resolves it, from a file in the same
17
18
  // directory, so every lazy import below is the URL the pin has already evaluated.
@@ -106,3 +107,15 @@ export async function handleCcusage(req, res) {
106
107
  const data = await fetchCcusageDaily({ since, until, force });
107
108
  send(res, 200, data);
108
109
  }
110
+
111
+ export async function handleCcusageSession(req, res) {
112
+ const url = new URL(req.url, "http://localhost");
113
+ const agent = url.searchParams.get("agent");
114
+ const sessionId = url.searchParams.get("id");
115
+ const speed = url.searchParams.get("speed") ?? "auto";
116
+ if (!validSessionLookup(agent, sessionId, speed)) {
117
+ return send(res, 400, { ok: false, reason: "bad_session", error: "Invalid session lookup." });
118
+ }
119
+ const { fetchCcusageSession } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
120
+ send(res, 200, await fetchCcusageSession({ agent, sessionId, speed, force: url.searchParams.get("refresh") === "1" }));
121
+ }