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.
@@ -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;
@@ -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
- const text = await readFile(src, "utf8");
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 ?? []).map(h => (typeof h?.command === "string" ? h.command : ""));
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
+ }