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.
@@ -44,8 +44,8 @@
44
44
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
45
45
  })();
46
46
  </script>
47
- <script type="module" crossorigin src="/assets/index-CPHonXg4.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-DlaHgzye.css">
47
+ <script type="module" crossorigin src="/assets/index-BrjE0Aat.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-B5RadDLY.css">
49
49
  </head>
50
50
  <body>
51
51
  <div id="root"></div>
package/hook/hook.js CHANGED
@@ -357,13 +357,15 @@ function sameProof(got, want) {
357
357
  //
358
358
  // They are now separated by a barrier: every target is challenged, then the
359
359
  // election is decided, then the payload goes out (#695). The worst case is
360
- // unchanged — the challenges run in parallel, so it is still one 400ms deadline
361
- // followed by one 1000ms deadline. What the barrier does cost is that an honest
362
- // deck's POST waits for the slowest challenge in the set, which only matters
363
- // when some OTHER record's port accepts a connection and then says nothing. A
364
- // ghost port with nothing behind it refuses instantly and delays no one; one
365
- // with something silent behind it costs both deadlines once, and its record is
366
- // then forgotten if no deck has been keeping it — see forgetIfAbandoned.
360
+ // unchanged — the challenges run in parallel, so it is still one challenge's
361
+ // two 400ms attempts followed by one 1000ms deadline, and each 400ms bounds the
362
+ // whole answer rather than a silence in it (see prove). What the barrier does
363
+ // cost is that an honest deck's POST waits for the slowest challenge in the
364
+ // set, which only matters when some OTHER record's port accepts a connection
365
+ // and then does not answer in time. A ghost port with nothing behind it refuses
366
+ // instantly and delays no one; one with something slow or silent behind it
367
+ // costs both attempts once, and its record is then forgotten if no deck has
368
+ // been keeping it — see forgetIfAbandoned.
367
369
  const CHALLENGE_TIMEOUT_MS = 400;
368
370
  const POST_TIMEOUT_MS = 1000;
369
371
 
@@ -423,6 +425,8 @@ const CAP_MS = 1900;
423
425
  */
424
426
  function prove(d, cb, attempt = 0) {
425
427
  let settled = false;
428
+ // Armed once the request is out, and cleared by whichever verdict comes first.
429
+ let deadline = null;
426
430
  // A DEADLINE IS NOT AN ANSWER, and the difference is worth one retry.
427
431
  //
428
432
  // A wrong proof, a refused connection and a 404 are all verdicts: that port
@@ -444,7 +448,12 @@ function prove(d, cb, attempt = 0) {
444
448
  settled = true; // this attempt is over; the next owns `cb`
445
449
  prove(d, cb, attempt + 1);
446
450
  };
447
- const finish = (ok, why = null) => { if (settled) return; settled = true; cb(ok, why); };
451
+ const finish = (ok, why = null) => {
452
+ if (settled) return;
453
+ settled = true;
454
+ clearTimeout(deadline);
455
+ cb(ok, why);
456
+ };
448
457
 
449
458
  if (!requiresProof(d)) return finish(true);
450
459
 
@@ -456,7 +465,6 @@ function prove(d, cb, attempt = 0) {
456
465
  port: d.port,
457
466
  path: `/api/hook-challenge?nonce=${nonce}`,
458
467
  method: "GET",
459
- timeout: CHALLENGE_TIMEOUT_MS,
460
468
  }, res => {
461
469
  if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
462
470
  let answer = "";
@@ -476,12 +484,26 @@ function prove(d, cb, attempt = 0) {
476
484
  finish(sameProof(proof, want));
477
485
  });
478
486
  });
479
- // `destroy()` on a timeout makes 'error' fire with ECONNRESET, so the two
480
- // handlers have to agree on which of them is speaking: `timedOut` is what
481
- // tells a deadline apart from a refusal.
482
- let timedOut = false;
483
- req.on("error", () => { if (timedOut) retryOnTimeout(); else finish(false); });
484
- req.on("timeout", () => { timedOut = true; req.destroy(); });
487
+ req.on("error", () => finish(false));
488
+ // THE DEADLINE IS ON THE WHOLE ANSWER, not on the silences in it (#1172).
489
+ //
490
+ // This was http.request's `timeout`, which is an IDLE timeout: it fires only
491
+ // after that long with no bytes either way. A port that answers one byte
492
+ // every 150ms is never idle and never reaches the 4096-byte cap above, so its
493
+ // prove() never settled — and proveTargets waits for every target before
494
+ // anything is posted. Measured with an honest deck beside such a port: the
495
+ // hook ran to CAP_MS at 1938ms and the deck saw its challenge and no event,
496
+ // on every tool call for as long as the record stood, and the record was
497
+ // never forgotten either, because the "deadline" verdict forgetIfAbandoned
498
+ // needs never came.
499
+ //
500
+ // The idle timeout had a second hole, and a port that sends its status line
501
+ // and then goes quiet fell into it: once a response has begun, destroying the
502
+ // request raises no 'error' on it, so the verdict that used to ride on that
503
+ // event never arrived either. So the timer gives the verdict itself, and
504
+ // gives it first: the destroy's own 'error', if it raises one, finds this
505
+ // attempt already over.
506
+ deadline = setTimeout(() => { retryOnTimeout(); req.destroy(); }, CHALLENGE_TIMEOUT_MS);
485
507
  req.end();
486
508
  }
487
509
 
@@ -686,11 +708,30 @@ function post(d, body, persists, done) {
686
708
  finish(res.statusCode >= 200 && res.statusCode < 300);
687
709
  });
688
710
  req.on("finish", () => { sent = true; });
689
- // `destroy()` on a timeout makes 'error' fire with ECONNRESET, as it does in
690
- // prove(), so `timedOut` is what tells this process giving up apart from the
691
- // writer hanging up.
692
- req.on("error", () => finish(false, timedOut && sent ? "deadline" : null));
693
- req.on("timeout", () => { timedOut = true; req.destroy(); });
711
+ // `destroy()` on a timeout makes 'error' fire with ECONNRESET — the status
712
+ // line has not arrived, or it would have settled this already — so `timedOut`
713
+ // is what tells this process giving up apart from the writer hanging up.
714
+ //
715
+ // AND THE BODY'S STATE IS READ WHEN THE DEADLINE FIRES, NOT WHEN THE ERROR
716
+ // ARRIVES (#1172). `timedOut && sent` was evaluated in the 'error' handler,
717
+ // and by the time that runs the answer is always yes: destroying a request
718
+ // that has been end()ed finalises its writable, which emits 'finish' — so
719
+ // `sent` flips to true on the way out of req.destroy(), one tick before the
720
+ // 'error' it causes. Measured on Node 22 against a listener that takes the
721
+ // headers and then stops reading, with 256MB still queued:
722
+ //
723
+ // TIMEOUT sent=false writableLength=268435608
724
+ // FINISH writableFinished=true pending=0
725
+ // ERROR ECONNRESET sent=true <- verdict "deadline"
726
+ //
727
+ // which made every deadline a "deadline" and left the paragraph above
728
+ // describing a branch that could not be taken: a writer wedged mid-read kept
729
+ // a log it had not received, and the deck behind it — which could have
730
+ // written the line — was never asked. The snapshot is the rule that paragraph
731
+ // states, taken at the only moment it is still true.
732
+ let sentByDeadline = false;
733
+ req.on("error", () => finish(false, timedOut && sentByDeadline ? "deadline" : null));
734
+ req.on("timeout", () => { timedOut = true; sentByDeadline = sent; req.destroy(); });
694
735
  req.write(body);
695
736
  req.end();
696
737
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.26.4",
3
+ "version": "3.27.0",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,14 @@
47
47
  "3.26.0",
48
48
  "3.26.1",
49
49
  "3.26.3",
50
- "3.26.4"
50
+ "3.26.4",
51
+ "3.26.5"
52
+ ],
53
+ "3.27.0": [
54
+ {
55
+ "title": "📊 Projects: where each account spent its work",
56
+ "body": "Each account card's ⋯ menu now breaks its cost down by project, over 7d / 30d / all."
57
+ }
51
58
  ],
52
59
  "3.25.0": [
53
60
  {
@@ -0,0 +1,410 @@
1
+ // How much each account worked in each project — the rollup behind the
2
+ // account card's "Projects" report.
3
+ //
4
+ // The account cards show account-wide rate-limit utilisation, which has no
5
+ // project dimension and cannot be sliced. This is the other axis: for one
6
+ // account, the tokens it spent per project, which the web side prices with the
7
+ // board's own pricing table (pricing.ts). Cost is NOT computed here — the
8
+ // server is plain .mjs with no build step and cannot import the .ts price
9
+ // table, and keeping one source of prices beats a second copy that drifts. So
10
+ // this file tallies TOKENS per (account, project, day, model) and the client
11
+ // multiplies by the rates it already owns.
12
+ //
13
+ // Attribution is per MESSAGE, not per session. A running session migrates to a
14
+ // newly-activated account on its next message, so a whole session can span two
15
+ // accounts; only the message's own timestamp, matched against the swap log,
16
+ // says who was active when it was billed. Each transcript line carries, in one
17
+ // JSON object, its `timestamp`, its `message.model` and its `message.usage`
18
+ // token counts, and its `cwd` — everything a row needs.
19
+ //
20
+ // The scan is incremental: a byte cursor per transcript file means a pass folds
21
+ // only the lines appended since the last one, so opening the report is a read
22
+ // of a small on-disk tally rather than a walk of every transcript.
23
+ import { open, stat, readdir, readFile, writeFile, mkdir } from "node:fs/promises";
24
+ import { homedir } from "node:os";
25
+ import { join, resolve, basename, dirname } from "node:path";
26
+ import { StringDecoder } from "node:string_decoder";
27
+ import { renameWithRetry } from "./installer.mjs";
28
+ import { claudeConfigDir } from "./claude-dir.mjs";
29
+ import { accountKey } from "./lan-sync.mjs";
30
+ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from "./swap-log.mjs";
31
+
32
+ /** The pseudo-account for messages the swap log cannot place — everything
33
+ * before tracking began, or a gap. Shown once, apart from any real account, so
34
+ * a per-account total is never quietly inflated by work that is not that
35
+ * account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
36
+ export const UNATTRIBUTED = "\u0000unattributed";
37
+
38
+ /** Where the tally and cursors live, beside cswap-auto's own state. */
39
+ export function statePath(home = homedir()) {
40
+ return join(home, ".agents-deck", "account-projects.json");
41
+ }
42
+
43
+ const MAX_CHUNK = 1 << 20; // 1 MiB reads, so a cold 100 MB transcript never loads whole
44
+ const RETAIN_DAYS = 60; // covers the 30-day window with margin; older days are pruned
45
+ const DEFAULT_INTERVAL_MS = 20_000; // a pass every 20 s; each is stat + append-only reads
46
+ const HEARTBEAT_MS = 300_000; // persist "last seen alive" at most this often when idle
47
+ const GAP_MS = 600_000; // a boot more than this after the last heartbeat means the
48
+ // deck was down; that stretch is fenced off as unattributed
49
+
50
+ /** The two directories Claude Code writes transcripts under: the configured one
51
+ * and the default, since a deck launched from a desktop shortcut may resolve
52
+ * CLAUDE_CONFIG_DIR differently than the `claude` process that wrote the file
53
+ * (the same two-root reason index.mjs states for the hook path). */
54
+ export function transcriptRoots(home = homedir(), env = process.env) {
55
+ const roots = [resolve(claudeConfigDir(env, home), "projects")];
56
+ const def = resolve(home, ".claude", "projects");
57
+ if (!roots.includes(def)) roots.push(def);
58
+ return roots;
59
+ }
60
+
61
+ function num(v) {
62
+ const n = Number(v);
63
+ return Number.isFinite(n) && n > 0 ? n : 0;
64
+ }
65
+
66
+ /** The six token counters a transcript's `usage` block carries, in the compact
67
+ * keys the tally stores. Shape mirrors web's TokenUsage so the client maps it
68
+ * straight onto costForUsage. */
69
+ export function countersFrom(usage) {
70
+ const cc = (usage && typeof usage.cache_creation === "object" && usage.cache_creation) || {};
71
+ return {
72
+ i: num(usage?.input_tokens),
73
+ o: num(usage?.output_tokens),
74
+ cr: num(usage?.cache_read_input_tokens),
75
+ cc: num(usage?.cache_creation_input_tokens),
76
+ c1h: num(cc.ephemeral_1h_input_tokens),
77
+ c5m: num(cc.ephemeral_5m_input_tokens),
78
+ };
79
+ }
80
+
81
+ function addInto(dst, src) {
82
+ dst.i += src.i; dst.o += src.o; dst.cr += src.cr;
83
+ dst.cc += src.cc; dst.c1h += src.c1h; dst.c5m += src.c5m;
84
+ return dst;
85
+ }
86
+
87
+ function zero() { return { i: 0, o: 0, cr: 0, cc: 0, c1h: 0, c5m: 0 }; }
88
+
89
+ /** Local calendar day of a timestamp, `YYYY-MM-DD`. Local because that is the
90
+ * day a person means by "today", and how ccusage buckets its daily rollup. The
91
+ * string sorts chronologically, so a window is a `>=` on it. */
92
+ export function localDay(ts) {
93
+ const d = new Date(ts);
94
+ const y = d.getFullYear();
95
+ const m = String(d.getMonth() + 1).padStart(2, "0");
96
+ const day = String(d.getDate()).padStart(2, "0");
97
+ return `${y}-${m}-${day}`;
98
+ }
99
+
100
+ /** The earliest day a window of `days` includes, counting today as day 1. */
101
+ export function windowCutoff(days, now = Date.now()) {
102
+ if (!days || days <= 0) return null;
103
+ return localDay(now - (days - 1) * 86_400_000);
104
+ }
105
+
106
+ /** CC encodes a cwd `/Users/x/y` as the folder `-Users-x-y`. The reverse is
107
+ * lossy — a real dash in a directory name is indistinguishable from a
108
+ * separator — so this is only the fallback for a line that carried no `cwd`;
109
+ * a real dash in a path is rare and only ever mislabels that one project. */
110
+ function cwdFromSlug(slug) {
111
+ if (!slug || slug === "." ) return "";
112
+ return "/" + slug.replace(/^-+/, "").replace(/-/g, "/");
113
+ }
114
+
115
+ /**
116
+ * The project a cwd belongs to. A git worktree lives at
117
+ * `<repo>/.claude/worktrees/<name>`, so work done in one is counted under the
118
+ * repo it is a checkout of — otherwise the same project splits into a row per
119
+ * worktree ("agents-deck" and "account-projects" for one repo, #1200 review).
120
+ * Handles both path separators, for a Windows cwd. Anything not in a worktree
121
+ * is returned unchanged.
122
+ */
123
+ export function projectPath(cwd) {
124
+ if (typeof cwd !== "string" || !cwd) return "";
125
+ // Strip `.claude/worktrees` and anything under it — the worktree name, a
126
+ // path inside it, or nothing at all (a session run in the worktrees dir).
127
+ return cwd.replace(/[\\/]\.claude[\\/]worktrees(?:[\\/].*)?$/, "");
128
+ }
129
+
130
+ /**
131
+ * Fold one transcript line into the tally. A line with no usage block, no
132
+ * parseable timestamp, or that is not JSON leaves the tally untouched — the
133
+ * same tolerance the whole-file scanners have.
134
+ *
135
+ * `timeline` is the sorted swap log; the account is whoever it says was active
136
+ * at this line's timestamp, or UNATTRIBUTED when the timestamp precedes it.
137
+ */
138
+ export function foldLine(tally, line, timeline, fallbackCwd) {
139
+ if (!line || !line.includes('"usage"')) return;
140
+ let obj = null;
141
+ try { obj = JSON.parse(line); } catch { return; }
142
+ const usage = obj?.message?.usage;
143
+ if (!usage || typeof usage !== "object") return;
144
+ const ts = Date.parse(obj?.timestamp);
145
+ if (!Number.isFinite(ts)) return;
146
+ const c = countersFrom(usage);
147
+ if (!(c.i || c.o || c.cr || c.cc)) return; // a usage block that billed nothing
148
+ const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
149
+ const rawCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : (fallbackCwd || "");
150
+ const cwd = projectPath(rawCwd); // a worktree counts under the repo it checks out
151
+ const who = accountAtTime(timeline, ts);
152
+ const key = who ? accountKey(who.email, who.orgUuid) : UNATTRIBUTED;
153
+ const day = localDay(ts);
154
+ const proj = (((tally[key] ??= {})[cwd] ??= {})[day] ??= {});
155
+ addInto(proj[model] ??= zero(), c);
156
+ }
157
+
158
+ /** Read the bytes of `path` from `start` to `size`, folding each COMPLETE line,
159
+ * and return the byte offset of the last newline consumed. A partial final
160
+ * line (an append still in flight) is left for the next pass. StringDecoder
161
+ * carries a multibyte character split across a chunk boundary. */
162
+ async function foldAppended(fh, start, size, onLine) {
163
+ const dec = new StringDecoder("utf8");
164
+ let offset = start;
165
+ let carry = "";
166
+ const buf = Buffer.allocUnsafe(Math.min(MAX_CHUNK, Math.max(1, size - start)));
167
+ while (offset < size) {
168
+ const want = Math.min(buf.length, size - offset);
169
+ const { bytesRead } = await fh.read(buf, 0, want, offset);
170
+ if (bytesRead <= 0) break;
171
+ offset += bytesRead;
172
+ carry += dec.write(buf.subarray(0, bytesRead));
173
+ let nl;
174
+ while ((nl = carry.indexOf("\n")) >= 0) {
175
+ onLine(carry.slice(0, nl));
176
+ carry = carry.slice(nl + 1);
177
+ }
178
+ }
179
+ carry += dec.end();
180
+ // Consumed up to the last newline; the leftover partial line's bytes stay
181
+ // uncounted so the next pass reads it whole.
182
+ return size - Buffer.byteLength(carry, "utf8");
183
+ }
184
+
185
+ /** Every `.jsonl` under the transcript roots, de-duplicated. */
186
+ async function listTranscripts(roots) {
187
+ const out = new Set();
188
+ for (const root of roots) {
189
+ let ents = null;
190
+ try { ents = await readdir(root, { recursive: true, withFileTypes: true }); }
191
+ catch { continue; }
192
+ for (const e of ents) {
193
+ if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
194
+ out.add(join(e.parentPath ?? e.path ?? root, e.name));
195
+ }
196
+ }
197
+ return [...out];
198
+ }
199
+
200
+ /** Drop day buckets older than the retention window and prune the empty
201
+ * branches left behind, so the file cannot grow without bound. */
202
+ function prune(tally, now = Date.now()) {
203
+ const keep = windowCutoff(RETAIN_DAYS, now);
204
+ if (!keep) return;
205
+ for (const acct of Object.keys(tally)) {
206
+ const projects = tally[acct];
207
+ for (const cwd of Object.keys(projects)) {
208
+ const days = projects[cwd];
209
+ for (const day of Object.keys(days)) if (day < keep) delete days[day];
210
+ if (!Object.keys(days).length) delete projects[cwd];
211
+ }
212
+ if (!Object.keys(projects).length) delete tally[acct];
213
+ }
214
+ }
215
+
216
+ /** Sum a project's per-day, per-model counters into one `{ model: counters }`
217
+ * over the days at or after `cutoff` (all days when cutoff is null). */
218
+ function sumModels(daysMap, cutoff) {
219
+ const models = {};
220
+ let any = false;
221
+ for (const day of Object.keys(daysMap)) {
222
+ if (cutoff && day < cutoff) continue;
223
+ for (const [model, c] of Object.entries(daysMap[day])) {
224
+ addInto(models[model] ??= zero(), c);
225
+ any = true;
226
+ }
227
+ }
228
+ return any ? models : null;
229
+ }
230
+
231
+ /**
232
+ * The report for one account over a window: its projects (each a per-model
233
+ * token breakdown for the client to price), plus the global unattributed
234
+ * bucket for the same window, and when tracking began.
235
+ *
236
+ * `days` of 0 (or null) means all retained days. Projects with no tokens in the
237
+ * window are dropped. Ordering is left to the client, which prices first.
238
+ */
239
+ export function reportFrom(tally, timeline, key, days, now = Date.now()) {
240
+ const cutoff = windowCutoff(days, now);
241
+ const projects = [];
242
+ for (const [cwd, daysMap] of Object.entries(tally[key] ?? {})) {
243
+ const models = sumModels(daysMap, cutoff);
244
+ if (models) projects.push({ path: cwd, name: basename(cwd) || cwd || "(unknown)", models });
245
+ }
246
+ // Unattributed is not any one account's, so it is collapsed across projects
247
+ // into a single bucket shown apart from the account's own totals.
248
+ const unModels = {};
249
+ let unAny = false;
250
+ for (const daysMap of Object.values(tally[UNATTRIBUTED] ?? {})) {
251
+ const m = sumModels(daysMap, cutoff);
252
+ if (m) { for (const [model, c] of Object.entries(m)) addInto(unModels[model] ??= zero(), c); unAny = true; }
253
+ }
254
+ return {
255
+ trackedSince: trackedSince(timeline),
256
+ days: days || 0,
257
+ projects,
258
+ unattributed: unAny ? unModels : null,
259
+ daily: dailyFrom(tally, key, cutoff),
260
+ };
261
+ }
262
+
263
+ /**
264
+ * The same window, sliced by day, for the per-day chart: for each day that has
265
+ * work, the per-project and the unattributed model counters. The unattributed
266
+ * bucket is kept per day too — not to draw it, but so the client can reconcile
267
+ * each day to ccusage over a complete denominator.
268
+ */
269
+ export function dailyFrom(tally, key, cutoff) {
270
+ const byDay = new Map();
271
+ const slot = day => { let s = byDay.get(day); if (!s) { s = { projects: {}, un: {} }; byDay.set(day, s); } return s; };
272
+ for (const [cwd, daysMap] of Object.entries(tally[key] ?? {})) {
273
+ for (const [day, models] of Object.entries(daysMap)) {
274
+ if (cutoff && day < cutoff) continue;
275
+ const dst = (slot(day).projects[cwd] ??= {});
276
+ for (const [m, c] of Object.entries(models)) addInto(dst[m] ??= zero(), c);
277
+ }
278
+ }
279
+ // Unattributed folds ONLY into days that already have attributed work — it
280
+ // feeds the per-day reconciliation denominator but must not raise a column of
281
+ // its own, or the chart grows an empty bar for every day of pre-tracking
282
+ // history (all of it unattributed).
283
+ for (const daysMap of Object.values(tally[UNATTRIBUTED] ?? {})) {
284
+ for (const [day, models] of Object.entries(daysMap)) {
285
+ const s = byDay.get(day);
286
+ if (!s) continue;
287
+ for (const [m, c] of Object.entries(models)) addInto(s.un[m] ??= zero(), c);
288
+ }
289
+ }
290
+ return [...byDay.entries()]
291
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1))
292
+ .map(([day, s]) => ({
293
+ day,
294
+ projects: Object.entries(s.projects).map(([path, models]) => ({ path, models })),
295
+ unattributed: Object.keys(s.un).length ? s.un : null,
296
+ }));
297
+ }
298
+
299
+ /**
300
+ * The rollup engine: a periodic incremental scan, an on-disk tally, and a
301
+ * synchronous `report`. Everything is injectable so the pass, the paths and the
302
+ * clock can be driven from a test without touching the real machine.
303
+ */
304
+ export function createProjectRollup({
305
+ now = Date.now,
306
+ intervalMs = DEFAULT_INTERVAL_MS,
307
+ roots = transcriptRoots(),
308
+ state: stateFile = statePath(),
309
+ swapLog, // path override for readSwapLog / seedActive
310
+ storeRoot, // claude-swap store root the seed reads (tests inject a bogus one)
311
+ setInterval: setIv = setInterval,
312
+ clearInterval: clearIv = clearInterval,
313
+ } = {}) {
314
+ let state = { version: 1, cursors: {}, tally: {} };
315
+ let timer = null;
316
+ let running = false;
317
+ let dirty = false;
318
+ let loaded = false;
319
+
320
+ async function load() {
321
+ if (loaded) return;
322
+ loaded = true;
323
+ try {
324
+ const disk = JSON.parse(await readFile(stateFile, "utf8"));
325
+ if (disk && disk.version === 1 && disk.tally && disk.cursors) state = disk;
326
+ } catch { /* first run: empty state */ }
327
+ }
328
+
329
+ async function persist() {
330
+ if (!dirty) return;
331
+ dirty = false;
332
+ prune(state.tally, now());
333
+ const tmp = stateFile + ".tmp";
334
+ try {
335
+ await mkdir(dirname(stateFile), { recursive: true });
336
+ await writeFile(tmp, JSON.stringify(state), "utf8");
337
+ // Atomic replace, so a crash never leaves half a file — through the
338
+ // shared retry ladder, because a bare rename loses to a virus scanner or
339
+ // an indexer holding the target open on Windows (installer.mjs's #786).
340
+ await renameWithRetry(tmp, stateFile);
341
+ } catch { /* a failed persist just re-folds next time; cursors stay in memory */ }
342
+ }
343
+
344
+ async function foldFile(path, timeline) {
345
+ let st = null;
346
+ try { st = await stat(path); } catch { return; }
347
+ let offset = state.cursors[path] ?? 0;
348
+ // A shrunk file was replaced or rotated. Re-folding from 0 would double-count
349
+ // what is already in the tally (which is not keyed by file), so instead
350
+ // resync the cursor to the current end: the replaced content is lost, never
351
+ // counted twice. Transcripts append and do not shrink in normal use, so this
352
+ // is the rare, safe side to err on.
353
+ if (st.size < offset) { state.cursors[path] = st.size; dirty = true; return; }
354
+ if (st.size <= offset) return;
355
+ const fallback = cwdFromSlug(basename(dirname(path)));
356
+ let fh = null;
357
+ try {
358
+ fh = await open(path, "r");
359
+ const consumed = await foldAppended(fh, offset, st.size, (line) => foldLine(state.tally, line, timeline, fallback));
360
+ if (consumed > offset) { state.cursors[path] = consumed; dirty = true; }
361
+ } catch { /* unreadable this pass; cursor unchanged, tried again next pass */ }
362
+ finally { if (fh) await fh.close().catch(() => {}); }
363
+ }
364
+
365
+ async function pass() {
366
+ if (running) return; // one pass at a time; folding twice would double totals
367
+ running = true;
368
+ try {
369
+ await load();
370
+ const nowMs = now();
371
+ // The deck was down between the last heartbeat and this boot. Fence that
372
+ // stretch off so work timestamped inside it is unattributed rather than
373
+ // charged to whoever was active when the deck closed — the user may have
374
+ // switched accounts by hand while nothing was recording. Written before
375
+ // the seed so the order is stop → start.
376
+ if (state.lastAlive && nowMs - state.lastAlive > GAP_MS) {
377
+ await markGap(state.lastAlive + 1, swapLog);
378
+ }
379
+ await seedActive({ now, path: swapLog, root: storeRoot }); // anchor the current account, once, deduped
380
+ const timeline = await readSwapLog(swapLog);
381
+ const files = await listTranscripts(roots);
382
+ for (const path of files) await foldFile(path, timeline);
383
+ // Forget cursors for files that are gone, so the map cannot grow forever.
384
+ const alive = new Set(files);
385
+ for (const p of Object.keys(state.cursors)) if (!alive.has(p)) { delete state.cursors[p]; dirty = true; }
386
+ // Heartbeat: record that the deck was alive now, so a later boot can see
387
+ // how long it was down. Cheap when nothing else changed — only bumped
388
+ // (and so only persisted) once every HEARTBEAT_MS while idle.
389
+ if (dirty || !state.lastAlive || nowMs - state.lastAlive > HEARTBEAT_MS) {
390
+ state.lastAlive = nowMs;
391
+ dirty = true;
392
+ }
393
+ await persist();
394
+ } finally { running = false; }
395
+ }
396
+
397
+ return {
398
+ async start() { await pass(); if (!timer) timer = setIv(() => { void pass(); }, intervalMs); if (timer.unref) timer.unref(); return this; },
399
+ stop() { if (timer) { clearIv(timer); timer = null; } },
400
+ /** Force one incremental pass now (used at boot and by tests). */
401
+ tick: pass,
402
+ /** Synchronous report from the in-memory tally + freshly-read swap log. */
403
+ async report(key, days) {
404
+ await load();
405
+ const timeline = await readSwapLog(swapLog);
406
+ return reportFrom(state.tally, timeline, key, days, now());
407
+ },
408
+ _state: () => state,
409
+ };
410
+ }
@@ -566,6 +566,15 @@ async function runTick() {
566
566
  // invalidating on those would throw away readings the deck paid a subprocess
567
567
  // for, every interval, forever.
568
568
  if (result.switched) forgetAccountScopedCaches();
569
+ // Record the move for the account-projects report. Only on a real switch, and
570
+ // best effort — the tick must not fail because a log line did not append. The
571
+ // slot is resolved from the store inside recordSwap, so cswap's `to` need not
572
+ // be threaded through here.
573
+ if (result.switched) {
574
+ import("./swap-log.mjs")
575
+ .then(({ recordSwap }) => recordSwap(result.to, "auto"))
576
+ .catch(() => {});
577
+ }
569
578
  _lastTick = { at: Date.now(), ...result };
570
579
  }
571
580