@vibe-cafe/vibe-usage 0.10.0 → 0.10.2
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/README.md +1 -1
- package/package.json +1 -1
- package/src/api.js +16 -8
- package/src/claude-roots.js +81 -0
- package/src/client-meta.js +44 -0
- package/src/daemon-service.js +23 -4
- package/src/daemon.js +1 -1
- package/src/parsers/claude-code.js +250 -188
- package/src/sync.js +32 -6
- package/src/tools.js +1 -14
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
50
50
|
|
|
51
51
|
| Tool | Data Location |
|
|
52
52
|
|------|---------------|
|
|
53
|
-
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR`
|
|
53
|
+
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR` and data-bearing `~/.claude-*` profiles, selects the most complete duplicate session, and streams logs so large active sessions are not silently omitted. Cache creation tokens are included in input usage. |
|
|
54
54
|
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`); a versioned local index avoids re-reading unchanged rollouts and reads only safe append tails for ordinary sessions, while fork/sub-agent replay matching, duplicate suppression, and live/archive deduplication retain their existing semantics |
|
|
55
55
|
| Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`); token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; honors `GROK_HOME` |
|
|
56
56
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -14,7 +14,7 @@ export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
|
14
14
|
let lastError;
|
|
15
15
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
16
16
|
try {
|
|
17
|
-
return await _send(apiUrl, apiKey, buckets, opts
|
|
17
|
+
return await _send(apiUrl, apiKey, buckets, opts, sessions);
|
|
18
18
|
} catch (err) {
|
|
19
19
|
lastError = err;
|
|
20
20
|
// Don't retry auth errors or client errors
|
|
@@ -30,14 +30,22 @@ export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
|
30
30
|
throw lastError;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function
|
|
33
|
+
export function encodeIngestBody(buckets, opts, sessions) {
|
|
34
|
+
const payload = { buckets };
|
|
35
|
+
if (sessions && sessions.length > 0) payload.sessions = sessions;
|
|
36
|
+
if (opts?.client) payload.client = opts.client;
|
|
37
|
+
const raw = Buffer.from(JSON.stringify(payload));
|
|
38
|
+
const useGzip = raw.length >= GZIP_MIN_BYTES;
|
|
39
|
+
return {
|
|
40
|
+
body: useGzip ? gzipSync(raw) : raw,
|
|
41
|
+
useGzip,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _send(apiUrl, apiKey, buckets, opts, sessions) {
|
|
34
46
|
return new Promise((resolve, reject) => {
|
|
35
47
|
const url = new URL('/api/usage/ingest', apiUrl);
|
|
36
|
-
const
|
|
37
|
-
if (sessions && sessions.length > 0) payload.sessions = sessions;
|
|
38
|
-
const raw = Buffer.from(JSON.stringify(payload));
|
|
39
|
-
const useGzip = raw.length >= GZIP_MIN_BYTES;
|
|
40
|
-
const body = useGzip ? gzipSync(raw) : raw;
|
|
48
|
+
const { body, useGzip } = encodeIngestBody(buckets, opts, sessions);
|
|
41
49
|
const totalBytes = body.length;
|
|
42
50
|
const mod = url.protocol === 'https:' ? https : http;
|
|
43
51
|
|
|
@@ -89,7 +97,7 @@ function _send(apiUrl, apiKey, buckets, onProgress, sessions) {
|
|
|
89
97
|
while (ok && sent < totalBytes) {
|
|
90
98
|
const slice = body.subarray(sent, sent + CHUNK);
|
|
91
99
|
sent += slice.length;
|
|
92
|
-
if (onProgress) onProgress(sent, totalBytes);
|
|
100
|
+
if (opts?.onProgress) opts.onProgress(sent, totalBytes);
|
|
93
101
|
ok = req.write(slice);
|
|
94
102
|
}
|
|
95
103
|
if (sent < totalBytes) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { delimiter, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
function expandHome(value) {
|
|
6
|
+
const trimmed = value.trim().replace(/[/\\]+$/, '');
|
|
7
|
+
if (trimmed === '~') return homedir();
|
|
8
|
+
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|
9
|
+
return join(homedir(), trimmed.slice(2));
|
|
10
|
+
}
|
|
11
|
+
return trimmed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hasClaudeData(root) {
|
|
15
|
+
return existsSync(join(root, 'projects')) || existsSync(join(root, 'transcripts'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return every Claude Code state root visible from this process.
|
|
20
|
+
*
|
|
21
|
+
* In addition to the default and CLAUDE_CONFIG_DIR, discover the documented
|
|
22
|
+
* multi-profile convention (~/.claude-work, ~/.claude-personal, ...). This is
|
|
23
|
+
* important for launchd/systemd and GUI processes, which commonly do not
|
|
24
|
+
* inherit the shell environment used to launch Claude Code.
|
|
25
|
+
*
|
|
26
|
+
* VIBE_USAGE_CLAUDE_DIRS is a test/diagnostic override. It replaces discovery
|
|
27
|
+
* with a path.delimiter-separated root list.
|
|
28
|
+
*/
|
|
29
|
+
export function getClaudeRoots() {
|
|
30
|
+
const override = process.env.VIBE_USAGE_CLAUDE_DIRS?.trim();
|
|
31
|
+
const roots = override
|
|
32
|
+
? override.split(delimiter).map(expandHome).filter(Boolean)
|
|
33
|
+
: [join(homedir(), '.claude')];
|
|
34
|
+
|
|
35
|
+
if (!override) {
|
|
36
|
+
const configured = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
37
|
+
if (configured) roots.push(expandHome(configured));
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
for (const entry of readdirSync(homedir(), { withFileTypes: true })) {
|
|
41
|
+
// Profiles are sometimes symlinked, so let hasClaudeData() follow the
|
|
42
|
+
// entry instead of requiring Dirent.isDirectory() here.
|
|
43
|
+
if (!/^\.claude-.+/.test(entry.name)) continue;
|
|
44
|
+
const candidate = join(homedir(), entry.name);
|
|
45
|
+
if (hasClaudeData(candidate)) roots.push(candidate);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// The default/configured roots remain usable if home discovery fails.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const unique = [];
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
let canonical = root;
|
|
56
|
+
try {
|
|
57
|
+
canonical = realpathSync(root);
|
|
58
|
+
} catch {
|
|
59
|
+
// Keep a missing explicit/default root so callers can report it normally.
|
|
60
|
+
}
|
|
61
|
+
if (seen.has(canonical)) continue;
|
|
62
|
+
seen.add(canonical);
|
|
63
|
+
unique.push(root);
|
|
64
|
+
}
|
|
65
|
+
return unique;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function findClaudeCodeDataDirs() {
|
|
69
|
+
const dirs = [];
|
|
70
|
+
for (const root of getClaudeRoots()) {
|
|
71
|
+
for (const name of ['projects', 'transcripts']) {
|
|
72
|
+
const candidate = join(root, name);
|
|
73
|
+
try {
|
|
74
|
+
if (statSync(candidate).isDirectory()) dirs.push(candidate);
|
|
75
|
+
} catch {
|
|
76
|
+
// Missing or unreadable roots are handled by the parser.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return dirs;
|
|
81
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const pkg = JSON.parse(
|
|
5
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf-8'),
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
const SURFACES = new Set(['cli', 'daemon', 'mac-app', 'windows-app']);
|
|
9
|
+
|
|
10
|
+
export const COLLECTOR_VERSION = String(pkg.version);
|
|
11
|
+
|
|
12
|
+
function cleanEnv(value, maxLength = 50) {
|
|
13
|
+
if (typeof value !== 'string') return null;
|
|
14
|
+
const cleaned = value.trim().slice(0, maxLength);
|
|
15
|
+
return cleaned || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createSyncClient({ defaultSurface = 'cli', hostname } = {}) {
|
|
19
|
+
const requestedSurface = cleanEnv(process.env.VIBE_USAGE_SURFACE, 30);
|
|
20
|
+
const surface = requestedSurface && SURFACES.has(requestedSurface)
|
|
21
|
+
? requestedSurface
|
|
22
|
+
: defaultSurface;
|
|
23
|
+
const runtime = process.versions.bun ? 'bun' : 'node';
|
|
24
|
+
const runtimeVersion = process.versions.bun || process.versions.node;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
collectorVersion: COLLECTOR_VERSION,
|
|
28
|
+
surface,
|
|
29
|
+
surfaceVersion: cleanEnv(process.env.VIBE_USAGE_SURFACE_VERSION) || COLLECTOR_VERSION,
|
|
30
|
+
runtime,
|
|
31
|
+
runtimeVersion,
|
|
32
|
+
platform: process.platform,
|
|
33
|
+
hostname: cleanEnv(hostname, 200) || 'unknown',
|
|
34
|
+
syncId: randomUUID(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function forBatch(client, batchIndex, batchCount) {
|
|
39
|
+
return {
|
|
40
|
+
...client,
|
|
41
|
+
batchIndex,
|
|
42
|
+
batchCount,
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/daemon-service.js
CHANGED
|
@@ -43,7 +43,23 @@ function getServicePaths(plat) {
|
|
|
43
43
|
return null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function
|
|
46
|
+
function escapeSystemdEnvironment(value) {
|
|
47
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escapeXml(value) {
|
|
51
|
+
return value
|
|
52
|
+
.replace(/&/g, '&')
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"')
|
|
56
|
+
.replace(/'/g, ''');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function generateSystemdUnit(nodePath, binPath, claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim()) {
|
|
60
|
+
const claudeEnvironment = claudeConfigDir
|
|
61
|
+
? `Environment="CLAUDE_CONFIG_DIR=${escapeSystemdEnvironment(claudeConfigDir)}"\n`
|
|
62
|
+
: '';
|
|
47
63
|
return `[Unit]
|
|
48
64
|
Description=VibeCafe Usage Tracker
|
|
49
65
|
After=network.target
|
|
@@ -54,15 +70,18 @@ ExecStart=${nodePath} ${binPath} daemon
|
|
|
54
70
|
Restart=on-failure
|
|
55
71
|
RestartSec=10
|
|
56
72
|
Environment=NODE_ENV=production
|
|
57
|
-
WorkingDirectory=${homedir()}
|
|
73
|
+
${claudeEnvironment}WorkingDirectory=${homedir()}
|
|
58
74
|
|
|
59
75
|
[Install]
|
|
60
76
|
WantedBy=default.target
|
|
61
77
|
`;
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
function generateLaunchdPlist(nodePath, binPath) {
|
|
80
|
+
export function generateLaunchdPlist(nodePath, binPath, claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim()) {
|
|
65
81
|
const logDir = join(homedir(), '.vibe-usage');
|
|
82
|
+
const claudeEnvironment = claudeConfigDir
|
|
83
|
+
? ` <key>CLAUDE_CONFIG_DIR</key>\n <string>${escapeXml(claudeConfigDir)}</string>\n`
|
|
84
|
+
: '';
|
|
66
85
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
67
86
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
68
87
|
<plist version="1.0">
|
|
@@ -89,7 +108,7 @@ function generateLaunchdPlist(nodePath, binPath) {
|
|
|
89
108
|
<dict>
|
|
90
109
|
<key>NODE_ENV</key>
|
|
91
110
|
<string>production</string>
|
|
92
|
-
</dict>
|
|
111
|
+
${claudeEnvironment} </dict>
|
|
93
112
|
</dict>
|
|
94
113
|
</plist>
|
|
95
114
|
`;
|
package/src/daemon.js
CHANGED
|
@@ -34,7 +34,7 @@ export async function runDaemon() {
|
|
|
34
34
|
// eslint-disable-next-line no-constant-condition
|
|
35
35
|
while (true) {
|
|
36
36
|
try {
|
|
37
|
-
await runSync({ throws: true, quiet: true });
|
|
37
|
+
await runSync({ throws: true, quiet: true, surface: 'daemon' });
|
|
38
38
|
consecutiveAuthFailures = 0;
|
|
39
39
|
} catch (err) {
|
|
40
40
|
if (err.message === 'UNAUTHORIZED') {
|
|
@@ -1,243 +1,305 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createReadStream, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
2
3
|
import { join, basename, sep } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
4
|
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
|
+
import { getClaudeRoots } from '../claude-roots.js';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
* Stateless Claude Code parser.
|
|
8
|
-
* Reads ALL *.jsonl files under <root>/projects/ and extracts per-message
|
|
9
|
-
* token usage from assistant messages. No state file needed — every sync
|
|
10
|
-
* computes the full bucket totals from raw data, making server-side
|
|
11
|
-
* ON CONFLICT ... DO UPDATE SET idempotent.
|
|
12
|
-
*
|
|
13
|
-
* Roots: always ~/.claude, plus $CLAUDE_CONFIG_DIR when set to a different
|
|
14
|
-
* path. Claude Code itself relocates its whole tree (incl. projects/) to
|
|
15
|
-
* $CLAUDE_CONFIG_DIR and uses only that dir — but a GUI launched from the
|
|
16
|
-
* Dock may not inherit the shell's env, so usage can be split across both
|
|
17
|
-
* roots. We scan both and dedup so neither source is missed or double-counted.
|
|
18
|
-
*/
|
|
7
|
+
const MAX_WARNINGS = 20;
|
|
19
8
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*/
|
|
25
|
-
function getClaudeRoots() {
|
|
26
|
-
const roots = [join(homedir(), '.claude')];
|
|
27
|
-
|
|
28
|
-
const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
29
|
-
if (cfg) {
|
|
30
|
-
let custom = cfg;
|
|
31
|
-
if (custom.startsWith('~')) custom = join(homedir(), custom.slice(1));
|
|
32
|
-
custom = custom.replace(/[/\\]+$/, '') || custom;
|
|
33
|
-
roots.push(custom);
|
|
34
|
-
}
|
|
9
|
+
function addWarning(ctx, message) {
|
|
10
|
+
ctx.incomplete = true;
|
|
11
|
+
if (ctx.warnings.length < MAX_WARNINGS) ctx.warnings.push(message);
|
|
12
|
+
}
|
|
35
13
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// dir may not exist yet — fall back to the literal path
|
|
14
|
+
/** Recursively collect JSONL files without making one unreadable branch fatal. */
|
|
15
|
+
function findJsonlFiles(dir, ctx) {
|
|
16
|
+
let entries;
|
|
17
|
+
try {
|
|
18
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err?.code !== 'ENOENT') {
|
|
21
|
+
addWarning(ctx, `Claude Code: cannot read directory ${dir}: ${err.message}`);
|
|
45
22
|
}
|
|
46
|
-
|
|
47
|
-
seen.add(key);
|
|
48
|
-
unique.push(r);
|
|
23
|
+
return [];
|
|
49
24
|
}
|
|
50
|
-
return unique;
|
|
51
|
-
}
|
|
52
25
|
|
|
53
|
-
/**
|
|
54
|
-
* Recursively find all .jsonl files under a directory.
|
|
55
|
-
* Claude Code stores sessions in two layouts:
|
|
56
|
-
* 2-layer: projects/{projectPath}/{sessionId}.jsonl
|
|
57
|
-
* 3-layer: projects/{projectPath}/{sessionId}/subagents/agent-*.jsonl
|
|
58
|
-
*/
|
|
59
|
-
function findJsonlFiles(dir) {
|
|
60
26
|
const results = [];
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} else if (entry.name.endsWith('.jsonl')) {
|
|
68
|
-
results.push(fullPath);
|
|
69
|
-
}
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const fullPath = join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
results.push(...findJsonlFiles(fullPath, ctx));
|
|
31
|
+
} else if (entry.name.endsWith('.jsonl')) {
|
|
32
|
+
results.push(fullPath);
|
|
70
33
|
}
|
|
71
|
-
} catch {
|
|
72
|
-
// ignore unreadable directories
|
|
73
34
|
}
|
|
74
35
|
return results;
|
|
75
36
|
}
|
|
76
37
|
|
|
77
|
-
/**
|
|
78
|
-
* Path of a project file relative to its root's projects/ dir, e.g.
|
|
79
|
-
* "<root>/projects/-Users-foo-app/abc.jsonl" → "-Users-foo-app/abc.jsonl".
|
|
80
|
-
* Used both for project-name extraction and cross-root dedup.
|
|
81
|
-
*/
|
|
82
38
|
function projectRelativePath(filePath, projectsDir) {
|
|
83
39
|
const prefix = projectsDir + sep;
|
|
84
40
|
return filePath.startsWith(prefix) ? filePath.slice(prefix.length) : null;
|
|
85
41
|
}
|
|
86
42
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
* The first segment is the dash-encoded project path (e.g. -Users-foo-myproject);
|
|
90
|
-
* we take its last component as the project name.
|
|
91
|
-
*/
|
|
92
|
-
function extractProject(relative) {
|
|
43
|
+
/** Best-effort fallback for old records without cwd. */
|
|
44
|
+
function projectFromRelative(relative) {
|
|
93
45
|
if (!relative) return 'unknown';
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
96
|
-
const parts =
|
|
97
|
-
return parts.
|
|
46
|
+
const firstSegment = relative.split(sep)[0];
|
|
47
|
+
if (!firstSegment) return 'unknown';
|
|
48
|
+
const parts = firstSegment.split('-').filter(Boolean);
|
|
49
|
+
return parts.at(-1) || 'unknown';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Works for Unix and Windows cwd values regardless of the current OS. */
|
|
53
|
+
function projectFromCwd(cwd, fallback) {
|
|
54
|
+
if (typeof cwd !== 'string') return fallback;
|
|
55
|
+
const trimmed = cwd.trim().replace(/[\\/]+$/, '');
|
|
56
|
+
if (!trimmed) return fallback;
|
|
57
|
+
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) || fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toCount(value) {
|
|
61
|
+
const n = Number(value);
|
|
62
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cacheCreationTokens(usage) {
|
|
66
|
+
const direct = toCount(usage.cache_creation_input_tokens);
|
|
67
|
+
const breakdown = usage.cache_creation || {};
|
|
68
|
+
const split =
|
|
69
|
+
toCount(breakdown.ephemeral_5m_input_tokens) +
|
|
70
|
+
toCount(breakdown.ephemeral_1h_input_tokens);
|
|
71
|
+
// Current Claude logs carry both the total and its TTL breakdown. max()
|
|
72
|
+
// avoids double-counting while remaining tolerant of partially populated logs.
|
|
73
|
+
return Math.max(direct, split);
|
|
98
74
|
}
|
|
99
75
|
|
|
100
|
-
function
|
|
101
|
-
|
|
76
|
+
function candidateIsBetter(next, current) {
|
|
77
|
+
if (!current) return true;
|
|
78
|
+
if (next.size !== current.size) return next.size > current.size;
|
|
79
|
+
if (next.mtimeMs !== current.mtimeMs) return next.mtimeMs > current.mtimeMs;
|
|
80
|
+
return next.filePath.localeCompare(current.filePath) < 0;
|
|
102
81
|
}
|
|
103
82
|
|
|
104
83
|
/**
|
|
105
|
-
*
|
|
84
|
+
* Group physical files by logical session id and keep candidates ordered by
|
|
85
|
+
* completeness. A session copied between ~/.claude and CLAUDE_CONFIG_DIR must
|
|
86
|
+
* use its largest/newest copy, not whichever root happened to be scanned first.
|
|
106
87
|
*/
|
|
107
|
-
function
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
88
|
+
function collectCandidates(roots, directoryName, ctx) {
|
|
89
|
+
const groups = new Map();
|
|
90
|
+
for (const root of roots) {
|
|
91
|
+
const baseDir = join(root, directoryName);
|
|
92
|
+
for (const filePath of findJsonlFiles(baseDir, ctx)) {
|
|
93
|
+
let stat;
|
|
94
|
+
try {
|
|
95
|
+
stat = statSync(filePath);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
addWarning(ctx, `Claude Code: cannot stat ${filePath}: ${err.message}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const sessionId = basename(filePath, '.jsonl');
|
|
101
|
+
const relative = projectRelativePath(filePath, baseDir);
|
|
102
|
+
const candidate = {
|
|
103
|
+
filePath,
|
|
104
|
+
sessionId,
|
|
105
|
+
size: stat.size,
|
|
106
|
+
mtimeMs: stat.mtimeMs,
|
|
107
|
+
fallbackProject: directoryName === 'projects'
|
|
108
|
+
? projectFromRelative(relative)
|
|
109
|
+
: 'unknown',
|
|
110
|
+
};
|
|
111
|
+
const group = groups.get(sessionId) || [];
|
|
112
|
+
group.push(candidate);
|
|
113
|
+
groups.set(sessionId, group);
|
|
124
114
|
}
|
|
115
|
+
}
|
|
116
|
+
for (const group of groups.values()) {
|
|
117
|
+
group.sort((a, b) => candidateIsBetter(a, b) ? -1 : candidateIsBetter(b, a) ? 1 : 0);
|
|
118
|
+
}
|
|
119
|
+
return groups;
|
|
120
|
+
}
|
|
125
121
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
122
|
+
/** Read only the file size captured during discovery, so live appends wait. */
|
|
123
|
+
async function readJsonl(candidate, onObject) {
|
|
124
|
+
if (candidate.size === 0) return;
|
|
125
|
+
const stream = createReadStream(candidate.filePath, {
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
start: 0,
|
|
128
|
+
end: candidate.size - 1,
|
|
129
|
+
});
|
|
130
|
+
let streamError = null;
|
|
131
|
+
stream.on('error', (err) => { streamError = err; });
|
|
132
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
129
133
|
|
|
130
|
-
|
|
134
|
+
try {
|
|
135
|
+
for await (const line of lines) {
|
|
131
136
|
if (!line.trim()) continue;
|
|
132
137
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const timestamp = obj.timestamp;
|
|
136
|
-
if (!timestamp) continue;
|
|
137
|
-
const ts = new Date(timestamp);
|
|
138
|
-
if (isNaN(ts.getTime())) continue;
|
|
139
|
-
|
|
140
|
-
if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
|
|
141
|
-
ctx.sessionEvents.push({
|
|
142
|
-
sessionId,
|
|
143
|
-
source: 'claude-code',
|
|
144
|
-
project,
|
|
145
|
-
timestamp: ts,
|
|
146
|
-
role: obj.type === 'user' ? 'user' : 'assistant',
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (obj.type !== 'assistant') continue;
|
|
151
|
-
const msg = obj.message;
|
|
152
|
-
if (!msg || !msg.usage) continue;
|
|
153
|
-
|
|
154
|
-
const usage = msg.usage;
|
|
155
|
-
if (usage.input_tokens == null && usage.output_tokens == null) continue;
|
|
156
|
-
|
|
157
|
-
const uuid = obj.uuid;
|
|
158
|
-
if (uuid) {
|
|
159
|
-
if (ctx.seenUuids.has(uuid)) continue;
|
|
160
|
-
ctx.seenUuids.add(uuid);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
ctx.entries.push({
|
|
164
|
-
source: 'claude-code',
|
|
165
|
-
model: msg.model || 'unknown',
|
|
166
|
-
project,
|
|
167
|
-
timestamp: ts,
|
|
168
|
-
inputTokens: usage.input_tokens || 0,
|
|
169
|
-
outputTokens: usage.output_tokens || 0,
|
|
170
|
-
cachedInputTokens: usage.cache_read_input_tokens || 0,
|
|
171
|
-
reasoningOutputTokens: 0,
|
|
172
|
-
});
|
|
138
|
+
onObject(JSON.parse(line));
|
|
173
139
|
} catch {
|
|
174
|
-
|
|
140
|
+
// Claude may be appending the final JSONL record while we snapshot it.
|
|
141
|
+
// A later sync will see the complete line; malformed historical lines
|
|
142
|
+
// are isolated instead of taking the whole parser down.
|
|
175
143
|
}
|
|
176
144
|
}
|
|
145
|
+
if (streamError) throw streamError;
|
|
146
|
+
} finally {
|
|
147
|
+
lines.close();
|
|
148
|
+
stream.destroy();
|
|
177
149
|
}
|
|
178
150
|
}
|
|
179
151
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
152
|
+
function timingEvent(obj, sessionId, project) {
|
|
153
|
+
if (
|
|
154
|
+
obj.type !== 'user' &&
|
|
155
|
+
obj.type !== 'assistant' &&
|
|
156
|
+
obj.type !== 'tool_use' &&
|
|
157
|
+
obj.type !== 'tool_result'
|
|
158
|
+
) return null;
|
|
159
|
+
if (!obj.timestamp) return null;
|
|
160
|
+
const timestamp = new Date(obj.timestamp);
|
|
161
|
+
if (Number.isNaN(timestamp.getTime())) return null;
|
|
162
|
+
return {
|
|
163
|
+
sessionId,
|
|
164
|
+
source: 'claude-code',
|
|
165
|
+
project,
|
|
166
|
+
timestamp,
|
|
167
|
+
role: obj.type === 'user' ? 'user' : 'assistant',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
189
170
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
171
|
+
async function scanProjectCandidate(candidate) {
|
|
172
|
+
const entries = [];
|
|
173
|
+
const events = [];
|
|
174
|
+
let lastModel = null;
|
|
175
|
+
let sessionProject = candidate.fallbackProject;
|
|
176
|
+
let foundSessionCwd = false;
|
|
177
|
+
|
|
178
|
+
await readJsonl(candidate, (obj) => {
|
|
179
|
+
// cwd can change after Claude runs `cd`; project attribution should remain
|
|
180
|
+
// the directory where this session started, not fragment into subfolders.
|
|
181
|
+
if (!foundSessionCwd && typeof obj.cwd === 'string' && obj.cwd.trim()) {
|
|
182
|
+
sessionProject = projectFromCwd(obj.cwd, candidate.fallbackProject);
|
|
183
|
+
foundSessionCwd = true;
|
|
195
184
|
}
|
|
185
|
+
const event = timingEvent(obj, candidate.sessionId, sessionProject);
|
|
186
|
+
if (event) events.push(event);
|
|
196
187
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
188
|
+
if (obj.type !== 'assistant' || !obj.message?.usage || !obj.timestamp) return;
|
|
189
|
+
const timestamp = new Date(obj.timestamp);
|
|
190
|
+
if (Number.isNaN(timestamp.getTime())) return;
|
|
191
|
+
|
|
192
|
+
const usage = obj.message.usage;
|
|
193
|
+
const rawModel = typeof obj.message.model === 'string'
|
|
194
|
+
? obj.message.model.trim()
|
|
195
|
+
: '';
|
|
196
|
+
if (rawModel && rawModel !== '<synthetic>') lastModel = rawModel;
|
|
197
|
+
const model = rawModel && rawModel !== '<synthetic>'
|
|
198
|
+
? rawModel
|
|
199
|
+
: lastModel || 'claude-unknown';
|
|
200
|
+
const inputTokens = toCount(usage.input_tokens) + cacheCreationTokens(usage);
|
|
201
|
+
const outputTokens = toCount(usage.output_tokens);
|
|
202
|
+
const cachedInputTokens = toCount(usage.cache_read_input_tokens);
|
|
203
|
+
const usageScore = inputTokens + outputTokens + cachedInputTokens;
|
|
204
|
+
|
|
205
|
+
// Synthetic bookkeeping messages are common and carry zero usage. Do not
|
|
206
|
+
// inflate the CLI's bucket count with rows the server will discard anyway.
|
|
207
|
+
if (usageScore === 0) return;
|
|
208
|
+
|
|
209
|
+
entries.push({
|
|
210
|
+
uuid: typeof obj.uuid === 'string' && obj.uuid ? obj.uuid : null,
|
|
211
|
+
usageScore,
|
|
212
|
+
source: 'claude-code',
|
|
213
|
+
model,
|
|
214
|
+
project: sessionProject,
|
|
215
|
+
timestamp,
|
|
216
|
+
inputTokens,
|
|
217
|
+
outputTokens,
|
|
218
|
+
cachedInputTokens,
|
|
219
|
+
reasoningOutputTokens: 0,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// A cwd can appear after initial metadata/messages. Normalize the completed
|
|
224
|
+
// session in one place so early records receive the same project label.
|
|
225
|
+
for (const entry of entries) entry.project = sessionProject;
|
|
226
|
+
for (const event of events) event.project = sessionProject;
|
|
227
|
+
return { entries, events };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function scanTranscriptCandidate(candidate) {
|
|
231
|
+
const events = [];
|
|
232
|
+
await readJsonl(candidate, (obj) => {
|
|
233
|
+
const event = timingEvent(
|
|
234
|
+
obj,
|
|
235
|
+
candidate.sessionId,
|
|
236
|
+
projectFromCwd(obj.cwd, 'unknown'),
|
|
237
|
+
);
|
|
238
|
+
if (event) events.push(event);
|
|
239
|
+
});
|
|
240
|
+
return { entries: [], events };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function scanBestCandidate(candidates, scanner, ctx) {
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
try {
|
|
246
|
+
return await scanner(candidate);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
addWarning(ctx, `Claude Code: cannot read ${candidate.filePath}: ${err.message}`);
|
|
219
249
|
}
|
|
220
250
|
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function mergeUsageEntry(ctx, entry) {
|
|
255
|
+
if (!entry.uuid) {
|
|
256
|
+
ctx.anonymousEntries.push(entry);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const current = ctx.entriesByUuid.get(entry.uuid);
|
|
260
|
+
// Claude sometimes copies the same UUID into another session with zeroed
|
|
261
|
+
// usage. Keep the most complete payload, independent of directory order.
|
|
262
|
+
if (!current || entry.usageScore > current.usageScore) {
|
|
263
|
+
ctx.entriesByUuid.set(entry.uuid, entry);
|
|
264
|
+
}
|
|
221
265
|
}
|
|
222
266
|
|
|
223
267
|
export async function parse() {
|
|
224
268
|
const ctx = {
|
|
225
|
-
|
|
269
|
+
entriesByUuid: new Map(),
|
|
270
|
+
anonymousEntries: [],
|
|
226
271
|
sessionEvents: [],
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
seenProjectFiles: new Set(), // projects-relative path → dedup same session across roots
|
|
272
|
+
warnings: [],
|
|
273
|
+
incomplete: false,
|
|
230
274
|
};
|
|
231
|
-
|
|
232
275
|
const roots = getClaudeRoots();
|
|
276
|
+
const projectGroups = collectCandidates(roots, 'projects', ctx);
|
|
277
|
+
const projectSessionIds = new Set();
|
|
278
|
+
|
|
279
|
+
for (const [sessionId, candidates] of projectGroups) {
|
|
280
|
+
const parsed = await scanBestCandidate(candidates, scanProjectCandidate, ctx);
|
|
281
|
+
if (!parsed) continue;
|
|
282
|
+
projectSessionIds.add(sessionId);
|
|
283
|
+
ctx.sessionEvents.push(...parsed.events);
|
|
284
|
+
for (const entry of parsed.entries) mergeUsageEntry(ctx, entry);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const transcriptGroups = collectCandidates(roots, 'transcripts', ctx);
|
|
288
|
+
for (const [sessionId, candidates] of transcriptGroups) {
|
|
289
|
+
if (projectSessionIds.has(sessionId)) continue;
|
|
290
|
+
const parsed = await scanBestCandidate(candidates, scanTranscriptCandidate, ctx);
|
|
291
|
+
if (parsed) ctx.sessionEvents.push(...parsed.events);
|
|
292
|
+
}
|
|
233
293
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
294
|
+
const entries = [
|
|
295
|
+
...ctx.anonymousEntries,
|
|
296
|
+
...ctx.entriesByUuid.values(),
|
|
297
|
+
].map(({ uuid: _uuid, usageScore: _usageScore, ...entry }) => entry);
|
|
238
298
|
|
|
239
299
|
return {
|
|
240
|
-
buckets: aggregateToBuckets(
|
|
300
|
+
buckets: aggregateToBuckets(entries),
|
|
241
301
|
sessions: extractSessions(ctx.sessionEvents),
|
|
302
|
+
...(ctx.incomplete ? { skipped: true } : {}),
|
|
303
|
+
...(ctx.warnings.length > 0 ? { warnings: ctx.warnings } : {}),
|
|
242
304
|
};
|
|
243
305
|
}
|
package/src/sync.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
bucketKey, bucketHash, sessionKey, sessionHash,
|
|
6
6
|
} from './state.js';
|
|
7
7
|
import { ingest, fetchSettings } from './api.js';
|
|
8
|
+
import { createSyncClient, forBatch } from './client-meta.js';
|
|
8
9
|
import { parsers } from './parsers/index.js';
|
|
9
10
|
import { success, failure, arrow, link, dim } from './output.js';
|
|
10
11
|
|
|
@@ -17,7 +18,7 @@ function formatBytes(bytes) {
|
|
|
17
18
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export async function runSync({ throws = false, quiet = false } = {}) {
|
|
21
|
+
export async function runSync({ throws = false, quiet = false, surface = 'cli' } = {}) {
|
|
21
22
|
const config = loadConfig();
|
|
22
23
|
if (!config?.apiKey) {
|
|
23
24
|
console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
|
|
@@ -51,6 +52,11 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
51
52
|
if (result?.indexing) {
|
|
52
53
|
parserProgress.push({ source, ...result.indexing });
|
|
53
54
|
}
|
|
55
|
+
if (Array.isArray(result?.warnings)) {
|
|
56
|
+
for (const message of result.warnings) {
|
|
57
|
+
process.stderr.write(`${dim(` ${message}`)}\n`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
54
60
|
// A parser may deliberately suppress a transient error (Cursor network
|
|
55
61
|
// timeout) to keep daemon logs quiet. Its empty result is not proof that
|
|
56
62
|
// its prior data disappeared, so it must not be pruned this run.
|
|
@@ -185,10 +191,14 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
185
191
|
let totalIngested = 0;
|
|
186
192
|
let totalSessionsSynced = 0;
|
|
187
193
|
let totalDroppedBuckets = 0;
|
|
194
|
+
let totalDroppedUnknownModels = 0;
|
|
195
|
+
let totalDroppedImplausible = 0;
|
|
196
|
+
let totalProtectedBuckets = 0;
|
|
188
197
|
const droppedSources = new Set();
|
|
189
198
|
const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
|
|
190
199
|
const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
|
|
191
200
|
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
201
|
+
const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
|
|
192
202
|
|
|
193
203
|
try {
|
|
194
204
|
for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
|
|
@@ -198,6 +208,7 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
198
208
|
const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
|
|
199
209
|
|
|
200
210
|
const result = await ingest(apiUrl, config.apiKey, batch, {
|
|
211
|
+
client: forBatch(syncClient, batchIdx, totalBatches),
|
|
201
212
|
onProgress(sent, total) {
|
|
202
213
|
const pct = Math.round((sent / total) * 100);
|
|
203
214
|
process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)`)}\x1b[K`);
|
|
@@ -205,16 +216,24 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
205
216
|
}, batchSessions.length > 0 ? batchSessions : undefined);
|
|
206
217
|
totalIngested += result.ingested ?? batch.length;
|
|
207
218
|
totalSessionsSynced += result.sessions ?? 0;
|
|
219
|
+
const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
|
|
208
220
|
if (result.dropped) {
|
|
209
221
|
totalDroppedBuckets += Number(result.dropped.buckets) || 0;
|
|
222
|
+
totalDroppedUnknownModels += Number(result.dropped.unknownModels) || 0;
|
|
223
|
+
totalDroppedImplausible += Number(result.dropped.implausible) || 0;
|
|
210
224
|
for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
|
|
211
225
|
}
|
|
226
|
+
totalProtectedBuckets += Number(result.protected?.buckets) || 0;
|
|
212
227
|
|
|
213
228
|
// Commit only this batch's hashes, only after it uploaded successfully.
|
|
214
229
|
// A batch that throws aborts the loop with its keys still absent from
|
|
215
230
|
// state, so the next sync re-sends exactly those items — no data loss,
|
|
216
231
|
// no silent gaps.
|
|
217
232
|
for (const b of batch) {
|
|
233
|
+
// A source unknown to an older backend may become valid after deploy.
|
|
234
|
+
// Leave those hashes uncommitted so the next sync retries them instead
|
|
235
|
+
// of turning a temporary release-order mismatch into permanent loss.
|
|
236
|
+
if (batchUnknownSources.has(b.source)) continue;
|
|
218
237
|
const key = bucketKey(b);
|
|
219
238
|
const entry = pendingBucketState.get(key);
|
|
220
239
|
if (entry) state.buckets[key] = entry;
|
|
@@ -235,11 +254,18 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
235
254
|
console.log(success(`已同步 ${syncParts.join(' · ')}`));
|
|
236
255
|
|
|
237
256
|
if (totalDroppedBuckets > 0) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
257
|
+
const reasons = [];
|
|
258
|
+
if (droppedSources.size > 0) {
|
|
259
|
+
reasons.push(`服务端未收录的 source: ${Array.from(droppedSources).sort().join(', ')}`);
|
|
260
|
+
}
|
|
261
|
+
if (totalDroppedUnknownModels > 0) reasons.push(`模型未知: ${totalDroppedUnknownModels}`);
|
|
262
|
+
if (totalDroppedImplausible > 0) reasons.push(`超出合理范围: ${totalDroppedImplausible}`);
|
|
263
|
+
if (reasons.length === 0) reasons.push('服务端拒绝');
|
|
264
|
+
console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (totalProtectedBuckets > 0) {
|
|
268
|
+
console.log(dim(` 服务端保留了 ${totalProtectedBuckets} 个更大的已有 bucket(本次较小快照未覆盖)`));
|
|
243
269
|
}
|
|
244
270
|
|
|
245
271
|
if (!quiet && totalSessionsSynced > 0) {
|
package/src/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
+
import { findClaudeCodeDataDirs } from './claude-roots.js';
|
|
4
5
|
|
|
5
6
|
function getCursorStateDbPath() {
|
|
6
7
|
const rel = join('User', 'globalStorage', 'state.vscdb');
|
|
@@ -80,20 +81,6 @@ function findOpenclawDataDirs() {
|
|
|
80
81
|
return dirs;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
// Claude Code lives in ~/.claude/projects, but $CLAUDE_CONFIG_DIR relocates its
|
|
84
|
-
// whole tree. Detect either so a user who only set CLAUDE_CONFIG_DIR is still
|
|
85
|
-
// recognized (the parser scans both roots; see parsers/claude-code.js).
|
|
86
|
-
function findClaudeCodeDataDirs() {
|
|
87
|
-
const dirs = [join(homedir(), '.claude', 'projects')];
|
|
88
|
-
const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
89
|
-
if (cfg) {
|
|
90
|
-
let custom = cfg.startsWith('~') ? join(homedir(), cfg.slice(1)) : cfg;
|
|
91
|
-
custom = custom.replace(/[/\\]+$/, '') || custom;
|
|
92
|
-
dirs.push(join(custom, 'projects'));
|
|
93
|
-
}
|
|
94
|
-
return dirs.filter(existsSync);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
84
|
// Codex keeps live sessions in ~/.codex/sessions and moves completed ones to
|
|
98
85
|
// ~/.codex/archived_sessions. Detect Codex if either dir exists, so a user
|
|
99
86
|
// whose sessions have all been archived is still recognized.
|