ccdeck 3.39.4 → 3.40.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 +8 -0
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/dist/web/assets/{BrowserWatchModal-DvLtvWy6.js → BrowserWatchModal-BB9KSESQ.js} +1 -1
- package/dist/web/assets/RadioPanel-BTPZdlIr.js +1 -0
- package/dist/web/assets/{TrafficRadar-MfnZJeq1.js → TrafficRadar-DlMqUfpo.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-X4UF2sFE.js → UsageHistoryModal-C7ay7k-d.js} +1 -1
- package/dist/web/assets/index-8IDHjTIH.css +1 -0
- package/dist/web/assets/index-Di3xMUCd.js +306 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/src/server/account-projects.mjs +15 -6
- package/src/server/ccusage-session.mjs +60 -0
- package/src/server/ccusage.mjs +40 -0
- package/src/server/index.mjs +6 -2
- package/src/server/music-routes.mjs +23 -0
- package/src/server/pinned-build.mjs +1 -0
- package/src/server/radio-directory.mjs +100 -0
- package/src/server/request-gates.mjs +3 -0
- package/src/server/transcript-scan.mjs +24 -4
- package/src/server/usage-routes.mjs +13 -0
- package/dist/web/assets/index-CxIdVCPz.js +0 -305
- package/dist/web/assets/index-TDFtmv7q.css +0 -1
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
70
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
69
|
+
<script type="module" crossorigin src="/assets/index-Di3xMUCd.js"></script>
|
|
70
|
+
<link rel="stylesheet" crossorigin href="/assets/index-8IDHjTIH.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.
|
|
3
|
+
"version": "3.40.0",
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -76,6 +76,24 @@
|
|
|
76
76
|
"3.38.6",
|
|
77
77
|
"3.39.4"
|
|
78
78
|
],
|
|
79
|
+
"3.40.0": [
|
|
80
|
+
{
|
|
81
|
+
"title": "📻 Explore radio around the world",
|
|
82
|
+
"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."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"title": "🦊 Choose your canvas companion",
|
|
86
|
+
"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."
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"title": "📊 See what each session used",
|
|
90
|
+
"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."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"title": "A clearer canvas",
|
|
94
|
+
"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."
|
|
95
|
+
}
|
|
96
|
+
],
|
|
79
97
|
"3.39.3": [
|
|
80
98
|
{
|
|
81
99
|
"title": "A cleaner desktop window",
|
|
@@ -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 =
|
|
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
|
|
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
|
+
}
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -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
|
*
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|