ccdeck 3.26.4 → 3.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/web/assets/{BrowserWatchModal-BUWNIaK8.js → BrowserWatchModal-Ce0hVjif.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-DlRe0P3F.js → UsageHistoryModal-Cf6oqnB8.js} +1 -1
- package/dist/web/assets/{index-DlaHgzye.css → index-B5RadDLY.css} +1 -1
- package/dist/web/assets/index-BrjE0Aat.js +297 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +61 -20
- package/package.json +1 -1
- package/release-notes.json +8 -1
- package/src/server/account-projects.mjs +410 -0
- package/src/server/cswap-auto.mjs +9 -0
- package/src/server/index.mjs +54 -0
- package/src/server/installer.mjs +23 -1
- package/src/server/retire-sound-hook.mjs +10 -1
- package/src/server/swap-log.mjs +188 -0
- package/dist/web/assets/index-CPHonXg4.js +0 -297
package/src/server/index.mjs
CHANGED
|
@@ -6065,10 +6065,49 @@ async function handleClaudeAccountSwitch(req, res) {
|
|
|
6065
6065
|
pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
|
|
6066
6066
|
);
|
|
6067
6067
|
invalidateQuotaCache();
|
|
6068
|
+
// Note who became active, and when, for the account-projects report. Best
|
|
6069
|
+
// effort — a failed log write must never fail the switch — so it is not
|
|
6070
|
+
// awaited into the response.
|
|
6071
|
+
import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href)
|
|
6072
|
+
.then(({ recordSwap }) => recordSwap(parsed.account, "manual"))
|
|
6073
|
+
.catch(() => {});
|
|
6068
6074
|
}
|
|
6069
6075
|
send(res, result.ok ? 200 : 400, result);
|
|
6070
6076
|
}
|
|
6071
6077
|
|
|
6078
|
+
// The account-projects rollup, started once and shared. Its timer is unref'd
|
|
6079
|
+
// inside start(), so holding the instance here never keeps the process alive.
|
|
6080
|
+
let _projectRollup = null;
|
|
6081
|
+
function getProjectRollup() {
|
|
6082
|
+
if (!_projectRollup) {
|
|
6083
|
+
_projectRollup = import(pathToFileURL(join(PKG_ROOT, "src/server/account-projects.mjs")).href)
|
|
6084
|
+
.then(m => { const r = m.createProjectRollup(); r.start().catch(() => {}); return r; });
|
|
6085
|
+
}
|
|
6086
|
+
return _projectRollup;
|
|
6087
|
+
}
|
|
6088
|
+
|
|
6089
|
+
/**
|
|
6090
|
+
* The "Projects" report for one account: how many tokens it spent per project
|
|
6091
|
+
* over a window. The server tallies only tokens (per model); the web side
|
|
6092
|
+
* prices them with its own table, so cost never lives in two places. `num` is
|
|
6093
|
+
* the account's slot, resolved to its `(email, org)` key here; `days` is 7, 30,
|
|
6094
|
+
* or 0 for all tracked.
|
|
6095
|
+
*/
|
|
6096
|
+
async function handleAccountProjects(req, res) {
|
|
6097
|
+
const url = new URL(req.url, "http://localhost");
|
|
6098
|
+
const num = Number(url.searchParams.get("num"));
|
|
6099
|
+
const d = Number(url.searchParams.get("days"));
|
|
6100
|
+
const days = d === 30 ? 30 : d === 0 ? 0 : 7;
|
|
6101
|
+
if (!Number.isInteger(num) || num <= 0) return send(res, 400, { ok: false, reason: "bad_account" });
|
|
6102
|
+
const { identityForSlot } = await import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href);
|
|
6103
|
+
const id = await identityForSlot(num);
|
|
6104
|
+
if (!id) return send(res, 400, { ok: false, reason: "bad_account" });
|
|
6105
|
+
const { accountKey } = await import(pathToFileURL(join(PKG_ROOT, "src/server/lan-sync.mjs")).href);
|
|
6106
|
+
const rollup = await getProjectRollup();
|
|
6107
|
+
const report = await rollup.report(accountKey(id.email, id.orgUuid), days);
|
|
6108
|
+
send(res, 200, { ok: true, ...report });
|
|
6109
|
+
}
|
|
6110
|
+
|
|
6072
6111
|
function cswapAdminModule() {
|
|
6073
6112
|
return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-admin.mjs")).href);
|
|
6074
6113
|
}
|
|
@@ -6181,6 +6220,12 @@ const PINNED_MODULES = [
|
|
|
6181
6220
|
"macmon.mjs",
|
|
6182
6221
|
"hwmonitor.mjs",
|
|
6183
6222
|
"retire-sound-hook.mjs",
|
|
6223
|
+
// The account-projects report: the rollup and the swap log its attribution
|
|
6224
|
+
// reads, plus lan-sync for the account key — all reached only through
|
|
6225
|
+
// import() from the report's route.
|
|
6226
|
+
"account-projects.mjs",
|
|
6227
|
+
"swap-log.mjs",
|
|
6228
|
+
"lan-sync.mjs",
|
|
6184
6229
|
];
|
|
6185
6230
|
|
|
6186
6231
|
let _pinned = null;
|
|
@@ -7127,6 +7172,9 @@ const GUARDED_READS = new Set([
|
|
|
7127
7172
|
"/api/browser-watch",
|
|
7128
7173
|
"/api/lan",
|
|
7129
7174
|
"/api/prefs",
|
|
7175
|
+
// Per-account, per-project token spend — the user's own work, the same class
|
|
7176
|
+
// of secret as the accounts list it hangs off.
|
|
7177
|
+
"/api/account-projects",
|
|
7130
7178
|
]);
|
|
7131
7179
|
|
|
7132
7180
|
function isAuthorizedMutation(req) {
|
|
@@ -7483,6 +7531,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7483
7531
|
if (req.method === "GET" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatch(req, res), res);
|
|
7484
7532
|
if (req.method === "POST" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatchSettings(req, res), res);
|
|
7485
7533
|
if (req.method === "POST" && url.pathname === "/api/browser-watch/dismiss") return guard(handleBrowserWatchDismiss(req, res), res);
|
|
7534
|
+
if (req.method === "GET" && url.pathname === "/api/account-projects") return guard(handleAccountProjects(req, res), res);
|
|
7486
7535
|
if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
|
|
7487
7536
|
if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
|
|
7488
7537
|
if (req.method === "GET" && url.pathname === "/api/claude-accounts/login") return guard(handleAccountLoginState(req, res), res);
|
|
@@ -7603,6 +7652,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7603
7652
|
// being installed or upgraded underneath them (#1043) — null from every
|
|
7604
7653
|
// caller with nothing to wait for.
|
|
7605
7654
|
cswapAutoModule().then(m => m.initCswapAuto({ after: cswapQuiet })).catch(() => {});
|
|
7655
|
+
// The account-projects rollup: fold Claude transcripts into a per-account,
|
|
7656
|
+
// per-project token tally, incrementally. Claude only — it reads Claude
|
|
7657
|
+
// transcripts — best effort, and its timer is unref'd so it never holds
|
|
7658
|
+
// the process open.
|
|
7659
|
+
if (claude) getProjectRollup().catch(() => {});
|
|
7606
7660
|
return server;
|
|
7607
7661
|
} catch (err) {
|
|
7608
7662
|
lastErr = err;
|
package/src/server/installer.mjs
CHANGED
|
@@ -403,15 +403,37 @@ async function writeFileAtomic(rawTarget, text) {
|
|
|
403
403
|
* identical content skips the write and the file is not replaced at all.
|
|
404
404
|
*/
|
|
405
405
|
async function installScript(src, dst) {
|
|
406
|
-
|
|
406
|
+
return installText(await readFile(src, "utf8"), dst);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function installText(text, dst) {
|
|
407
410
|
const current = await readFile(dst, "utf8").catch(() => null);
|
|
408
411
|
if (current === text) return false;
|
|
409
412
|
await writeFileAtomic(dst, text);
|
|
410
413
|
return true;
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
/**
|
|
417
|
+
* What makes the installed hook.js a CommonJS file wherever the config dir is.
|
|
418
|
+
*
|
|
419
|
+
* Node decides whether a `.js` file is CommonJS or an ES module from the nearest
|
|
420
|
+
* package.json above it, and hook.js is CommonJS. With none in agent-dag/, that
|
|
421
|
+
* nearest one is whatever the user has further up: a `"type": "module"` in
|
|
422
|
+
* ~/package.json, or in the repo a relocated CLAUDE_CONFIG_DIR lives in, loads
|
|
423
|
+
* the hook as an ES module, and it dies on its first `require` before main()
|
|
424
|
+
* has installed a single handler. Exit 1 and `require is not defined in ES
|
|
425
|
+
* module scope` on every tool call, and no deck receives anything (#1172). One
|
|
426
|
+
* file here answers the question before Node looks any further.
|
|
427
|
+
*
|
|
428
|
+
* Written before the hook, so a first install never has a hook.js without it,
|
|
429
|
+
* and through the same atomic write, because a half-written package.json is a
|
|
430
|
+
* load error of its own for every hook that starts while it is being written.
|
|
431
|
+
*/
|
|
432
|
+
const HOOK_PACKAGE_JSON = '{ "type": "commonjs" }\n';
|
|
433
|
+
|
|
413
434
|
async function installHookScript(installDir) {
|
|
414
435
|
await ensureDir(installDir);
|
|
436
|
+
await installText(HOOK_PACKAGE_JSON, join(installDir, "package.json"));
|
|
415
437
|
const src = join(PKG_ROOT, "hook", "hook.js");
|
|
416
438
|
const dst = join(installDir, "hook.js");
|
|
417
439
|
await installScript(src, dst);
|
|
@@ -91,8 +91,17 @@ const EVENT = "Stop";
|
|
|
91
91
|
// writes this any more; retirement reads it once and deletes it.
|
|
92
92
|
const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
|
|
93
93
|
|
|
94
|
+
// `Array.isArray`, not `?? []`, and that is a crash fix rather than a style
|
|
95
|
+
// choice. A group whose `hooks` is not an array — hand-edited settings.json,
|
|
96
|
+
// something else's schema, a half-finished edit — made this `.map` throw
|
|
97
|
+
// `TypeError: … .map is not a function`, out of `retireSoundHookIn`, out of
|
|
98
|
+
// `installHooks`, on the unconditional install every boot runs: the deck would
|
|
99
|
+
// not start at all until the user found and fixed the entry, with nothing but a
|
|
100
|
+
// stack to say which one. installer.mjs's own `isOurEntry` has always read the
|
|
101
|
+
// same field with the same guard, and its answer for a group it cannot read is
|
|
102
|
+
// to leave it exactly where it is.
|
|
94
103
|
const commandsOf = (entry) =>
|
|
95
|
-
(entry?.hooks
|
|
104
|
+
(Array.isArray(entry?.hooks) ? entry.hooks : []).map(h => (typeof h?.command === "string" ? h.command : ""));
|
|
96
105
|
|
|
97
106
|
/**
|
|
98
107
|
* THE TAIL, NOT THE WHOLE PATH — and this is a data-loss fix, not a tidy-up.
|
|
@@ -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
|
+
}
|