ccdeck 3.29.0 → 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.
- package/dist/web/assets/{BrowserWatchModal-CxfWHmjb.js → BrowserWatchModal-C-TkzDPi.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-D-XRttte.js → UsageHistoryModal-FKBbTozZ.js} +1 -1
- package/dist/web/assets/{index-DrbRdXDD.js → index-7mOqoJ3Y.js} +2 -2
- package/dist/web/assets/index-FeNTTJ0x.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/server/account-projects.mjs +89 -23
- package/src/server/claude-dir.mjs +35 -0
- package/src/server/index.mjs +4 -35
- package/dist/web/assets/index-GTZ73XfY.css +0 -1
|
@@ -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 =
|
|
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,
|
|
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
|
|
155
|
-
const cwd = projectPath(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
|
|
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
|
|
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,
|
|
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,
|
|
396
|
-
|
|
397
|
-
for (const p of Object.keys(state.cursors)) if (!
|
|
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
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -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";
|
|
@@ -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
|
*
|