ccdeck 3.28.1 → 3.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/THIRD_PARTY_NOTICES.md +148 -4
- package/dist/web/assets/{BrowserWatchModal-CIla-_hm.js → BrowserWatchModal-C-TkzDPi.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-CSgh5kjS.js → UsageHistoryModal-FKBbTozZ.js} +1 -1
- package/dist/web/assets/hls-BFpXDghs.js +42 -0
- package/dist/web/assets/index-7mOqoJ3Y.js +298 -0
- package/dist/web/assets/index-FeNTTJ0x.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/release-notes.json +36 -0
- package/src/server/account-health.mjs +38 -0
- package/src/server/account-projects.mjs +89 -23
- package/src/server/claude-accounts.mjs +84 -26
- package/src/server/claude-dir.mjs +35 -0
- package/src/server/cswap-admin.mjs +136 -8
- package/src/server/deck-prefs.mjs +2 -0
- package/src/server/fm-station.mjs +186 -0
- package/src/server/index.mjs +208 -55
- package/src/server/lan-engine.mjs +244 -38
- package/src/server/lan-socket.mjs +101 -15
- package/src/server/lan-sync.mjs +86 -9
- package/dist/web/assets/index-CsHTYwoD.js +0 -297
- package/dist/web/assets/index-DbwzUUcd.css +0 -1
package/src/server/index.mjs
CHANGED
|
@@ -22,7 +22,10 @@ import { challengeDeck, challengeProof, isProcessAlive } from "./deck-probe.mjs"
|
|
|
22
22
|
// re-exporting them would be inventing API on the way out of a move.
|
|
23
23
|
export { challengeDeck, challengeProof, isProcessAlive };
|
|
24
24
|
import { promisify } from "node:util";
|
|
25
|
-
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
25
|
+
import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
|
|
26
|
+
// Moved to claude-dir.mjs so the Projects rollup can read transcript folders
|
|
27
|
+
// without importing this file; re-exported under the name it always had.
|
|
28
|
+
export { ccProjectSlug };
|
|
26
29
|
import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
27
30
|
import { PRODUCT } from "./brand.mjs";
|
|
28
31
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
@@ -432,6 +435,67 @@ function notifyTrays(title, body, { chime = null, who = null } = {}) {
|
|
|
432
435
|
return true;
|
|
433
436
|
}
|
|
434
437
|
|
|
438
|
+
// The desktop app's updater lives in Electron, while the window is a page
|
|
439
|
+
// served by this deck. Keep only the small piece of state the page needs. A
|
|
440
|
+
// report is accepted only with the deck token; a browser may request an
|
|
441
|
+
// install, but Electron verifies the exact ready version again before acting.
|
|
442
|
+
const DESKTOP_UPDATE_STATUSES = new Set(["idle", "checking", "current", "downloading", "ready", "error"]);
|
|
443
|
+
let desktopUpdateState = { status: "idle", version: null };
|
|
444
|
+
|
|
445
|
+
function cleanDesktopUpdate(value) {
|
|
446
|
+
if (!value || typeof value !== "object" || !DESKTOP_UPDATE_STATUSES.has(value.status)) return null;
|
|
447
|
+
const version = typeof value.version === "string" && value.version.trim()
|
|
448
|
+
? value.version.trim().slice(0, 80)
|
|
449
|
+
: null;
|
|
450
|
+
if (value.status === "ready" && !version) return null;
|
|
451
|
+
return { status: value.status, version };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function broadcastDesktopUpdate() {
|
|
455
|
+
const frame = `event: desktop-update\ndata: ${JSON.stringify(desktopUpdateState)}\n\n`;
|
|
456
|
+
for (const client of sseClients) {
|
|
457
|
+
if (!trayClients.has(client)) writeSse(client, frame);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function handleDesktopUpdateRead(_req, res) {
|
|
462
|
+
send(res, 200, desktopUpdateState);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function handleDesktopUpdateReport(req, res) {
|
|
466
|
+
// Same-origin pages pass the generic mutation gate, but only the native app
|
|
467
|
+
// may claim what its updater has verified.
|
|
468
|
+
if (!presentsDeckToken(req.headers ?? {})) {
|
|
469
|
+
return send(res, 401, { ok: false, reason: "app_token_required" });
|
|
470
|
+
}
|
|
471
|
+
const body = await readBody(req, res).catch(() => null);
|
|
472
|
+
let value = null;
|
|
473
|
+
try { value = cleanDesktopUpdate(JSON.parse(body ?? "")); } catch { /* bad JSON */ }
|
|
474
|
+
if (!value) return send(res, 400, { ok: false, reason: "bad_update_state" });
|
|
475
|
+
desktopUpdateState = value;
|
|
476
|
+
broadcastDesktopUpdate();
|
|
477
|
+
send(res, 200, { ok: true });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// The window's two messages about that update, relayed to the app as frames
|
|
481
|
+
// on its tray stream: apply it (`desktop-update-restart`), or it has been shown
|
|
482
|
+
// (`desktop-update-seen`, so the app's own ready notice stands down, #1182).
|
|
483
|
+
// Both behind the same gates as /api/restart, and neither decides anything:
|
|
484
|
+
// this refuses only what cannot be current, and Electron checks the exact
|
|
485
|
+
// ready version again before acting on either.
|
|
486
|
+
async function handleDesktopUpdateRequest(req, res, event) {
|
|
487
|
+
const body = await readBody(req, res).catch(() => null);
|
|
488
|
+
let version = "";
|
|
489
|
+
try { version = String(JSON.parse(body ?? "")?.version ?? "").trim(); } catch { /* bad JSON */ }
|
|
490
|
+
if (!version || desktopUpdateState.status !== "ready" || desktopUpdateState.version !== version) {
|
|
491
|
+
return send(res, 409, { ok: false, reason: "update_not_ready" });
|
|
492
|
+
}
|
|
493
|
+
if (trayClients.size === 0) return send(res, 409, { ok: false, reason: "app_disconnected" });
|
|
494
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify({ version })}\n\n`;
|
|
495
|
+
for (const client of trayClients) writeSse(client, frame);
|
|
496
|
+
send(res, 202, { ok: true });
|
|
497
|
+
}
|
|
498
|
+
|
|
435
499
|
let persistPath = null; // absolute path to events.jsonl, or null
|
|
436
500
|
|
|
437
501
|
// ─── Which deck records which session ─────────────────────────────────────
|
|
@@ -1782,40 +1846,6 @@ export async function readContextFromTranscript(path) {
|
|
|
1782
1846
|
return { ...state.ctx };
|
|
1783
1847
|
}
|
|
1784
1848
|
|
|
1785
|
-
/** Longest slug CC will store verbatim. Anything longer is truncated here and
|
|
1786
|
-
* given a hash suffix, so two deep paths sharing a 200-character prefix still
|
|
1787
|
-
* get separate directories. */
|
|
1788
|
-
const CC_SLUG_MAX = 200;
|
|
1789
|
-
|
|
1790
|
-
/** CC's hash of the *unencoded* path, used only for the truncation suffix:
|
|
1791
|
-
* the classic h*31 + c string hash, kept in a signed 32-bit int. */
|
|
1792
|
-
function ccPathHash(s) {
|
|
1793
|
-
let h = 0;
|
|
1794
|
-
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
1795
|
-
return h;
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
/** Encode an absolute path the way CC stores it under
|
|
1799
|
-
* ~/.claude/projects/<slug>/, so the auto-memory scan below looks in the
|
|
1800
|
-
* directory CC actually wrote.
|
|
1801
|
-
*
|
|
1802
|
-
* CC flattens *every* non-alphanumeric character to "-", not just the path
|
|
1803
|
-
* separators and the Windows drive colon this used to replace. Dots are the
|
|
1804
|
-
* ones that bite: a worktree under .claude/worktrees/, or any folder named
|
|
1805
|
-
* like my.app, produced a slug with a literal dot in it, the readdir below
|
|
1806
|
-
* missed, and the project's auto-memory files were quietly left out of the
|
|
1807
|
-
* context panel. Underscores and spaces were wrong the same way. */
|
|
1808
|
-
export function ccProjectSlug(cwd) {
|
|
1809
|
-
if (!cwd) return "";
|
|
1810
|
-
// resolve() gives the platform's own absolute form — "/Users/…" on
|
|
1811
|
-
// macOS/Linux, "C:\Users\…" on Windows — and this class covers both, so the
|
|
1812
|
-
// drive colon and the backslashes fall out of the general rule.
|
|
1813
|
-
const abs = resolve(cwd);
|
|
1814
|
-
const slug = abs.replace(/[^a-zA-Z0-9]/g, "-");
|
|
1815
|
-
if (slug.length <= CC_SLUG_MAX) return slug;
|
|
1816
|
-
return `${slug.slice(0, CC_SLUG_MAX)}-${Math.abs(ccPathHash(abs)).toString(36)}`;
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
1849
|
/**
|
|
1820
1850
|
* Candidate memory-file paths on the walk from `cwd` up to the filesystem root.
|
|
1821
1851
|
*
|
|
@@ -3889,6 +3919,31 @@ async function handlePresence(req, res) {
|
|
|
3889
3919
|
* Tailscale is here at all before anybody can turn discovery on. */
|
|
3890
3920
|
const tailnet = createTailnet();
|
|
3891
3921
|
|
|
3922
|
+
// Whether every import is followed by checkImports, which only asks on a Mac.
|
|
3923
|
+
// When it does, the import skips its own detached collection — see importAccount.
|
|
3924
|
+
const CHECKS_IMPORTS = process.platform === "darwin";
|
|
3925
|
+
|
|
3926
|
+
const ACTIVE_VERDICT_FIRST_RETRY_MS = 60_000;
|
|
3927
|
+
const ACTIVE_VERDICT_MAX_RETRY_MS = 30 * 60_000;
|
|
3928
|
+
const LIVE_LOGIN_TIMEOUT_MS = 5_000;
|
|
3929
|
+
let activeVerdictRetryMs = ACTIVE_VERDICT_FIRST_RETRY_MS;
|
|
3930
|
+
let activeVerdictNextAt = 0;
|
|
3931
|
+
// A claude-swap that never reports a verdict for the active slot would
|
|
3932
|
+
// otherwise cost a usage collection every minute for as long as LAN is on.
|
|
3933
|
+
function refreshActiveVerdict() {
|
|
3934
|
+
if (Date.now() < activeVerdictNextAt) return;
|
|
3935
|
+
activeVerdictNextAt = Date.now() + activeVerdictRetryMs;
|
|
3936
|
+
activeVerdictRetryMs = Math.min(activeVerdictRetryMs * 2, ACTIVE_VERDICT_MAX_RETRY_MS);
|
|
3937
|
+
void import("./claude-accounts.mjs")
|
|
3938
|
+
.then(({ verdictsNow, invalidateClaudeAccountsCache }) =>
|
|
3939
|
+
verdictsNow().then(got => { if (got) invalidateClaudeAccountsCache(); }))
|
|
3940
|
+
.catch(() => {});
|
|
3941
|
+
}
|
|
3942
|
+
function activeVerdictArrived() {
|
|
3943
|
+
activeVerdictRetryMs = ACTIVE_VERDICT_FIRST_RETRY_MS;
|
|
3944
|
+
activeVerdictNextAt = 0;
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3892
3947
|
const lanEngine = createEngine({
|
|
3893
3948
|
tailnet,
|
|
3894
3949
|
// Who holds the discovery port when it is taken, so the panel can say.
|
|
@@ -3901,17 +3956,63 @@ const lanEngine = createEngine({
|
|
|
3901
3956
|
about: aboutThisDeck({ version: RUNNING_VERSION }),
|
|
3902
3957
|
readAccounts: async () => {
|
|
3903
3958
|
const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
|
|
3904
|
-
|
|
3959
|
+
const got = await fetchClaudeAccounts();
|
|
3960
|
+
// A login this Mac's Keychain will not open from the deck's session. The
|
|
3961
|
+
// engine refuses to export it with a fixed code rather than spawning an
|
|
3962
|
+
// export that can only fail. Mac only: claude-swap's `keychain_unavailable`
|
|
3963
|
+
// elsewhere is an unreadable .enc file, and the sentence the peer prints
|
|
3964
|
+
// talks about a Keychain.
|
|
3965
|
+
if (!got || !Array.isArray(got.accounts)) return got;
|
|
3966
|
+
// The active slot is withheld from peers until it has a fresh verdict — see
|
|
3967
|
+
// cachedExportReadable — so ask for one now rather than waiting on the
|
|
3968
|
+
// collector's own schedule.
|
|
3969
|
+
if (got.accounts.some(a => a.active === true && a.collector == null)) refreshActiveVerdict();
|
|
3970
|
+
else activeVerdictArrived();
|
|
3971
|
+
const { markUnreadable } = await import("./cswap-admin.mjs");
|
|
3972
|
+
return { ...got, accounts: markUnreadable(got.accounts) };
|
|
3973
|
+
},
|
|
3974
|
+
// Bounded well inside a round, since a want waits on it.
|
|
3975
|
+
liveLogin: async () => {
|
|
3976
|
+
const { currentIdentity } = await import("./cswap-admin.mjs");
|
|
3977
|
+
const late = new Promise(resolve => setTimeout(resolve, LIVE_LOGIN_TIMEOUT_MS, null).unref?.());
|
|
3978
|
+
return Promise.race([currentIdentity().catch(() => null), late]);
|
|
3979
|
+
},
|
|
3980
|
+
exportAccount: async (num, expectedKey) => {
|
|
3981
|
+
const { accountKey } = await import("./lan-sync.mjs");
|
|
3982
|
+
const { shareAccounts, unwrapShare } = await import("./cswap-admin.mjs");
|
|
3983
|
+
// Do not collect live verdicts here. A LAN want has a ten-second round
|
|
3984
|
+
// budget, while a verdict collection may wait up to ninety seconds (or
|
|
3985
|
+
// queue behind another one). The want handler already re-reads this deck's
|
|
3986
|
+
// cached account state immediately before export and refuses known
|
|
3987
|
+
// unreadable/dead copies. The share itself is therefore the only bounded
|
|
3988
|
+
// operation left on the hot path.
|
|
3989
|
+
//
|
|
3990
|
+
// Without the explanation, which would outlast the asking peer's patience;
|
|
3991
|
+
// see shareAccounts.
|
|
3992
|
+
const out = await shareAccounts([String(num)], { explain: false });
|
|
3993
|
+
// The blob or nothing. WHY it failed is never carried out of here: the
|
|
3994
|
+
// engine decides what a peer is told from this deck's state — see
|
|
3995
|
+
// `readable` in readAccounts below.
|
|
3996
|
+
if (!out?.ok) return null;
|
|
3997
|
+
const opened = unwrapShare(out.blob);
|
|
3998
|
+
if (!opened.ok) return null;
|
|
3999
|
+
let accounts;
|
|
4000
|
+
try { accounts = JSON.parse(opened.payload)?.accounts; } catch { return null; }
|
|
4001
|
+
if (!Array.isArray(accounts) || accounts.length !== 1) return null;
|
|
4002
|
+
// Slot numbers are local. Verify the payload identity after export so a
|
|
4003
|
+
// moved/reused slot can never satisfy a want for another account.
|
|
4004
|
+
return accountKey(accounts[0]?.email, accounts[0]?.organizationUuid) === expectedKey ? out.blob : null;
|
|
3905
4005
|
},
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
const
|
|
3909
|
-
return
|
|
4006
|
+
// The logins a round just imported, checked once after it — see checkImports.
|
|
4007
|
+
checkArrivals: async steps => {
|
|
4008
|
+
const { checkImports } = await import("./cswap-admin.mjs");
|
|
4009
|
+
return checkImports(steps.map(step => {
|
|
4010
|
+
const [email, org] = String(step?.key ?? "").split("@@");
|
|
4011
|
+
return { email, org: org ?? "" };
|
|
4012
|
+
}));
|
|
3910
4013
|
},
|
|
3911
4014
|
importAccount: async (blob, step) => {
|
|
3912
|
-
|
|
3913
|
-
// the forced import, which fillEmptySlot now owns along with it.
|
|
3914
|
-
const { importAccount, fillEmptySlot } = await import("./cswap-admin.mjs");
|
|
4015
|
+
const { importAccount, fillEmptySlot, landed } = await import("./cswap-admin.mjs");
|
|
3915
4016
|
const [want, wantOrg] = String(step?.key ?? "").split("@@");
|
|
3916
4017
|
// NO `force`, ever, and this is the line where that promise is kept. A
|
|
3917
4018
|
// plain import adds an account that is missing and replaces exactly one
|
|
@@ -3932,22 +4033,27 @@ const lanEngine = createEngine({
|
|
|
3932
4033
|
// `force === true && narrowing` — so the promise above survives word for
|
|
3933
4034
|
// word, and a bundle that does not carry what was asked for is refused
|
|
3934
4035
|
// rather than unpacked.
|
|
3935
|
-
const out = await importAccount(blob, { only: { email: want, org: wantOrg ?? "" } });
|
|
4036
|
+
const out = await importAccount(blob, { only: { email: want, org: wantOrg ?? "" }, collect: !CHECKS_IMPORTS });
|
|
3936
4037
|
if (!out?.ok) return { ok: false, why: out?.reason ?? "import refused" };
|
|
3937
4038
|
// A SKIP IS NOT A HEAL, and reading `ok` alone said it was. `cswap import`
|
|
3938
|
-
// exits ZERO when it declines an account it already holds
|
|
3939
|
-
// own comment says so and reports it as `added: false` — so a round that
|
|
4039
|
+
// exits ZERO when it declines an account it already holds, so a round that
|
|
3940
4040
|
// changed nothing was counted as a successful repair and the panel said the
|
|
3941
4041
|
// account had been fixed. Whoever read that then waited for numbers that
|
|
3942
4042
|
// were never going to move.
|
|
3943
4043
|
//
|
|
4044
|
+
// `landed`, NOT `added`: `added` counts new slots only, and a plain import
|
|
4045
|
+
// that replaces a quarantined slot is `healed` — the one repair this path
|
|
4046
|
+
// exists for. Reading `added` reported every such heal as a failure and
|
|
4047
|
+
// then sent it on to fillEmptySlot, which declined it. A slot that stayed
|
|
4048
|
+
// `present` is the decline.
|
|
4049
|
+
//
|
|
3944
4050
|
// The decline is narrow and documented on claude-swap's side: a plain
|
|
3945
4051
|
// import replaces a slot only when its usage row is quarantined as
|
|
3946
4052
|
// refresh-token-dead, and is "never triggered by the live store's
|
|
3947
4053
|
// `no credentials` state". So an account whose login expired heals over the
|
|
3948
4054
|
// network, and one that has NO stored login does not — which is a true
|
|
3949
4055
|
// sentence the panel can now print instead of a false one.
|
|
3950
|
-
if (out.
|
|
4056
|
+
if (landed(out.results)) return { ok: true };
|
|
3951
4057
|
|
|
3952
4058
|
// THE DECLINE, AND WHY IT MATTERS WHICH ONE IT IS.
|
|
3953
4059
|
//
|
|
@@ -3986,7 +4092,7 @@ const lanEngine = createEngine({
|
|
|
3986
4092
|
// verdict taken before any of it began. fillEmptySlot re-reads the verdict
|
|
3987
4093
|
// as its first statement INSIDE the lock, so the promise holds by
|
|
3988
4094
|
// construction rather than by how long the queue happened to be.
|
|
3989
|
-
return fillEmptySlot(blob, { email: want, org: wantOrg ?? "" });
|
|
4095
|
+
return fillEmptySlot(blob, { email: want, org: wantOrg ?? "", collect: !CHECKS_IMPORTS });
|
|
3990
4096
|
},
|
|
3991
4097
|
// The deck's own long-term key, kept so a restart is the same deck rather
|
|
3992
4098
|
// than a stranger to everybody who has paired with it. Written once, on the
|
|
@@ -4117,6 +4223,7 @@ export function lanApplyFields(prefs, { load = false, env = process.env } = {})
|
|
|
4117
4223
|
shared: Array.isArray(lan.shared) ? lan.shared : [],
|
|
4118
4224
|
autoAsk: lan.autoAsk !== false,
|
|
4119
4225
|
autoAccept: lan.autoAccept !== false,
|
|
4226
|
+
pairingMode: lan.pairingMode === "invite" ? "invite" : "automatic",
|
|
4120
4227
|
// Whether paired decks are told which shared account this one is on.
|
|
4121
4228
|
shareActive: lan.shareActive !== false,
|
|
4122
4229
|
// Discovery over Tailscale, off unless somebody turned it on, and the two
|
|
@@ -5875,6 +5982,31 @@ async function handleClaudeFm(req, res) {
|
|
|
5875
5982
|
send(res, 200, answer);
|
|
5876
5983
|
}
|
|
5877
5984
|
|
|
5985
|
+
/**
|
|
5986
|
+
* A custom station's YouTube link, resolved to the channel and video the
|
|
5987
|
+
* embed plays (#1208). fm-station.mjs rebuilds the request from the parsed
|
|
5988
|
+
* link rather than fetching it as given, and caches the answer.
|
|
5989
|
+
*
|
|
5990
|
+
* AGENTS_DECK_NO_MUSIC is the promise that this deck never contacts YouTube,
|
|
5991
|
+
* and a station somebody added is not an exception to it: the answer is the
|
|
5992
|
+
* same plain no /api/claude-fm gives, and the canvas draws nothing for it.
|
|
5993
|
+
*/
|
|
5994
|
+
async function handleFmStation(req, res) {
|
|
5995
|
+
if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
|
|
5996
|
+
return send(res, 200, { ok: false, off: true });
|
|
5997
|
+
}
|
|
5998
|
+
const url = new URL(req.url, "http://localhost");
|
|
5999
|
+
const stationUrl = url.searchParams.get("url") ?? "";
|
|
6000
|
+
const { parseYouTubeStationUrl, resolveYouTubeStation } = await import(
|
|
6001
|
+
pathToFileURL(join(PKG_ROOT, "src/server/fm-station.mjs")).href
|
|
6002
|
+
);
|
|
6003
|
+
if (!parseYouTubeStationUrl(stationUrl)) {
|
|
6004
|
+
return send(res, 400, { ok: false, error: "unsupported_url" });
|
|
6005
|
+
}
|
|
6006
|
+
const answer = await resolveYouTubeStation(stationUrl);
|
|
6007
|
+
send(res, answer.ok ? 200 : 404, answer);
|
|
6008
|
+
}
|
|
6009
|
+
|
|
5878
6010
|
async function handleLofiGirl(req, res) {
|
|
5879
6011
|
if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
|
|
5880
6012
|
return send(res, 200, { ok: true, live: false, off: true });
|
|
@@ -6154,14 +6286,14 @@ function getProjectRollup() {
|
|
|
6154
6286
|
* The "Projects" report for one account: how many tokens it spent per project
|
|
6155
6287
|
* over a window. The server tallies only tokens (per model); the web side
|
|
6156
6288
|
* prices them with its own table, so cost never lives in two places. `num` is
|
|
6157
|
-
* the account's slot, resolved to its `(email, org)` key here; `days` is
|
|
6158
|
-
* or 0 for all tracked.
|
|
6289
|
+
* the account's slot, resolved to its `(email, org)` key here; `days` is 1, 7,
|
|
6290
|
+
* 30, or 0 for all tracked.
|
|
6159
6291
|
*/
|
|
6160
6292
|
async function handleAccountProjects(req, res) {
|
|
6161
6293
|
const url = new URL(req.url, "http://localhost");
|
|
6162
6294
|
const num = Number(url.searchParams.get("num"));
|
|
6163
6295
|
const d = Number(url.searchParams.get("days"));
|
|
6164
|
-
const days = d === 30 ? 30 : d === 0 ? 0 : 7;
|
|
6296
|
+
const days = d === 1 ? 1 : d === 30 ? 30 : d === 0 ? 0 : 7;
|
|
6165
6297
|
if (!Number.isInteger(num) || num <= 0) return send(res, 400, { ok: false, reason: "bad_account" });
|
|
6166
6298
|
const { identityForSlot } = await import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href);
|
|
6167
6299
|
const id = await identityForSlot(num);
|
|
@@ -6211,10 +6343,17 @@ async function handleClaudeAccountAdmin(req, res) {
|
|
|
6211
6343
|
// `only` names one account inside the pasted bundle, and is the only way
|
|
6212
6344
|
// `force` is honoured at all - see importAccount for why the pair is
|
|
6213
6345
|
// required rather than the flag alone.
|
|
6214
|
-
case "import":
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6346
|
+
case "import": {
|
|
6347
|
+
const out = await admin.importAccount(parsed.blob, {
|
|
6348
|
+
force: parsed.force === true,
|
|
6349
|
+
only: parsed.only ?? null,
|
|
6350
|
+
collect: !CHECKS_IMPORTS,
|
|
6351
|
+
});
|
|
6352
|
+
// Checked the way a LAN round's arrivals are (#1244): `cswap import`
|
|
6353
|
+
// exiting 0 on a Mac is not proof this process can read what it wrote.
|
|
6354
|
+
result = out?.ok ? { ...out, results: await admin.checkImportResults(out.results) } : out;
|
|
6355
|
+
break;
|
|
6356
|
+
}
|
|
6218
6357
|
case "remove": result = await admin.removeAccount(parsed.account); break;
|
|
6219
6358
|
// #721. Re-captures the active slot's credentials in place; see
|
|
6220
6359
|
// recaptureActive for why this is not a login and cannot become one.
|
|
@@ -6270,6 +6409,7 @@ function cswapAutoModule() {
|
|
|
6270
6409
|
const PINNED_MODULES = [
|
|
6271
6410
|
"self-update.mjs",
|
|
6272
6411
|
"claude-accounts.mjs",
|
|
6412
|
+
"account-health.mjs",
|
|
6273
6413
|
"cswap-admin.mjs",
|
|
6274
6414
|
"cswap-auto.mjs",
|
|
6275
6415
|
"quota.mjs",
|
|
@@ -6279,6 +6419,7 @@ const PINNED_MODULES = [
|
|
|
6279
6419
|
"browser-watch.mjs",
|
|
6280
6420
|
"browser-watch-store.mjs",
|
|
6281
6421
|
"claude-fm.mjs",
|
|
6422
|
+
"fm-station.mjs",
|
|
6282
6423
|
"lofi-girl.mjs",
|
|
6283
6424
|
"live-radio-mix.mjs",
|
|
6284
6425
|
"best-of-nostalgia.mjs",
|
|
@@ -7244,6 +7385,13 @@ const GUARDED_READS = new Set([
|
|
|
7244
7385
|
// Per-account, per-project token spend — the user's own work, the same class
|
|
7245
7386
|
// of secret as the accounts list it hangs off.
|
|
7246
7387
|
"/api/account-projects",
|
|
7388
|
+
// Not a secret, and here for the other reason a read can be dangerous: it is
|
|
7389
|
+
// the one route where the caller names what the deck goes and fetches
|
|
7390
|
+
// (#1208). fm-station.mjs holds that to YouTube, but a page on another site
|
|
7391
|
+
// still had a way to make this process download pages on demand, and the
|
|
7392
|
+
// only caller that needs it is the deck's own canvas, which sends
|
|
7393
|
+
// Sec-Fetch-Site: same-origin on every fetch.
|
|
7394
|
+
"/api/fm-station",
|
|
7247
7395
|
]);
|
|
7248
7396
|
|
|
7249
7397
|
function isAuthorizedMutation(req) {
|
|
@@ -7554,6 +7702,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7554
7702
|
if (req.method === "GET" && url.pathname === "/api/hook-challenge") return handleHookChallenge(req, res, url);
|
|
7555
7703
|
if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
|
|
7556
7704
|
if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
|
|
7705
|
+
if (req.method === "GET" && url.pathname === "/api/desktop-update") return handleDesktopUpdateRead(req, res);
|
|
7706
|
+
if (req.method === "POST" && url.pathname === "/api/desktop-update") return guard(handleDesktopUpdateReport(req, res), res);
|
|
7707
|
+
if (req.method === "POST" && url.pathname === "/api/desktop-update/restart") return guard(handleDesktopUpdateRequest(req, res, "desktop-update-restart"), res);
|
|
7708
|
+
if (req.method === "POST" && url.pathname === "/api/desktop-update/seen") return guard(handleDesktopUpdateRequest(req, res, "desktop-update-seen"), res);
|
|
7557
7709
|
if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
|
|
7558
7710
|
if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
|
|
7559
7711
|
if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
|
|
@@ -7613,6 +7765,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7613
7765
|
if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
|
|
7614
7766
|
if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
|
|
7615
7767
|
if (req.method === "GET" && url.pathname === "/api/claude-fm") return guard(handleClaudeFm(req, res), res);
|
|
7768
|
+
if (req.method === "GET" && url.pathname === "/api/fm-station") return guard(handleFmStation(req, res), res);
|
|
7616
7769
|
if (req.method === "GET" && url.pathname === "/api/lofi-girl") return guard(handleLofiGirl(req, res), res);
|
|
7617
7770
|
if (req.method === "GET" && url.pathname === "/api/live-radio-mix") return guard(handleLiveRadioMix(req, res), res);
|
|
7618
7771
|
if (req.method === "GET" && url.pathname === "/api/best-of-nostalgia") return guard(handleBestOfNostalgia(req, res), res);
|