ccdeck 3.26.5 → 3.27.2

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.
@@ -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
@@ -6065,10 +6115,49 @@ async function handleClaudeAccountSwitch(req, res) {
6065
6115
  pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
6066
6116
  );
6067
6117
  invalidateQuotaCache();
6118
+ // Note who became active, and when, for the account-projects report. Best
6119
+ // effort — a failed log write must never fail the switch — so it is not
6120
+ // awaited into the response.
6121
+ import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href)
6122
+ .then(({ recordSwap }) => recordSwap(parsed.account, "manual"))
6123
+ .catch(() => {});
6068
6124
  }
6069
6125
  send(res, result.ok ? 200 : 400, result);
6070
6126
  }
6071
6127
 
6128
+ // The account-projects rollup, started once and shared. Its timer is unref'd
6129
+ // inside start(), so holding the instance here never keeps the process alive.
6130
+ let _projectRollup = null;
6131
+ function getProjectRollup() {
6132
+ if (!_projectRollup) {
6133
+ _projectRollup = import(pathToFileURL(join(PKG_ROOT, "src/server/account-projects.mjs")).href)
6134
+ .then(m => { const r = m.createProjectRollup(); r.start().catch(() => {}); return r; });
6135
+ }
6136
+ return _projectRollup;
6137
+ }
6138
+
6139
+ /**
6140
+ * The "Projects" report for one account: how many tokens it spent per project
6141
+ * over a window. The server tallies only tokens (per model); the web side
6142
+ * prices them with its own table, so cost never lives in two places. `num` is
6143
+ * the account's slot, resolved to its `(email, org)` key here; `days` is 7, 30,
6144
+ * or 0 for all tracked.
6145
+ */
6146
+ async function handleAccountProjects(req, res) {
6147
+ const url = new URL(req.url, "http://localhost");
6148
+ const num = Number(url.searchParams.get("num"));
6149
+ const d = Number(url.searchParams.get("days"));
6150
+ const days = d === 30 ? 30 : d === 0 ? 0 : 7;
6151
+ if (!Number.isInteger(num) || num <= 0) return send(res, 400, { ok: false, reason: "bad_account" });
6152
+ const { identityForSlot } = await import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href);
6153
+ const id = await identityForSlot(num);
6154
+ if (!id) return send(res, 400, { ok: false, reason: "bad_account" });
6155
+ const { accountKey } = await import(pathToFileURL(join(PKG_ROOT, "src/server/lan-sync.mjs")).href);
6156
+ const rollup = await getProjectRollup();
6157
+ const report = await rollup.report(accountKey(id.email, id.orgUuid), days);
6158
+ send(res, 200, { ok: true, ...report });
6159
+ }
6160
+
6072
6161
  function cswapAdminModule() {
6073
6162
  return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-admin.mjs")).href);
6074
6163
  }
@@ -6176,11 +6265,22 @@ const PINNED_MODULES = [
6176
6265
  "browser-watch.mjs",
6177
6266
  "browser-watch-store.mjs",
6178
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",
6179
6273
  // system-metrics.mjs's two, on the platforms that have them, and the one
6180
6274
  // installer.mjs reaches for while it rewrites the hooks.
6181
6275
  "macmon.mjs",
6182
6276
  "hwmonitor.mjs",
6183
6277
  "retire-sound-hook.mjs",
6278
+ // The account-projects report: the rollup and the swap log its attribution
6279
+ // reads, plus lan-sync for the account key — all reached only through
6280
+ // import() from the report's route.
6281
+ "account-projects.mjs",
6282
+ "swap-log.mjs",
6283
+ "lan-sync.mjs",
6184
6284
  ];
6185
6285
 
6186
6286
  let _pinned = null;
@@ -7127,6 +7227,9 @@ const GUARDED_READS = new Set([
7127
7227
  "/api/browser-watch",
7128
7228
  "/api/lan",
7129
7229
  "/api/prefs",
7230
+ // Per-account, per-project token spend — the user's own work, the same class
7231
+ // of secret as the accounts list it hangs off.
7232
+ "/api/account-projects",
7130
7233
  ]);
7131
7234
 
7132
7235
  function isAuthorizedMutation(req) {
@@ -7483,6 +7586,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7483
7586
  if (req.method === "GET" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatch(req, res), res);
7484
7587
  if (req.method === "POST" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatchSettings(req, res), res);
7485
7588
  if (req.method === "POST" && url.pathname === "/api/browser-watch/dismiss") return guard(handleBrowserWatchDismiss(req, res), res);
7589
+ if (req.method === "GET" && url.pathname === "/api/account-projects") return guard(handleAccountProjects(req, res), res);
7486
7590
  if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
7487
7591
  if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
7488
7592
  if (req.method === "GET" && url.pathname === "/api/claude-accounts/login") return guard(handleAccountLoginState(req, res), res);
@@ -7490,6 +7594,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7490
7594
  if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
7491
7595
  if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
7492
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);
7493
7602
 
7494
7603
  // Through writeJsonArray rather than `send`, and through `guard` like every
7495
7604
  // route above it: this is the one answer whose size is the ring's size, and
@@ -7603,6 +7712,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7603
7712
  // being installed or upgraded underneath them (#1043) — null from every
7604
7713
  // caller with nothing to wait for.
7605
7714
  cswapAutoModule().then(m => m.initCswapAuto({ after: cswapQuiet })).catch(() => {});
7715
+ // The account-projects rollup: fold Claude transcripts into a per-account,
7716
+ // per-project token tally, incrementally. Claude only — it reads Claude
7717
+ // transcripts — best effort, and its timer is unref'd so it never holds
7718
+ // the process open.
7719
+ if (claude) getProjectRollup().catch(() => {});
7606
7720
  return server;
7607
7721
  } catch (err) {
7608
7722
  lastErr = err;
@@ -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
+ }
@@ -0,0 +1,188 @@
1
+ // The timeline of which account was active, and when.
2
+ //
3
+ // Nothing in claude-swap or the deck ever recorded this. sequence.json holds a
4
+ // single `activeAccountNumber` — who is active *now* — and overwrites it on
5
+ // every switch, so the moment a swap happens the previous truth is gone. The
6
+ // account-projects report needs the opposite: a durable, append-only record of
7
+ // "at time T the active account became X", so a transcript message written at
8
+ // some past instant can be attributed to whoever was active *then*.
9
+ //
10
+ // The deck is the one thing that performs a swap (it drives `cswap switch` and
11
+ // the auto tick), so it does not have to reconstruct or guess: it writes one
12
+ // line each time it moves the live account. The past before this file existed
13
+ // is unrecoverable — there is no timestamped history anywhere to mine — so the
14
+ // report is honest only from the first line here onward, and everything earlier
15
+ // is left unattributed.
16
+ //
17
+ // One line per swap, JSON:
18
+ // { "at": <ms>, "slot": <n>, "email": "...", "orgUuid": "...", "source": "manual"|"auto"|"start" }
19
+ // `source` "start" is the anchor the rollup writes once at boot for whoever is
20
+ // already active, so a machine that never swaps still attributes its work.
21
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { backupRoot } from "./claude-accounts.mjs";
25
+
26
+ /** The log the deck appends swaps to. Under ~/.agents-deck like cswap-auto's
27
+ * own state, not under the claude-swap store — this is the deck's record, not
28
+ * claude-swap's. */
29
+ export function swapLogPath(home = homedir()) {
30
+ return join(home, ".agents-deck", "log", "account-active.jsonl");
31
+ }
32
+
33
+ /** Read one JSON value from a file, or null if it is missing or unparseable. */
34
+ async function readJsonSafe(path) {
35
+ try {
36
+ return JSON.parse(await readFile(path, "utf8"));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** The `(email, organizationUuid)` a slot number points at right now, read from
43
+ * claude-swap's sequence.json. Null when the store or the slot is not there. */
44
+ export async function identityForSlot(slot, root = backupRoot()) {
45
+ const seq = await readJsonSafe(join(root, "sequence.json"));
46
+ const acc = seq?.accounts?.[String(slot)];
47
+ if (!acc) return null;
48
+ return {
49
+ email: typeof acc.email === "string" ? acc.email : "",
50
+ orgUuid: typeof acc.organizationUuid === "string" ? acc.organizationUuid : "",
51
+ };
52
+ }
53
+
54
+ /** The slot claude-swap says is active right now, or null. */
55
+ export async function currentActiveSlot(root = backupRoot()) {
56
+ const seq = await readJsonSafe(join(root, "sequence.json"));
57
+ const n = Number(seq?.activeAccountNumber);
58
+ return Number.isInteger(n) && n > 0 ? n : null;
59
+ }
60
+
61
+ /** Parse the log into swap entries sorted oldest-first. A truncated or garbage
62
+ * last line (a crash mid-append) is skipped, never fatal. */
63
+ export async function readSwapLog(path = swapLogPath()) {
64
+ const text = await readFile(path, "utf8").catch(() => "");
65
+ const out = [];
66
+ for (const line of text.split("\n")) {
67
+ if (!line) continue;
68
+ let obj = null;
69
+ try { obj = JSON.parse(line); } catch { continue; }
70
+ if (!obj || typeof obj !== "object") continue;
71
+ const at = Number(obj.at);
72
+ if (!Number.isFinite(at)) continue;
73
+ out.push({
74
+ at,
75
+ slot: Number(obj.slot) || 0,
76
+ email: typeof obj.email === "string" ? obj.email : "",
77
+ orgUuid: typeof obj.orgUuid === "string" ? obj.orgUuid : "",
78
+ source: typeof obj.source === "string" ? obj.source : "",
79
+ });
80
+ }
81
+ out.sort((a, b) => a.at - b.at);
82
+ return out;
83
+ }
84
+
85
+ /** Append one swap line. Creates the log directory on first use. Best-effort:
86
+ * a failure to record must never break a switch, so callers ignore rejection. */
87
+ export async function appendSwap(entry, path = swapLogPath()) {
88
+ await mkdir(join(path, ".."), { recursive: true });
89
+ await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
90
+ }
91
+
92
+ /**
93
+ * Record that `slot` became the active account, resolving its identity from the
94
+ * store. `source` says which path moved it. Returns the entry written, or null
95
+ * when the slot has no identity (nothing worth attributing to).
96
+ *
97
+ * Best-effort by contract: every caller sits on a hot path (a click, an auto
98
+ * tick) that must not fail because a log write did. Errors are swallowed here
99
+ * so callers can `void recordSwap(...)` without a catch.
100
+ */
101
+ export async function recordSwap(slot, source, { now = Date.now, root, path } = {}) {
102
+ try {
103
+ const store = root ?? backupRoot();
104
+ // Trust the store's own idea of who is active over a caller-supplied slot
105
+ // when that slot is missing or malformed — after a switch, sequence.json's
106
+ // activeAccountNumber is the authoritative new slot, so the auto tick can
107
+ // record without threading the number through cswap's JSON.
108
+ let n = Number(slot);
109
+ if (!Number.isInteger(n) || n <= 0) n = await currentActiveSlot(store);
110
+ if (!n) return null;
111
+ const id = await identityForSlot(n, store);
112
+ if (!id) return null;
113
+ const entry = { at: now(), slot: n, email: id.email, orgUuid: id.orgUuid, source };
114
+ await appendSwap(entry, path ?? swapLogPath());
115
+ return entry;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Seed an anchor for the currently-active account, once, if the log does not
123
+ * already end on that account. Called at boot so a deck that never swaps still
124
+ * attributes its work to the account it launched on, and so the very first
125
+ * upgrade writes a "tracked since" point.
126
+ *
127
+ * Deduped against the last entry's `(email, orgUuid)`: a restart on the same
128
+ * account adds nothing, but a manual switch made while the deck was down (so no
129
+ * line was written for it) is still captured here on the next boot.
130
+ */
131
+ export async function seedActive({ now = Date.now, root, path } = {}) {
132
+ try {
133
+ const logPath = path ?? swapLogPath();
134
+ const slot = await currentActiveSlot(root ?? backupRoot());
135
+ if (!slot) return null;
136
+ const id = await identityForSlot(slot, root ?? backupRoot());
137
+ if (!id) return null;
138
+ const entries = await readSwapLog(logPath);
139
+ const last = entries[entries.length - 1];
140
+ if (last && last.email === id.email && last.orgUuid === id.orgUuid) return null;
141
+ const entry = { at: now(), slot, email: id.email, orgUuid: id.orgUuid, source: "start" };
142
+ await appendSwap(entry, logPath);
143
+ return entry;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Which account was active at millisecond `ts`: the last entry at or before it.
151
+ * Null when `ts` precedes every entry — before tracking began — OR when the
152
+ * governing entry is a `stop` marker, which the rollup writes to fence off the
153
+ * time the deck was not running: nothing recorded who was active then, so work
154
+ * with a timestamp inside a stop→start gap is honestly unattributable rather
155
+ * than charged to whichever account happened to be active when the deck closed.
156
+ * `entries` must be sorted oldest-first, as `readSwapLog` returns them.
157
+ */
158
+ export function accountAtTime(entries, ts) {
159
+ if (!Array.isArray(entries) || !entries.length) return null;
160
+ if (ts < entries[0].at) return null;
161
+ // Binary search for the rightmost entry with at <= ts.
162
+ let lo = 0, hi = entries.length - 1, ans = 0;
163
+ while (lo <= hi) {
164
+ const mid = (lo + hi) >> 1;
165
+ if (entries[mid].at <= ts) { ans = mid; lo = mid + 1; }
166
+ else hi = mid - 1;
167
+ }
168
+ const e = entries[ans];
169
+ if (e.source === "stop") return null; // inside a gap the deck was not running
170
+ return { email: e.email, orgUuid: e.orgUuid, slot: e.slot };
171
+ }
172
+
173
+ /** Fence off a stretch of time as "the deck was not running" from `at`, so
174
+ * work timestamped after it is unattributed until the next real entry. */
175
+ export async function markGap(at, path = swapLogPath()) {
176
+ try {
177
+ await appendSwap({ at, slot: 0, email: "", orgUuid: "", source: "stop" }, path);
178
+ return true;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /** The earliest instant the log covers — what the report shows as "tracked
185
+ * since". Null on an empty log. */
186
+ export function trackedSince(entries) {
187
+ return Array.isArray(entries) && entries.length ? entries[0].at : null;
188
+ }