ccdeck 3.29.0 → 3.29.3

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.
@@ -15,17 +15,18 @@
15
15
  // accounts; only the message's own timestamp, matched against the swap log,
16
16
  // says who was active when it was billed. Each transcript line carries, in one
17
17
  // JSON object, its `timestamp`, its `message.model` and its `message.usage`
18
- // token counts, and its `cwd` — everything a row needs.
18
+ // token counts, and its `cwd` — everything a row needs, once the `cwd` is taken
19
+ // back to the folder the session started in (see sessionFolder).
19
20
  //
20
21
  // The scan is incremental: a byte cursor per transcript file means a pass folds
21
22
  // only the lines appended since the last one, so opening the report is a read
22
23
  // of a small on-disk tally rather than a walk of every transcript.
23
24
  import { open, stat, readdir, readFile, writeFile, mkdir } from "node:fs/promises";
24
25
  import { homedir } from "node:os";
25
- import { join, resolve, basename, dirname } from "node:path";
26
+ import { join, resolve, basename, dirname, relative } from "node:path";
26
27
  import { StringDecoder } from "node:string_decoder";
27
28
  import { renameWithRetry } from "./installer.mjs";
28
- import { claudeConfigDir } from "./claude-dir.mjs";
29
+ import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
29
30
  import { accountKey } from "./lan-sync.mjs";
30
31
  import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from "./swap-log.mjs";
31
32
 
@@ -34,7 +35,7 @@ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from ".
34
35
  * a per-account total is never quietly inflated by work that is not that
35
36
  * account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
36
37
  export const UNATTRIBUTED = "\u0000unattributed";
37
- const STATE_VERSION = 2;
38
+ const STATE_VERSION = 3;
38
39
 
39
40
  /** Where the tally and cursors live, beside cswap-auto's own state. */
40
41
  export function statePath(home = homedir()) {
@@ -113,6 +114,51 @@ function cwdFromSlug(slug) {
113
114
  return "/" + slug.replace(/^-+/, "").replace(/-/g, "/");
114
115
  }
115
116
 
117
+ /**
118
+ * The folder a transcript line's session started in, recovered exactly from the
119
+ * line's own `cwd` and the slug of the project directory the transcript sits in.
120
+ *
121
+ * CC writes the shell's CURRENT directory on every line, so an agent that ran
122
+ * `cd src/web` stamps every later line with that subfolder. Keying the tally by
123
+ * it split one project into a row for every folder its agent passed through —
124
+ * 25 rows for one repo over a month (#1278). The folder CC files the transcript
125
+ * under, ~/.claude/projects/<slug>, is the one the session started in, and the
126
+ * canvas names a session after the same one. The slug cannot be decoded (a dash
127
+ * in a folder name reads as a separator), but it can be MATCHED: the start
128
+ * folder is the nearest ancestor of the line's cwd, the cwd itself included,
129
+ * that encodes to that slug.
130
+ *
131
+ * Null when none does: the agent left its folder (`cd ../elsewhere`), or the
132
+ * slug is CC's hashed long form of a path this cwd is not under.
133
+ */
134
+ export function sessionFolder(cwd, slug) {
135
+ if (!cwd || !slug) return null;
136
+ for (let dir = cwd; ;) {
137
+ if (ccProjectSlug(dir) === slug) return dir;
138
+ const up = dirname(dir);
139
+ if (up === dir) return null;
140
+ dir = up;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Which folder a line counts under. `from` describes the transcript the line
146
+ * came from: `slug` is its project directory's name, and `folders` maps each
147
+ * slug to the start folder last recovered for it. The map is shared by every
148
+ * transcript of one project directory and persisted, so a line written after
149
+ * the agent left its folder still counts under that folder. Without a slug (a
150
+ * direct call, as the unit tests make) the line's own cwd is used unchanged.
151
+ */
152
+ function folderOf(lineCwd, { slug = "", folders = null } = {}) {
153
+ const found = sessionFolder(lineCwd, slug);
154
+ if (found) {
155
+ if (folders) folders[slug] = found;
156
+ return found;
157
+ }
158
+ if (slug && folders?.[slug]) return folders[slug];
159
+ return lineCwd || cwdFromSlug(slug);
160
+ }
161
+
116
162
  /**
117
163
  * The project a cwd belongs to. A git worktree lives at
118
164
  * `<repo>/.claude/worktrees/<name>`, so work done in one is counted under the
@@ -135,8 +181,9 @@ export function projectPath(cwd) {
135
181
  *
136
182
  * `timeline` is the sorted swap log; the account is whoever it says was active
137
183
  * at this line's timestamp, or UNATTRIBUTED when the timestamp precedes it.
184
+ * `from` names the transcript's project directory — see folderOf.
138
185
  */
139
- export function foldLine(tally, line, timeline, fallbackCwd) {
186
+ export function foldLine(tally, line, timeline, from) {
140
187
  if (!line || !line.includes('"usage"')) return;
141
188
  let obj = null;
142
189
  try { obj = JSON.parse(line); } catch { return; }
@@ -151,8 +198,8 @@ export function foldLine(tally, line, timeline, fallbackCwd) {
151
198
  const c = countersFrom(usage);
152
199
  if (!(c.i || c.o || c.cr || c.cc)) return; // a usage block that billed nothing
153
200
  const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
154
- const rawCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : (fallbackCwd || "");
155
- const cwd = projectPath(rawCwd); // a worktree counts under the repo it checks out
201
+ const lineCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : "";
202
+ const cwd = projectPath(folderOf(lineCwd, from)); // a worktree counts under the repo it checks out
156
203
  const who = accountAtTime(timeline, ts);
157
204
  const key = who ? accountKey(who.email, who.orgUuid) : UNATTRIBUTED;
158
205
  const day = localDay(ts);
@@ -187,19 +234,32 @@ async function foldAppended(fh, start, size, onLine) {
187
234
  return size - Buffer.byteLength(carry, "utf8");
188
235
  }
189
236
 
190
- /** Every `.jsonl` under the transcript roots, de-duplicated. */
237
+ /** Every `.jsonl` under the transcript roots, de-duplicated, mapped to the
238
+ * slug of the project directory it sits in: the first folder below the root,
239
+ * also for a subagent's `<slug>/<session>/subagents/agent-*.jsonl`.
240
+ *
241
+ * Shallowest first, so a session's own transcript, whose first line carries
242
+ * the folder it started in, is folded before its subagents' and a subagent
243
+ * that only ever worked outside that folder still finds it in `folders`. */
191
244
  async function listTranscripts(roots) {
192
- const out = new Set();
245
+ const found = [];
246
+ const seen = new Set();
193
247
  for (const root of roots) {
194
248
  let ents = null;
195
249
  try { ents = await readdir(root, { recursive: true, withFileTypes: true }); }
196
250
  catch { continue; }
197
251
  for (const e of ents) {
198
252
  if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
199
- out.add(join(e.parentPath ?? e.path ?? root, e.name));
253
+ const dir = e.parentPath ?? e.path ?? root;
254
+ const path = join(dir, e.name);
255
+ if (seen.has(path)) continue;
256
+ seen.add(path);
257
+ const parts = relative(root, dir).split(/[\\/]/).filter(Boolean);
258
+ found.push({ path, slug: parts[0] ?? "", depth: parts.length });
200
259
  }
201
260
  }
202
- return [...out];
261
+ found.sort((a, b) => a.depth - b.depth);
262
+ return new Map(found.map(f => [f.path, f.slug]));
203
263
  }
204
264
 
205
265
  /** Drop day buckets older than the retention window and prune the empty
@@ -316,7 +376,7 @@ export function createProjectRollup({
316
376
  setInterval: setIv = setInterval,
317
377
  clearInterval: clearIv = clearInterval,
318
378
  } = {}) {
319
- let state = { version: STATE_VERSION, cursors: {}, tally: {} };
379
+ let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {} };
320
380
  let timer = null;
321
381
  let running = false;
322
382
  let dirty = false;
@@ -329,10 +389,14 @@ export function createProjectRollup({
329
389
  const disk = JSON.parse(await readFile(stateFile, "utf8"));
330
390
  if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
331
391
  state = disk;
332
- } else if (disk && disk.version === 1) {
333
- // Version 1 counted every API content block. Keep the heartbeat so a
334
- // restart gap remains fenced, but rebuild the tally from transcripts.
335
- state = { version: STATE_VERSION, cursors: {}, tally: {}, lastAlive: disk.lastAlive };
392
+ state.folders ??= {};
393
+ } else if (disk && (disk.version === 1 || disk.version === 2)) {
394
+ // Version 1 counted every API content block; version 2 keyed each row
395
+ // by the line's own cwd, a row per folder the agent cd'd into (#1278).
396
+ // Neither tally can be re-keyed, since it no longer knows which
397
+ // transcript a count came from. Keep the heartbeat so a restart gap
398
+ // remains fenced, but rebuild the tally from transcripts.
399
+ state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, lastAlive: disk.lastAlive };
336
400
  dirty = true;
337
401
  }
338
402
  } catch { /* first run: empty state */ }
@@ -353,7 +417,7 @@ export function createProjectRollup({
353
417
  } catch { /* a failed persist just re-folds next time; cursors stay in memory */ }
354
418
  }
355
419
 
356
- async function foldFile(path, timeline) {
420
+ async function foldFile(path, slug, timeline) {
357
421
  let st = null;
358
422
  try { st = await stat(path); } catch { return; }
359
423
  let offset = state.cursors[path] ?? 0;
@@ -364,11 +428,11 @@ export function createProjectRollup({
364
428
  // is the rare, safe side to err on.
365
429
  if (st.size < offset) { state.cursors[path] = st.size; dirty = true; return; }
366
430
  if (st.size <= offset) return;
367
- const fallback = cwdFromSlug(basename(dirname(path)));
431
+ const from = { slug, folders: state.folders };
368
432
  let fh = null;
369
433
  try {
370
434
  fh = await open(path, "r");
371
- const consumed = await foldAppended(fh, offset, st.size, (line) => foldLine(state.tally, line, timeline, fallback));
435
+ const consumed = await foldAppended(fh, offset, st.size, (line) => foldLine(state.tally, line, timeline, from));
372
436
  if (consumed > offset) { state.cursors[path] = consumed; dirty = true; }
373
437
  } catch { /* unreadable this pass; cursor unchanged, tried again next pass */ }
374
438
  finally { if (fh) await fh.close().catch(() => {}); }
@@ -391,10 +455,12 @@ export function createProjectRollup({
391
455
  await seedActive({ now, path: swapLog, root: storeRoot }); // anchor the current account, once, deduped
392
456
  const timeline = await readSwapLog(swapLog);
393
457
  const files = await listTranscripts(roots);
394
- for (const path of files) await foldFile(path, timeline);
395
- // Forget cursors for files that are gone, so the map cannot grow forever.
396
- const alive = new Set(files);
397
- for (const p of Object.keys(state.cursors)) if (!alive.has(p)) { delete state.cursors[p]; dirty = true; }
458
+ for (const [path, slug] of files) await foldFile(path, slug, timeline);
459
+ // Forget cursors for files that are gone, and the folders of project
460
+ // directories with no transcript left, so neither map grows forever.
461
+ for (const p of Object.keys(state.cursors)) if (!files.has(p)) { delete state.cursors[p]; dirty = true; }
462
+ const slugs = new Set(files.values());
463
+ for (const s of Object.keys(state.folders)) if (!slugs.has(s)) { delete state.folders[s]; dirty = true; }
398
464
  // Heartbeat: record that the deck was alive now, so a later boot can see
399
465
  // how long it was down. Cheap when nothing else changed — only bumped
400
466
  // (and so only persisted) once every HEARTBEAT_MS while idle.
@@ -211,3 +211,38 @@ export function hasClaudeInstalled({
211
211
  // inside ~/.claude — so the loop above cannot reach it there.
212
212
  return exists(path.join(home, ".claude.json"));
213
213
  }
214
+
215
+ /** Longest slug CC will store verbatim. Anything longer is truncated here and
216
+ * given a hash suffix, so two deep paths sharing a 200-character prefix still
217
+ * get separate directories. */
218
+ const CC_SLUG_MAX = 200;
219
+
220
+ /** CC's hash of the *unencoded* path, used only for the truncation suffix:
221
+ * the classic h*31 + c string hash, kept in a signed 32-bit int. */
222
+ function ccPathHash(s) {
223
+ let h = 0;
224
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
225
+ return h;
226
+ }
227
+
228
+ /** Encode an absolute path the way CC stores it under
229
+ * ~/.claude/projects/<slug>/, so a scan looks in the directory CC actually
230
+ * wrote — index.mjs's auto-memory scan, and the Projects rollup reading a
231
+ * transcript's folder back out of it (account-projects.mjs).
232
+ *
233
+ * CC flattens *every* non-alphanumeric character to "-", not just the path
234
+ * separators and the Windows drive colon this used to replace. Dots are the
235
+ * ones that bite: a worktree under .claude/worktrees/, or any folder named
236
+ * like my.app, produced a slug with a literal dot in it, the auto-memory
237
+ * readdir missed, and the project's auto-memory files were quietly left out
238
+ * of the context panel. Underscores and spaces were wrong the same way. */
239
+ export function ccProjectSlug(cwd) {
240
+ if (!cwd) return "";
241
+ // resolve() gives the platform's own absolute form — "/Users/…" on
242
+ // macOS/Linux, "C:\Users\…" on Windows — and this class covers both, so the
243
+ // drive colon and the backslashes fall out of the general rule.
244
+ const abs = resolve(cwd);
245
+ const slug = abs.replace(/[^a-zA-Z0-9]/g, "-");
246
+ if (slug.length <= CC_SLUG_MAX) return slug;
247
+ return `${slug.slice(0, CC_SLUG_MAX)}-${Math.abs(ccPathHash(abs)).toString(36)}`;
248
+ }
@@ -285,6 +285,24 @@ function unreadablePrefs(path, why) {
285
285
  return err;
286
286
  }
287
287
 
288
+ /** The errors a filesystem raises for a place this user may not write — most
289
+ * often a settings folder a `sudo` run left owned by root (#1335). */
290
+ const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
291
+
292
+ /**
293
+ * Why a settings write failed, as a reason the panel can name — or null when
294
+ * the failure is not one of the two a person can fix from outside the deck.
295
+ *
296
+ * A code, never the message: the message carries the absolute path, and a
297
+ * route's body is readable by a DNS-rebound page (see sendInternalError). The
298
+ * deck's log keeps the path for the person who has to go and look.
299
+ */
300
+ export function prefsWriteRefusal(err) {
301
+ if (err?.code === "PREFS_UNREADABLE") return "prefs_unreadable";
302
+ if (NOT_WRITABLE.has(err?.code)) return "prefs_not_writable";
303
+ return null;
304
+ }
305
+
288
306
  /**
289
307
  * Read prefs.json, and say WHICH of four things happened — because three of
290
308
  * them hand back the same object and only one of them means it.
@@ -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";
@@ -34,7 +37,7 @@ import { createOutputWatch } from "./output-watch.mjs";
34
37
  import { RECAP_MARK, foldRecapLine } from "./session-recap.mjs";
35
38
  import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
36
39
  import { createPresence } from "./presence.mjs";
37
- import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, withAlias, withManualEntry, withShared, writePrefs } from "./deck-prefs.mjs";
40
+ import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, prefsWriteRefusal, publicPrefs, readPrefs, updatePrefs, withAlias, withManualEntry, withShared, writePrefs } from "./deck-prefs.mjs";
38
41
  import { createEngine, defaultName } from "./lan-engine.mjs";
39
42
  import { createTailnet, IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
40
43
  import { portHolder } from "./port-holder.mjs";
@@ -1843,40 +1846,6 @@ export async function readContextFromTranscript(path) {
1843
1846
  return { ...state.ctx };
1844
1847
  }
1845
1848
 
1846
- /** Longest slug CC will store verbatim. Anything longer is truncated here and
1847
- * given a hash suffix, so two deep paths sharing a 200-character prefix still
1848
- * get separate directories. */
1849
- const CC_SLUG_MAX = 200;
1850
-
1851
- /** CC's hash of the *unencoded* path, used only for the truncation suffix:
1852
- * the classic h*31 + c string hash, kept in a signed 32-bit int. */
1853
- function ccPathHash(s) {
1854
- let h = 0;
1855
- for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
1856
- return h;
1857
- }
1858
-
1859
- /** Encode an absolute path the way CC stores it under
1860
- * ~/.claude/projects/<slug>/, so the auto-memory scan below looks in the
1861
- * directory CC actually wrote.
1862
- *
1863
- * CC flattens *every* non-alphanumeric character to "-", not just the path
1864
- * separators and the Windows drive colon this used to replace. Dots are the
1865
- * ones that bite: a worktree under .claude/worktrees/, or any folder named
1866
- * like my.app, produced a slug with a literal dot in it, the readdir below
1867
- * missed, and the project's auto-memory files were quietly left out of the
1868
- * context panel. Underscores and spaces were wrong the same way. */
1869
- export function ccProjectSlug(cwd) {
1870
- if (!cwd) return "";
1871
- // resolve() gives the platform's own absolute form — "/Users/…" on
1872
- // macOS/Linux, "C:\Users\…" on Windows — and this class covers both, so the
1873
- // drive colon and the backslashes fall out of the general rule.
1874
- const abs = resolve(cwd);
1875
- const slug = abs.replace(/[^a-zA-Z0-9]/g, "-");
1876
- if (slug.length <= CC_SLUG_MAX) return slug;
1877
- return `${slug.slice(0, CC_SLUG_MAX)}-${Math.abs(ccPathHash(abs)).toString(36)}`;
1878
- }
1879
-
1880
1849
  /**
1881
1850
  * Candidate memory-file paths on the walk from `cwd` up to the filesystem root.
1882
1851
  *
@@ -3852,7 +3821,17 @@ async function handlePrefsWrite(req, res) {
3852
3821
  let body = null;
3853
3822
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3854
3823
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
3855
- _prefs = await writePrefs(body);
3824
+ try {
3825
+ _prefs = await writePrefs(body);
3826
+ } catch (err) {
3827
+ // A settings file or folder this user cannot reach is the machine's to fix,
3828
+ // not the deck's, and `guard`'s bare 500 left the panel with nothing to say
3829
+ // about it (#1335). Anything else is still a bug and still goes to `guard`.
3830
+ const reason = prefsWriteRefusal(err);
3831
+ if (!reason) throw err;
3832
+ console.error(`${PRODUCT}: settings were not saved:`, err?.message ?? err);
3833
+ return send(res, 500, { ok: false, reason });
3834
+ }
3856
3835
  // The engine reads its settings from here rather than holding its own copy,
3857
3836
  // so turning the switch off in the panel really does stop the sockets rather
3858
3837
  // than only changing what the panel says.