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.
@@ -0,0 +1,38 @@
1
+ /** A collector's verdict is about the stored copy, not the live CLI session.
2
+ * In particular, an inaccessible Keychain does not make the stored copy dead:
3
+ * treating it as dead starts a futile peer-healing loop on every LAN round. */
4
+ const LOST_COPY = new Set(["no_credentials", "relogin_required", "foreign_credential"]);
5
+
6
+ export function storedCopyAlive(alive, verdict) {
7
+ // An unreadable keychain or a deferred token refresh is a failure to CHECK,
8
+ // not evidence that the stored login has died. Do not repeatedly overwrite
9
+ // that slot with copies from a peer while the check is unavailable.
10
+ if (verdict === "keychain_unavailable" || verdict === "token_expired") return true;
11
+ return alive === true && !LOST_COPY.has(verdict);
12
+ }
13
+
14
+ /** When a collector verdict is present, only `ok` is positive export evidence.
15
+ * Unknown and transient verdict strings are therefore fail-closed. */
16
+ export function exportVerdictOk(verdict) {
17
+ return verdict === "ok";
18
+ }
19
+
20
+ /** Cached preflight for the LAN hot path. A KNOWN bad or unknown verdict is
21
+ * refused before spawning an export. An inactive slot with no verdict may reach
22
+ * the bounded export itself: it exports the stored copy, which carries the
23
+ * slot's own identity. The ACTIVE slot may not, because its export is the live
24
+ * CLI login, which can belong to another account or be expired while the
25
+ * blob's labels still name the slot, so the identity check cannot catch it.
26
+ * Its verdict is refreshed in the background and the next round shares it. */
27
+ export function cachedExportReadable(verdict, { active = false } = {}) {
28
+ if (verdict == null) return !active;
29
+ return exportVerdictOk(verdict);
30
+ }
31
+
32
+ /** Is the live CLI login the account this slot names? The active slot exports
33
+ * the live login, and a verdict can predate a `/login` as somebody else. */
34
+ export function liveLoginIs(identity, email, org) {
35
+ const live = String(identity?.email ?? "").trim().toLowerCase();
36
+ if (!live || live !== String(email ?? "").trim().toLowerCase()) return false;
37
+ return !identity.orgId || !org || identity.orgId === org;
38
+ }
@@ -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.
@@ -16,6 +16,7 @@
16
16
  import { readFile, writeFile, mkdir } from "node:fs/promises";
17
17
  import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
18
18
  import { run, runDetached } from "./exec.mjs";
19
+ import { storedCopyAlive } from "./account-health.mjs";
19
20
  // The CLI identity oracle, already written and already trusted by the account
20
21
  // admin routes. #721 needs the same answer, so it reuses the same function
21
22
  // rather than shelling out a second way to ask one question.
@@ -347,14 +348,39 @@ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
347
348
  * for `--json` and keeps the verdicts. Still not awaited by anyone — the nudge
348
349
  * stays synchronous for its callers — and still one child at a time.
349
350
  */
350
- let _verdicts = { at: 0, byNum: {} };
351
+ let _verdicts = { at: 0, byNum: {}, identities: {} };
351
352
  /** Stale after this, because a verdict that outlives its cause is worse than no
352
353
  * verdict: "no credentials" under an account somebody has since signed into is
353
354
  * a sentence that sends them to fix what is already fixed. */
354
355
  const VERDICT_TTL_MS = 10 * 60_000;
355
- /** One `cswap list --json` at a time. A collection can take a while on a cold
356
- * network, and a nudge landing inside one must not start a second. */
357
- let _verdictInFlight = false;
356
+ /** Run one `cswap list --json` at a time. A post-write question must start
357
+ * after any older collection has finished; routine readers can share the
358
+ * latest pending answer without starting another slow collection. */
359
+ export function createVerdictQueue(collect) {
360
+ let pending = null;
361
+ return {
362
+ ask({ fresh = false } = {}) {
363
+ if (pending && !fresh) return pending;
364
+ const previous = pending;
365
+ // Start the first poll immediately: requestCollection promises the UI that
366
+ // its refresh has started before it returns. Only subsequent fresh polls
367
+ // wait for the earlier snapshot to finish.
368
+ let next;
369
+ if (previous) next = previous.then(collect, collect);
370
+ else {
371
+ try { next = Promise.resolve(collect()); }
372
+ catch (error) { next = Promise.reject(error); }
373
+ }
374
+ pending = next;
375
+ void next.then(
376
+ () => { if (pending === next) pending = null; },
377
+ () => { if (pending === next) pending = null; },
378
+ );
379
+ return next;
380
+ },
381
+ busy() { return pending !== null; },
382
+ };
383
+ }
358
384
 
359
385
  /**
360
386
  * Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
@@ -374,6 +400,20 @@ let _verdictInFlight = false;
374
400
  export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
375
401
  const want = String(email ?? "").trim().toLowerCase();
376
402
  if (!want) return null;
403
+ const all = await verdictsNow({ runner, bin, fresh: true });
404
+ return all?.find(a => a.email === want && a.org === (org ?? ""))?.status ?? null;
405
+ }
406
+
407
+ /**
408
+ * Every account's verdict from ONE `cswap list --json`, as `{ email, org,
409
+ * status, active }` rows (email lower-cased, status null when claude-swap gave none),
410
+ * or null when the question could not be asked at all.
411
+ *
412
+ * For a caller holding several identities at once — a round that imported
413
+ * three logins asks once, not three times, since each ask is a full usage
414
+ * collection that can take a minute on a cold network.
415
+ */
416
+ async function collectVerdicts({ runner, bin }) {
377
417
  try {
378
418
  const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
379
419
  if (!out?.ok) return null;
@@ -381,20 +421,47 @@ export async function verdictNow(email, org, { runner = run, bin = cswapBin } =
381
421
  // The same freshly-read answer feeds the cache the panel draws from, since
382
422
  // it cost a subprocess either way.
383
423
  const byNum = readVerdicts(out.stdout);
384
- if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
424
+ const identities = {};
385
425
  for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
386
- if (String(a?.email ?? "").trim().toLowerCase() !== want) continue;
387
- if ((a?.organizationUuid ?? "") !== (org ?? "")) continue;
388
- return typeof a?.usageStatus === "string" ? a.usageStatus : null;
426
+ if (Number.isInteger(a?.number) && typeof a?.email === "string" && a.email.trim()) {
427
+ identities[String(a.number)] = `${a.email.trim().toLowerCase()}@@${a.organizationUuid ?? ""}`;
428
+ }
389
429
  }
390
- return null;
430
+ _verdicts = { at: Date.now(), byNum, identities };
431
+ return (Array.isArray(d?.accounts) ? d.accounts : []).map(a => ({
432
+ number: a?.number,
433
+ email: String(a?.email ?? "").trim().toLowerCase(),
434
+ org: a?.organizationUuid ?? "",
435
+ status: typeof a?.usageStatus === "string" ? a.usageStatus : null,
436
+ // For the account Claude Code is signed in as, claude-swap reads the
437
+ // LIVE credential, not the stored copy — so its verdict there is about
438
+ // the live login. See checkImports.
439
+ active: a?.active === true,
440
+ }));
391
441
  } catch { return null; }
392
442
  }
393
443
 
444
+ const verdictQueue = createVerdictQueue(() => collectVerdicts({ runner: run, bin: cswapBin }));
445
+
446
+ /** Routine readers share a collection; post-write callers wait for any older
447
+ * collection and start a new one, so they never inspect the pre-write store. */
448
+ export function verdictsNow({ runner = run, bin = cswapBin, fresh = false } = {}) {
449
+ // Tests and callers supplying their own runner must receive their own answer.
450
+ if (runner !== run || bin !== cswapBin) return collectVerdicts({ runner, bin });
451
+ return verdictQueue.ask({ fresh });
452
+ }
453
+
394
454
  /** claude-swap's verdict for a slot, or null when there is none fresh enough. */
395
- function verdictFor(num, now) {
396
- if (now - _verdicts.at > VERDICT_TTL_MS) return null;
397
- const v = _verdicts.byNum[String(num)];
455
+ function verdictFor(num, now, email, org) {
456
+ return cachedVerdictFor(_verdicts, num, now, email, org);
457
+ }
458
+
459
+ /** Only attach a cached slot verdict to the identity it was collected for. */
460
+ export function cachedVerdictFor(cache, num, now, email, org) {
461
+ if (now - cache.at > VERDICT_TTL_MS) return null;
462
+ const identity = `${String(email ?? "").trim().toLowerCase()}@@${org ?? ""}`;
463
+ if (cache.identities[String(num)] !== identity) return null;
464
+ const v = cache.byNum[String(num)];
398
465
  return typeof v === "string" && v !== "" ? v : null;
399
466
  }
400
467
 
@@ -439,22 +506,12 @@ function nudgeCollector(rows, slots, now, activeNum) {
439
506
  cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
440
507
  return;
441
508
  }
442
- if (_verdictInFlight) return;
443
- _verdictInFlight = true;
509
+ if (verdictQueue.busy()) return;
444
510
  // Fire-and-forget: this function is deliberately synchronous so callers never
445
511
  // wait on it, and resolving the binary is the only async part. `run` rather
446
512
  // than `runDetached` only so the output can be read; the caller is no more
447
513
  // aware of it than before.
448
- cswapBin()
449
- .then(bin => run(bin, ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS }))
450
- .then(out => {
451
- const byNum = out?.ok ? readVerdicts(out.stdout) : {};
452
- // Replaced whole rather than merged. A slot that has gone away must not
453
- // keep the verdict it had when it was last seen.
454
- if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
455
- })
456
- .catch(() => {})
457
- .finally(() => { _verdictInFlight = false; });
514
+ void verdictsNow();
458
515
  }
459
516
 
460
517
  /** Long enough for a cold collection over a slow network, short enough that a
@@ -637,6 +694,7 @@ async function readRoster(now, gen) {
637
694
  .filter(Boolean),
638
695
  ].filter(Boolean);
639
696
 
697
+ const collector = verdictFor(num, now, acct.email, acct.organizationUuid);
640
698
  accounts.push({
641
699
  num: Number(num),
642
700
  email: acct.email ?? null,
@@ -654,7 +712,7 @@ async function readRoster(now, gen) {
654
712
  // `stale-copy` row means the live session is fine while the copy in the
655
713
  // store is dead, and the copy is what a share would carry and what a
656
714
  // peer's copy would heal. So both kinds of trouble read as not alive.
657
- alive: trouble == null,
715
+ alive: storedCopyAlive(trouble == null, collector),
658
716
  active: String(seq.activeAccountNumber) === num,
659
717
  disabled: acct.disabled === true,
660
718
  lanes,
@@ -694,7 +752,7 @@ async function readRoster(now, gen) {
694
752
  // "no_credentials", "relogin_required", "keychain_unavailable", … It is
695
753
  // what turns "not collecting" into a sentence with a next step in it, and
696
754
  // it is null on every machine where the collector has not been asked yet.
697
- collector: verdictFor(num, now),
755
+ collector,
698
756
  });
699
757
  }
700
758
 
@@ -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
+ }