ccdeck 3.27.0 → 3.27.4

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.
@@ -44,8 +44,8 @@
44
44
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
45
45
  })();
46
46
  </script>
47
- <script type="module" crossorigin src="/assets/index-BrjE0Aat.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-B5RadDLY.css">
47
+ <script type="module" crossorigin src="/assets/index-BSsa18Kr.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-B5yzqlDA.css">
49
49
  </head>
50
50
  <body>
51
51
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.27.0",
3
+ "version": "3.27.4",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,6 +56,30 @@
56
56
  "body": "Each account card's ⋯ menu now breaks its cost down by project, over 7d / 30d / all."
57
57
  }
58
58
  ],
59
+ "3.27.1": [
60
+ {
61
+ "title": "🎨 Make Appearance easier to tune",
62
+ "body": "The Appearance dialog now has clearer sections, stronger selection states, and a calmer layout for choosing a theme and configuring music. Your existing settings and keyboard shortcuts continue to work."
63
+ }
64
+ ],
65
+ "3.27.2": [
66
+ {
67
+ "title": "💰 Project costs now match Usage",
68
+ "body": "Project cost totals no longer count the same Claude request once for every content block. Existing project history is rebuilt automatically so the totals align with the Usage panel."
69
+ }
70
+ ],
71
+ "3.27.3": [
72
+ {
73
+ "title": "🎵 Station selection works in the desktop app",
74
+ "body": "The music station picker now opens inside ccdeck instead of relying on the operating system popup, so choosing a station works reliably in the Electron app."
75
+ }
76
+ ],
77
+ "3.27.4": [
78
+ {
79
+ "title": "✅ Desktop picker release checks are green",
80
+ "body": "The station picker now keeps the same keyboard, motion, focus, and layout behavior as the rest of ccdeck across desktop and browser builds."
81
+ }
82
+ ],
59
83
  "3.25.0": [
60
84
  {
61
85
  "title": "🖱️ A click goes to the session, a double-click opens the details",
@@ -34,6 +34,7 @@ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from ".
34
34
  * a per-account total is never quietly inflated by work that is not that
35
35
  * account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
36
36
  export const UNATTRIBUTED = "\u0000unattributed";
37
+ const STATE_VERSION = 2;
37
38
 
38
39
  /** Where the tally and cursors live, beside cswap-auto's own state. */
39
40
  export function statePath(home = homedir()) {
@@ -139,6 +140,10 @@ export function foldLine(tally, line, timeline, fallbackCwd) {
139
140
  if (!line || !line.includes('"usage"')) return;
140
141
  let obj = null;
141
142
  try { obj = JSON.parse(line); } catch { return; }
143
+ // Claude writes one assistant record for each content block in a request.
144
+ // Every block repeats the request's usage, so only block 0 is billable for
145
+ // this rollup. Older records without the field are kept compatible.
146
+ if (obj?.apiBlockIndex !== undefined && obj.apiBlockIndex !== 0) return;
142
147
  const usage = obj?.message?.usage;
143
148
  if (!usage || typeof usage !== "object") return;
144
149
  const ts = Date.parse(obj?.timestamp);
@@ -311,7 +316,7 @@ export function createProjectRollup({
311
316
  setInterval: setIv = setInterval,
312
317
  clearInterval: clearIv = clearInterval,
313
318
  } = {}) {
314
- let state = { version: 1, cursors: {}, tally: {} };
319
+ let state = { version: STATE_VERSION, cursors: {}, tally: {} };
315
320
  let timer = null;
316
321
  let running = false;
317
322
  let dirty = false;
@@ -322,7 +327,14 @@ export function createProjectRollup({
322
327
  loaded = true;
323
328
  try {
324
329
  const disk = JSON.parse(await readFile(stateFile, "utf8"));
325
- if (disk && disk.version === 1 && disk.tally && disk.cursors) state = disk;
330
+ if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
331
+ state = disk;
332
+ } else if (disk && disk.version === 1) {
333
+ // Version 1 counted every API content block. Keep the heartbeat so a
334
+ // restart gap remains fenced, but rebuild the tally from transcripts.
335
+ state = { version: STATE_VERSION, cursors: {}, tally: {}, lastAlive: disk.lastAlive };
336
+ dirty = true;
337
+ }
326
338
  } catch { /* first run: empty state */ }
327
339
  }
328
340
 
@@ -0,0 +1,63 @@
1
+ const NOSTALGIA_URL = "https://www.youtube.com/@bestofnostalgia";
2
+ const CACHE_MS = 600_000;
3
+ const MISS_CACHE_MS = 60_000;
4
+ const READ_LIMIT = 2_000_000;
5
+ const TIMEOUT_MS = 6_000;
6
+ const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
7
+
8
+ let cache = null;
9
+ let cacheAt = 0;
10
+ let cacheTtl = 0;
11
+ let inflight = null;
12
+
13
+ function currentLiveVideo(html) {
14
+ const marker = '"liveBadgeText":"LIVE"';
15
+ const live = html.indexOf(marker);
16
+ if (live < 0) return null;
17
+ const start = Math.max(0, live - 2_000);
18
+ const window = html.slice(start, live + 2_000);
19
+ let best = null;
20
+ for (const match of window.matchAll(/"videoId":"([\w-]{11})"/g)) {
21
+ const distance = Math.abs(start + match.index - live);
22
+ if (!best || distance < best.distance) best = { id: match[1], distance };
23
+ }
24
+ return best?.id ?? null;
25
+ }
26
+
27
+ export async function fetchBestOfNostalgia({ fetchImpl } = {}) {
28
+ const now = Date.now();
29
+ if (cache && now - cacheAt < cacheTtl) return cache;
30
+ if (inflight) return inflight;
31
+ const get = fetchImpl ?? fetch;
32
+ inflight = (async () => {
33
+ try {
34
+ const response = await get(NOSTALGIA_URL, {
35
+ headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
36
+ signal: AbortSignal.timeout(TIMEOUT_MS),
37
+ redirect: "follow",
38
+ });
39
+ if (!response.ok) return settle(null);
40
+ const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
41
+ return settle(video ? { id: "best-of-nostalgia", label: "Best of Nostalgia — Live", video } : null);
42
+ } catch {
43
+ return settle(null);
44
+ } finally {
45
+ inflight = null;
46
+ }
47
+ })();
48
+ return inflight;
49
+ }
50
+
51
+ function settle(value) {
52
+ cache = value;
53
+ cacheAt = Date.now();
54
+ cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
55
+ return value;
56
+ }
57
+
58
+ export function forgetBestOfNostalgia() {
59
+ cache = null;
60
+ cacheAt = 0;
61
+ cacheTtl = 0;
62
+ inflight = null;
63
+ }
@@ -0,0 +1,63 @@
1
+ const CAFE_MUSIC_URL = "https://www.youtube.com/channel/UCJhjE7wbdYAae1G25m0tHAA";
2
+ const CACHE_MS = 600_000;
3
+ const MISS_CACHE_MS = 60_000;
4
+ const READ_LIMIT = 2_000_000;
5
+ const TIMEOUT_MS = 6_000;
6
+ const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
7
+
8
+ let cache = null;
9
+ let cacheAt = 0;
10
+ let cacheTtl = 0;
11
+ let inflight = null;
12
+
13
+ function currentLiveVideo(html) {
14
+ const marker = '"liveBadgeText":"LIVE"';
15
+ const live = html.indexOf(marker);
16
+ if (live < 0) return null;
17
+ const start = Math.max(0, live - 2_000);
18
+ const window = html.slice(start, live + 2_000);
19
+ let best = null;
20
+ for (const match of window.matchAll(/"videoId":"([\w-]{11})"/g)) {
21
+ const distance = Math.abs(start + match.index - live);
22
+ if (!best || distance < best.distance) best = { id: match[1], distance };
23
+ }
24
+ return best?.id ?? null;
25
+ }
26
+
27
+ export async function fetchCafeMusicBgm({ fetchImpl } = {}) {
28
+ const now = Date.now();
29
+ if (cache && now - cacheAt < cacheTtl) return cache;
30
+ if (inflight) return inflight;
31
+ const get = fetchImpl ?? fetch;
32
+ inflight = (async () => {
33
+ try {
34
+ const response = await get(CAFE_MUSIC_URL, {
35
+ headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
36
+ signal: AbortSignal.timeout(TIMEOUT_MS),
37
+ redirect: "follow",
38
+ });
39
+ if (!response.ok) return settle(null);
40
+ const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
41
+ return settle(video ? { id: "cafe-music-bgm", label: "Cafe Music BGM — Live", video } : null);
42
+ } catch {
43
+ return settle(null);
44
+ } finally {
45
+ inflight = null;
46
+ }
47
+ })();
48
+ return inflight;
49
+ }
50
+
51
+ function settle(value) {
52
+ cache = value;
53
+ cacheAt = Date.now();
54
+ cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
55
+ return value;
56
+ }
57
+
58
+ export function forgetCafeMusicBgm() {
59
+ cache = null;
60
+ cacheAt = 0;
61
+ cacheTtl = 0;
62
+ inflight = null;
63
+ }
@@ -0,0 +1,63 @@
1
+ const GOOD_LIFE_URL = "https://www.youtube.com/@TheGoodLiferadio";
2
+ const CACHE_MS = 600_000;
3
+ const MISS_CACHE_MS = 60_000;
4
+ const READ_LIMIT = 2_000_000;
5
+ const TIMEOUT_MS = 6_000;
6
+ const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
7
+
8
+ let cache = null;
9
+ let cacheAt = 0;
10
+ let cacheTtl = 0;
11
+ let inflight = null;
12
+
13
+ function currentLiveVideo(html) {
14
+ const marker = '"liveBadgeText":"LIVE"';
15
+ const live = html.indexOf(marker);
16
+ if (live < 0) return null;
17
+ const start = Math.max(0, live - 2_000);
18
+ const window = html.slice(start, live + 2_000);
19
+ let best = null;
20
+ for (const match of window.matchAll(/"videoId":"([\w-]{11})"/g)) {
21
+ const distance = Math.abs(start + match.index - live);
22
+ if (!best || distance < best.distance) best = { id: match[1], distance };
23
+ }
24
+ return best?.id ?? null;
25
+ }
26
+
27
+ export async function fetchGoodLifeRadio({ fetchImpl } = {}) {
28
+ const now = Date.now();
29
+ if (cache && now - cacheAt < cacheTtl) return cache;
30
+ if (inflight) return inflight;
31
+ const get = fetchImpl ?? fetch;
32
+ inflight = (async () => {
33
+ try {
34
+ const response = await get(GOOD_LIFE_URL, {
35
+ headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
36
+ signal: AbortSignal.timeout(TIMEOUT_MS),
37
+ redirect: "follow",
38
+ });
39
+ if (!response.ok) return settle(null);
40
+ const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
41
+ return settle(video ? { id: "good-life-radio", label: "The Good Life Radio — Live", video } : null);
42
+ } catch {
43
+ return settle(null);
44
+ } finally {
45
+ inflight = null;
46
+ }
47
+ })();
48
+ return inflight;
49
+ }
50
+
51
+ function settle(value) {
52
+ cache = value;
53
+ cacheAt = Date.now();
54
+ cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
55
+ return value;
56
+ }
57
+
58
+ export function forgetGoodLifeRadio() {
59
+ cache = null;
60
+ cacheAt = 0;
61
+ cacheTtl = 0;
62
+ inflight = null;
63
+ }
@@ -309,6 +309,39 @@ export function payloadChars(raw) {
309
309
  return n;
310
310
  }
311
311
 
312
+ async function handleLiveRadioMix(req, res) {
313
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
314
+ return send(res, 200, { ok: true, live: false, off: true });
315
+ }
316
+
317
+ const { fetchLiveRadioMix } = await import(
318
+ pathToFileURL(join(PKG_ROOT, "src/server/live-radio-mix.mjs")).href
319
+ );
320
+ const answer = await fetchLiveRadioMix();
321
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Radio Mix is not live" });
322
+ }
323
+
324
+ async function handleBestOfNostalgia(req, res) {
325
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, live: false, off: true });
326
+ const { fetchBestOfNostalgia } = await import(pathToFileURL(join(PKG_ROOT, "src/server/best-of-nostalgia.mjs")).href);
327
+ const answer = await fetchBestOfNostalgia();
328
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Best of Nostalgia is not live" });
329
+ }
330
+
331
+ async function handleGoodLifeRadio(req, res) {
332
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, live: false, off: true });
333
+ const { fetchGoodLifeRadio } = await import(pathToFileURL(join(PKG_ROOT, "src/server/good-life-radio.mjs")).href);
334
+ const answer = await fetchGoodLifeRadio();
335
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "The Good Life Radio is not live" });
336
+ }
337
+
338
+ async function handleCafeMusicBgm(req, res) {
339
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, live: false, off: true });
340
+ const { fetchCafeMusicBgm } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cafe-music-bgm.mjs")).href);
341
+ const answer = await fetchCafeMusicBgm();
342
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Cafe Music BGM is not live" });
343
+ }
344
+
312
345
  // Where the charge rides. A Symbol key rather than an ordinary field, because
313
346
  // the envelope is JSON.stringify'd on the hot path into both the SSE frame and
314
347
  // the events.jsonl line, and JSON.stringify ignores symbol-keyed properties
@@ -5828,6 +5861,23 @@ async function handleClaudeFm(req, res) {
5828
5861
  send(res, 200, answer);
5829
5862
  }
5830
5863
 
5864
+ async function handleLofiGirl(req, res) {
5865
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
5866
+ return send(res, 200, { ok: true, live: false, off: true });
5867
+ }
5868
+ const url = new URL(req.url, "http://localhost");
5869
+ const station = url.searchParams.get("station");
5870
+ if (!["relax", "game", "vibe", "sleep"].includes(station)) {
5871
+ return send(res, 400, { ok: false, error: "unknown Lofi Girl station" });
5872
+ }
5873
+ const { fetchLofiStations } = await import(
5874
+ pathToFileURL(join(PKG_ROOT, "src/server/lofi-girl.mjs")).href
5875
+ );
5876
+ const stations = await fetchLofiStations();
5877
+ const answer = stations[station];
5878
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "station is not live" });
5879
+ }
5880
+
5831
5881
  async function handleCodexQuota(req, res) {
5832
5882
  const { fetchCodexQuota } = await import(
5833
5883
  pathToFileURL(join(PKG_ROOT, "src/server/codex-quota.mjs")).href
@@ -6215,6 +6265,11 @@ const PINNED_MODULES = [
6215
6265
  "browser-watch.mjs",
6216
6266
  "browser-watch-store.mjs",
6217
6267
  "claude-fm.mjs",
6268
+ "lofi-girl.mjs",
6269
+ "live-radio-mix.mjs",
6270
+ "best-of-nostalgia.mjs",
6271
+ "good-life-radio.mjs",
6272
+ "cafe-music-bgm.mjs",
6218
6273
  // system-metrics.mjs's two, on the platforms that have them, and the one
6219
6274
  // installer.mjs reaches for while it rewrites the hooks.
6220
6275
  "macmon.mjs",
@@ -7539,6 +7594,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7539
7594
  if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
7540
7595
  if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
7541
7596
  if (req.method === "GET" && url.pathname === "/api/claude-fm") return guard(handleClaudeFm(req, res), res);
7597
+ if (req.method === "GET" && url.pathname === "/api/lofi-girl") return guard(handleLofiGirl(req, res), res);
7598
+ if (req.method === "GET" && url.pathname === "/api/live-radio-mix") return guard(handleLiveRadioMix(req, res), res);
7599
+ if (req.method === "GET" && url.pathname === "/api/best-of-nostalgia") return guard(handleBestOfNostalgia(req, res), res);
7600
+ if (req.method === "GET" && url.pathname === "/api/good-life-radio") return guard(handleGoodLifeRadio(req, res), res);
7601
+ if (req.method === "GET" && url.pathname === "/api/cafe-music-bgm") return guard(handleCafeMusicBgm(req, res), res);
7542
7602
 
7543
7603
  // Through writeJsonArray rather than `send`, and through `guard` like every
7544
7604
  // route above it: this is the one answer whose size is the ring's size, and
@@ -0,0 +1,56 @@
1
+ const RADIO_MIX_URL = "https://www.youtube.com/@liveradiomix";
2
+ const CACHE_MS = 600_000;
3
+ const MISS_CACHE_MS = 60_000;
4
+ const READ_LIMIT = 2_000_000;
5
+ const TIMEOUT_MS = 6_000;
6
+ const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
7
+
8
+ let cache = null;
9
+ let cacheAt = 0;
10
+ let cacheTtl = 0;
11
+ let inflight = null;
12
+
13
+ function currentLiveVideo(html) {
14
+ const live = html.indexOf('"liveBadgeText":"LIVE"');
15
+ if (live < 0) return null;
16
+ const window = html.slice(Math.max(0, live - 2_000), live + 2_000);
17
+ return window.match(/"videoId":"([\w-]{11})"/)?.[1] ?? null;
18
+ }
19
+
20
+ export async function fetchLiveRadioMix({ fetchImpl } = {}) {
21
+ const now = Date.now();
22
+ if (cache && now - cacheAt < cacheTtl) return cache;
23
+ if (inflight) return inflight;
24
+ const get = fetchImpl ?? fetch;
25
+ inflight = (async () => {
26
+ try {
27
+ const response = await get(RADIO_MIX_URL, {
28
+ headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
29
+ signal: AbortSignal.timeout(TIMEOUT_MS),
30
+ redirect: "follow",
31
+ });
32
+ if (!response.ok) return settle(null);
33
+ const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
34
+ return settle(video ? { id: "radio-mix", label: "Radio Mix — Live", video } : null);
35
+ } catch {
36
+ return settle(null);
37
+ } finally {
38
+ inflight = null;
39
+ }
40
+ })();
41
+ return inflight;
42
+ }
43
+
44
+ function settle(value) {
45
+ cache = value;
46
+ cacheAt = Date.now();
47
+ cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
48
+ return value;
49
+ }
50
+
51
+ export function forgetLiveRadioMix() {
52
+ cache = null;
53
+ cacheAt = 0;
54
+ cacheTtl = 0;
55
+ inflight = null;
56
+ }
@@ -0,0 +1,76 @@
1
+ const LOFI_GIRL_URL = "https://www.youtube.com/@LofiGirl";
2
+ const CACHE_MS = 600_000;
3
+ const MISS_CACHE_MS = 60_000;
4
+ const READ_LIMIT = 2_000_000;
5
+ const TIMEOUT_MS = 6_000;
6
+ const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
7
+
8
+ export const LOFI_STATIONS = {
9
+ relax: { label: "Lofi Girl — relax/study" , terms: ["relax/study"] },
10
+ game: { label: "Lofi Girl — chill/game", terms: ["chill/game"] },
11
+ vibe: { label: "Lofi Girl — vibe/chill", terms: ["vibe/chill"] },
12
+ sleep: { label: "Lofi Girl — sleep/chill", terms: ["sleep/chill"] },
13
+ };
14
+
15
+ let cache = null;
16
+ let cacheAt = 0;
17
+ let cacheTtl = 0;
18
+ let inflight = null;
19
+
20
+ function stationVideo(html, terms) {
21
+ for (const term of terms) {
22
+ const index = html.toLowerCase().indexOf(term);
23
+ if (index < 0) continue;
24
+ const start = Math.max(0, index - 4_000);
25
+ const window = html.slice(start, index + 4_000);
26
+ const ids = [...window.matchAll(/"videoId":"([\w-]{11})"/g)];
27
+ if (ids.length) {
28
+ ids.sort((a, b) => Math.abs(start + a.index - index) - Math.abs(start + b.index - index));
29
+ return ids[0][1];
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export async function fetchLofiStations({ fetchImpl } = {}) {
36
+ const now = Date.now();
37
+ if (cache && now - cacheAt < cacheTtl) return cache;
38
+ if (inflight) return inflight;
39
+ const get = fetchImpl ?? fetch;
40
+ inflight = (async () => {
41
+ try {
42
+ const response = await get(LOFI_GIRL_URL, {
43
+ headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
44
+ signal: AbortSignal.timeout(TIMEOUT_MS),
45
+ redirect: "follow",
46
+ });
47
+ if (!response.ok) return settle({});
48
+ const html = (await response.text()).slice(0, READ_LIMIT);
49
+ const stations = Object.fromEntries(
50
+ Object.entries(LOFI_STATIONS)
51
+ .map(([id, station]) => [id, { id, label: station.label, video: stationVideo(html, station.terms) }])
52
+ .filter(([, station]) => station.video),
53
+ );
54
+ return settle(stations);
55
+ } catch {
56
+ return settle({});
57
+ } finally {
58
+ inflight = null;
59
+ }
60
+ })();
61
+ return inflight;
62
+ }
63
+
64
+ function settle(value) {
65
+ cache = value;
66
+ cacheAt = Date.now();
67
+ cacheTtl = Object.keys(value).length ? CACHE_MS : MISS_CACHE_MS;
68
+ return value;
69
+ }
70
+
71
+ export function forgetLofiStations() {
72
+ cache = null;
73
+ cacheAt = 0;
74
+ cacheTtl = 0;
75
+ inflight = null;
76
+ }