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.
- package/dist/web/assets/{BrowserWatchModal-B6MuDGvq.js → BrowserWatchModal-dGoIoVqT.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BJA3mUzs.js → UsageHistoryModal-zfjtA5Nv.js} +1 -1
- package/dist/web/assets/index-CQSnj_z2.js +297 -0
- package/dist/web/assets/index-D_RU_J-E.css +1 -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 +422 -0
- package/src/server/best-of-nostalgia.mjs +63 -0
- package/src/server/cafe-music-bgm.mjs +63 -0
- package/src/server/cswap-auto.mjs +9 -0
- package/src/server/good-life-radio.mjs +63 -0
- package/src/server/index.mjs +114 -0
- package/src/server/live-radio-mix.mjs +56 -0
- package/src/server/lofi-girl.mjs +76 -0
- package/src/server/swap-log.mjs +188 -0
- package/dist/web/assets/index-D4fPrztm.js +0 -297
- package/dist/web/assets/index-DlaHgzye.css +0 -1
package/src/server/index.mjs
CHANGED
|
@@ -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
|
+
}
|