@vibe-cafe/vibe-usage 0.10.4 → 0.10.6

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 CHANGED
@@ -31,6 +31,7 @@ npx @vibe-cafe/vibe-usage # Init (first run, browser login) or sync
31
31
  npx @vibe-cafe/vibe-usage init # Re-run setup via browser login
32
32
  npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
33
33
  npx @vibe-cafe/vibe-usage sync # Manual sync
34
+ npx @vibe-cafe/vibe-usage sync --extra-codex-home /path/to/.codex # Add another Codex Home for this run only
34
35
  npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
35
36
  npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
36
37
  npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
@@ -50,17 +51,19 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
50
51
 
51
52
  | Tool | Data Location |
52
53
  |------|---------------|
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
- | 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 |
54
+ | Claude Code + Claude Desktop Code/Cowork | Claude Code data in `~/.claude/projects/` (tokens + sessions) and `~/.claude/transcripts/` (sessions only), plus Claude Desktop Cowork's per-session `.claude/projects/` directories. Also scans `$CLAUDE_CONFIG_DIR` and data-bearing `~/.claude-*` profiles. All variants use the existing `claude-code` source; the parser selects the most complete copy of each session so shared/copied transcripts are not counted twice. Logs are streamed and cache creation tokens are included in input usage. |
55
+ | Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home` or manually persisted `codexExtraHome`; 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/cross-root deduplication retain their existing semantics |
55
56
  | 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
57
  | GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
57
58
  | Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
59
+ | DimAgent | `$DIMCODE_HOME/dimcode.sqlite` (default `~/.dimcode/v2/dimcode.sqlite`); exact usage from `usage_ledger`, with forked ledger/history copies deduplicated |
58
60
  | Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
59
61
  | OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
60
62
  | OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments) |
61
63
  | pi | `~/.pi/agent/sessions/` |
62
64
  | Qwen Code | `~/.qwen/tmp/` |
63
65
  | Kimi Code | Current `~/.kimi-code/sessions/wd_<slug>_<hash>/session_<id>/agents/<agent>/wire.jsonl` (`usage.record` deltas, including retry/compaction scope and cache creation; main/subagent wires form one session), data root resolved via `$KIMI_CODE_HOME` like the CLI itself, with project names from `session_index.jsonl`; legacy `~/.kimi/sessions/` is parsed alongside (`kimi migrate` never carries usage over, so both stores are always merged) |
66
+ | MiMoCode | `$MIMOCODE_HOME/data/mimocode.db`, `$XDG_DATA_HOME/mimocode/mimocode.db`, or `~/.local/share/mimocode/mimocode.db` (SQLite; exact input, output, reasoning, and cache-read tokens from assistant messages; honors `MIMOCODE_DB`; cache-write tokens are included in input usage) |
64
67
  | Amp | `~/.local/share/amp/threads/` |
65
68
  | Droid | `~/.factory/sessions/` |
66
69
  | Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
@@ -147,6 +150,7 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`).
147
150
  | `apiKey` | Your API key (starts with `vbu_`) |
148
151
  | `apiUrl` | Server URL (default: `https://vibecafe.ai`) |
149
152
  | `hostname` | Stable device name for usage tracking (set at init, reused across syncs) |
153
+ | `codexExtraHome` | Optional additional Codex Home scanned together with `$CODEX_HOME` / `~/.codex` |
150
154
 
151
155
  The `hostname` is captured once during `init` and reused for all future syncs. This prevents macOS mDNS hostname changes (e.g., `MacBook-Pro` → `MacBook-Pro-2`) from creating duplicate device entries. To change it manually:
152
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -2,6 +2,9 @@ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
2
  import { delimiter, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
+ const MAX_DESKTOP_DISCOVERY_DEPTH = 8;
6
+ const DESKTOP_NON_SESSION_DIRS = new Set(['rpm', 'skills']);
7
+
5
8
  function expandHome(value) {
6
9
  const trimmed = value.trim().replace(/[/\\]+$/, '');
7
10
  if (trimmed === '~') return homedir();
@@ -15,18 +18,95 @@ function hasClaudeData(root) {
15
18
  return existsSync(join(root, 'projects')) || existsSync(join(root, 'transcripts'));
16
19
  }
17
20
 
21
+ function defaultClaudeDesktopDataDir() {
22
+ if (process.platform === 'darwin') {
23
+ return join(homedir(), 'Library', 'Application Support', 'Claude');
24
+ }
25
+ if (process.platform === 'win32') {
26
+ const appData = process.env.APPDATA?.trim();
27
+ return appData
28
+ ? join(expandHome(appData), 'Claude')
29
+ : join(homedir(), 'AppData', 'Roaming', 'Claude');
30
+ }
31
+ const configHome = process.env.XDG_CONFIG_HOME?.trim();
32
+ return join(configHome ? expandHome(configHome) : join(homedir(), '.config'), 'Claude');
33
+ }
34
+
35
+ function getClaudeDesktopDataDirs() {
36
+ const override = process.env.VIBE_USAGE_CLAUDE_DESKTOP_DIRS?.trim();
37
+ return override
38
+ ? override.split(delimiter).map(expandHome).filter(Boolean)
39
+ : [defaultClaudeDesktopDataDir()];
40
+ }
41
+
42
+ function discoverDesktopRoots(dir, depth, roots, onWarning) {
43
+ let entries;
44
+ try {
45
+ entries = readdirSync(dir, { withFileTypes: true });
46
+ } catch (err) {
47
+ if (err?.code !== 'ENOENT') {
48
+ onWarning(`Claude Desktop: cannot read directory ${dir}: ${err.message}`);
49
+ }
50
+ return;
51
+ }
52
+
53
+ // Once a session root is found, do not descend into the user's Cowork files.
54
+ // Those directories can be large and are unrelated to Claude's transcript.
55
+ const claudeEntry = entries.find(
56
+ (entry) => entry.name === '.claude' && entry.isDirectory(),
57
+ );
58
+ if (claudeEntry) {
59
+ roots.push(join(dir, claudeEntry.name));
60
+ return;
61
+ }
62
+
63
+ for (const entry of entries) {
64
+ if (!entry.isDirectory()) continue;
65
+ if (DESKTOP_NON_SESSION_DIRS.has(entry.name)) continue;
66
+ const candidate = join(dir, entry.name);
67
+ if (depth < MAX_DESKTOP_DISCOVERY_DEPTH) {
68
+ discoverDesktopRoots(candidate, depth + 1, roots, onWarning);
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Find the private Claude Code state roots created for Claude Desktop Cowork.
75
+ * Desktop Code itself uses the normal ~/.claude root, while Cowork isolates
76
+ * each local-agent session below the Electron user-data directory.
77
+ */
78
+ export function findClaudeDesktopRoots(
79
+ desktopDataDirs = getClaudeDesktopDataDirs(),
80
+ onWarning = () => {},
81
+ ) {
82
+ const roots = [];
83
+ for (const dataDir of desktopDataDirs) {
84
+ discoverDesktopRoots(
85
+ join(dataDir, 'local-agent-mode-sessions'),
86
+ 0,
87
+ roots,
88
+ onWarning,
89
+ );
90
+ }
91
+ return roots;
92
+ }
93
+
18
94
  /**
19
- * Return every Claude Code state root visible from this process.
95
+ * Return every Claude Code-compatible state root visible from this process.
20
96
  *
21
97
  * In addition to the default and CLAUDE_CONFIG_DIR, discover the documented
22
98
  * multi-profile convention (~/.claude-work, ~/.claude-personal, ...). This is
23
99
  * important for launchd/systemd and GUI processes, which commonly do not
24
100
  * inherit the shell environment used to launch Claude Code.
25
101
  *
26
- * VIBE_USAGE_CLAUDE_DIRS is a test/diagnostic override. It replaces discovery
27
- * with a path.delimiter-separated root list.
102
+ * Claude Desktop Code uses the default Claude Code root. Cowork creates a
103
+ * private .claude root per local-agent session, so those roots are discovered
104
+ * recursively under the app's user-data directory.
105
+ *
106
+ * VIBE_USAGE_CLAUDE_DIRS is a test/diagnostic override. It replaces all normal
107
+ * and Desktop discovery with a path.delimiter-separated root list.
28
108
  */
29
- export function getClaudeRoots() {
109
+ export function getClaudeRoots({ onWarning = () => {} } = {}) {
30
110
  const override = process.env.VIBE_USAGE_CLAUDE_DIRS?.trim();
31
111
  const roots = override
32
112
  ? override.split(delimiter).map(expandHome).filter(Boolean)
@@ -47,6 +127,8 @@ export function getClaudeRoots() {
47
127
  } catch {
48
128
  // The default/configured roots remain usable if home discovery fails.
49
129
  }
130
+
131
+ roots.push(...findClaudeDesktopRoots(getClaudeDesktopDataDirs(), onWarning));
50
132
  }
51
133
 
52
134
  const seen = new Set();
@@ -0,0 +1,46 @@
1
+ import { statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ function normalizeHomePath(value) {
6
+ const trimmed = value.trim();
7
+ if (trimmed === '~') return homedir();
8
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
9
+ return resolve(homedir(), trimmed.slice(2));
10
+ }
11
+ return resolve(trimmed);
12
+ }
13
+
14
+ export function primaryCodexHome() {
15
+ const configured = process.env.CODEX_HOME?.trim();
16
+ return configured ? normalizeHomePath(configured) : join(homedir(), '.codex');
17
+ }
18
+
19
+ export function resolveCodexHomes(extraCodexHome) {
20
+ const roots = [primaryCodexHome()];
21
+ if (extraCodexHome?.trim()) roots.push(normalizeHomePath(extraCodexHome));
22
+ return [...new Set(roots)];
23
+ }
24
+
25
+ export function codexSessionDirs(codexHome) {
26
+ return [
27
+ join(codexHome, 'sessions'),
28
+ join(codexHome, 'archived_sessions'),
29
+ ];
30
+ }
31
+
32
+ function isDirectory(path) {
33
+ try {
34
+ return statSync(path).isDirectory();
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export function validateExtraCodexHome(value) {
41
+ const path = normalizeHomePath(value);
42
+ return {
43
+ ok: isDirectory(path) && codexSessionDirs(path).some(isDirectory),
44
+ path,
45
+ };
46
+ }
@@ -56,10 +56,25 @@ function escapeXml(value) {
56
56
  .replace(/'/g, '&apos;');
57
57
  }
58
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
- : '';
59
+ const PRESERVED_SERVICE_ENV = ['MIMOCODE_HOME', 'MIMOCODE_DB', 'XDG_DATA_HOME'];
60
+
61
+ function serviceEnvironment(claudeConfigDir, env) {
62
+ const values = {
63
+ CLAUDE_CONFIG_DIR: claudeConfigDir,
64
+ ...Object.fromEntries(PRESERVED_SERVICE_ENV.map(key => [key, env[key]?.trim()])),
65
+ };
66
+ return Object.entries(values).filter(([, value]) => value);
67
+ }
68
+
69
+ export function generateSystemdUnit(
70
+ nodePath,
71
+ binPath,
72
+ claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim(),
73
+ env = process.env,
74
+ ) {
75
+ const environment = serviceEnvironment(claudeConfigDir, env)
76
+ .map(([key, value]) => `Environment="${key}=${escapeSystemdEnvironment(value)}"\n`)
77
+ .join('');
63
78
  return `[Unit]
64
79
  Description=VibeCafe Usage Tracker
65
80
  After=network.target
@@ -70,18 +85,23 @@ ExecStart=${nodePath} ${binPath} daemon
70
85
  Restart=on-failure
71
86
  RestartSec=10
72
87
  Environment=NODE_ENV=production
73
- ${claudeEnvironment}WorkingDirectory=${homedir()}
88
+ ${environment}WorkingDirectory=${homedir()}
74
89
 
75
90
  [Install]
76
91
  WantedBy=default.target
77
92
  `;
78
93
  }
79
94
 
80
- export function generateLaunchdPlist(nodePath, binPath, claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim()) {
95
+ export function generateLaunchdPlist(
96
+ nodePath,
97
+ binPath,
98
+ claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim(),
99
+ env = process.env,
100
+ ) {
81
101
  const logDir = join(homedir(), '.vibe-usage');
82
- const claudeEnvironment = claudeConfigDir
83
- ? ` <key>CLAUDE_CONFIG_DIR</key>\n <string>${escapeXml(claudeConfigDir)}</string>\n`
84
- : '';
102
+ const environment = serviceEnvironment(claudeConfigDir, env)
103
+ .map(([key, value]) => ` <key>${key}</key>\n <string>${escapeXml(value)}</string>\n`)
104
+ .join('');
85
105
  return `<?xml version="1.0" encoding="UTF-8"?>
86
106
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
87
107
  <plist version="1.0">
@@ -108,7 +128,7 @@ export function generateLaunchdPlist(nodePath, binPath, claudeConfigDir = proces
108
128
  <dict>
109
129
  <key>NODE_ENV</key>
110
130
  <string>production</string>
111
- ${claudeEnvironment} </dict>
131
+ ${environment} </dict>
112
132
  </dict>
113
133
  </plist>
114
134
  `;
package/src/daemon.js CHANGED
@@ -13,7 +13,7 @@ function sleep(ms) {
13
13
  return new Promise(resolve => setTimeout(resolve, ms));
14
14
  }
15
15
 
16
- export async function runDaemon() {
16
+ export async function runDaemon({ codexExtraHome } = {}) {
17
17
  const config = loadConfig();
18
18
  if (!config?.apiKey) {
19
19
  console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
@@ -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, surface: 'daemon' });
37
+ await runSync({ throws: true, quiet: true, surface: 'daemon', codexExtraHome });
38
38
  consecutiveAuthFailures = 0;
39
39
  } catch (err) {
40
40
  if (err.message === 'UNAUTHORIZED') {
package/src/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { loadConfig, saveConfig, getConfigPath } from './config.js';
2
2
  import { detectInstalledTools, TOOLS } from './tools.js';
3
3
  import { existsSync } from 'node:fs';
4
- import { smallHeader } from './output.js';
4
+ import { validateExtraCodexHome } from './codex-roots.js';
5
+ import { failure, smallHeader } from './output.js';
5
6
 
6
7
  function printSmallHeader() {
7
8
  console.log();
@@ -20,10 +21,14 @@ async function showStatus() {
20
21
  console.log(` Config: ${getConfigPath()}`);
21
22
  console.log(` API key: ${config.apiKey.slice(0, 8)}...`);
22
23
  console.log(` API URL: ${config.apiUrl || 'https://vibecafe.ai'}`);
24
+ if (config.codexExtraHome) {
25
+ console.log(` Extra Codex Home: ${config.codexExtraHome}`);
26
+ }
23
27
  }
24
28
 
25
29
  console.log('\n Detected tools:');
26
- const detected = detectInstalledTools();
30
+ const toolOptions = { codexExtraHome: config?.codexExtraHome };
31
+ const detected = detectInstalledTools(toolOptions);
27
32
  if (detected.length === 0) {
28
33
  console.log(' (none)\n');
29
34
  } else {
@@ -35,13 +40,15 @@ async function showStatus() {
35
40
 
36
41
  console.log(' All supported tools:');
37
42
  for (const tool of TOOLS) {
38
- const installed = existsSync(tool.dataDir) ? 'installed' : 'not found';
43
+ const installed = (tool.detectDataDirs
44
+ ? tool.detectDataDirs(toolOptions).length > 0
45
+ : existsSync(tool.dataDir)) ? 'installed' : 'not found';
39
46
  console.log(` ${tool.name}: ${installed}`);
40
47
  }
41
48
  console.log();
42
49
  }
43
50
 
44
- const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname'];
51
+ const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname', 'codexExtraHome'];
45
52
 
46
53
  function handleConfig(args) {
47
54
  const sub = args[0];
@@ -64,7 +71,7 @@ function handleConfig(args) {
64
71
  }
65
72
  case 'set': {
66
73
  const key = args[1];
67
- const value = args[2];
74
+ let value = args[2];
68
75
  if (!key || value === undefined) {
69
76
  console.error('Usage: vibe-usage config set <key> <value>');
70
77
  process.exit(1);
@@ -74,6 +81,14 @@ function handleConfig(args) {
74
81
  console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`);
75
82
  process.exit(1);
76
83
  }
84
+ if (key === 'codexExtraHome' && value !== '') {
85
+ const validation = validateExtraCodexHome(value);
86
+ if (!validation.ok) {
87
+ console.error(failure(`额外 Codex Home 无效,需要包含 sessions/ 或 archived_sessions/: ${validation.path}`));
88
+ process.exit(1);
89
+ }
90
+ value = validation.path;
91
+ }
77
92
  const config = loadConfig() || {};
78
93
  config[key] = value;
79
94
  saveConfig(config);
@@ -117,19 +132,30 @@ export async function run(rawArgs) {
117
132
  if (apiKey === undefined) {
118
133
  ({ args: stripped, value: apiKey } = extractOption(stripped, 'key'));
119
134
  }
135
+ let codexExtraHome;
136
+ ({ args: stripped, value: codexExtraHome } = extractOption(stripped, 'extra-codex-home'));
137
+ if (codexExtraHome !== undefined) {
138
+ const validation = validateExtraCodexHome(codexExtraHome);
139
+ if (!validation.ok) {
140
+ console.error(failure(`额外 Codex Home 无效,需要包含 sessions/ 或 archived_sessions/: ${validation.path}`));
141
+ process.exit(1);
142
+ }
143
+ codexExtraHome = validation.path;
144
+ }
145
+
120
146
  const args = stripped;
121
147
  const command = args[0];
122
148
 
123
149
  switch (command) {
124
150
  case 'init': {
125
151
  const { runInit } = await import('./init.js');
126
- await runInit({ apiKey });
152
+ await runInit({ apiKey, codexExtraHome });
127
153
  break;
128
154
  }
129
155
  case 'sync': {
130
156
  printSmallHeader();
131
157
  const { runSync } = await import('./sync.js');
132
- await runSync();
158
+ await runSync({ codexExtraHome });
133
159
  break;
134
160
  }
135
161
  case 'summary': {
@@ -149,8 +175,12 @@ export async function run(rawArgs) {
149
175
  if (sub === undefined) {
150
176
  // Foreground daemon loop — no header, just start syncing
151
177
  const { runDaemon } = await import('./daemon.js');
152
- await runDaemon();
178
+ await runDaemon({ codexExtraHome });
153
179
  } else {
180
+ if (codexExtraHome !== undefined) {
181
+ console.error(failure('后台 daemon 不接受临时 Codex Home,请先运行 `config set codexExtraHome <path>`。'));
182
+ process.exit(1);
183
+ }
154
184
  // manageDaemon validates the subcommand and exits 1 on unknown ones —
155
185
  // a typo (e.g. `daemon stauts`) must never fall through to the
156
186
  // infinite foreground loop.
@@ -185,6 +215,7 @@ export async function run(rawArgs) {
185
215
  npx @vibe-cafe/vibe-usage init Set up via browser login (default)
186
216
  npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> Skip browser, use a pre-issued key (CI/headless)
187
217
  npx @vibe-cafe/vibe-usage sync Manually sync usage data
218
+ npx @vibe-cafe/vibe-usage sync --extra-codex-home <path> Use another Codex Home for this run
188
219
  npx @vibe-cafe/vibe-usage summary Print last 7 days as markdown (cost/tokens/model/project)
189
220
  npx @vibe-cafe/vibe-usage summary --days N Same, but over the last N days (1-90)
190
221
  npx @vibe-cafe/vibe-usage daemon Continuous sync (every 30m, foreground)
@@ -201,6 +232,7 @@ export async function run(rawArgs) {
201
232
  npx @vibe-cafe/vibe-usage config show Show full config as JSON
202
233
  npx @vibe-cafe/vibe-usage config get <key> Get a config value
203
234
  npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
235
+ npx @vibe-cafe/vibe-usage config set codexExtraHome <path> Persist another Codex Home
204
236
  npx @vibe-cafe/vibe-usage help Show this help
205
237
  `);
206
238
  break;
@@ -212,12 +244,12 @@ export async function run(rawArgs) {
212
244
  if (!config?.apiKey || apiKey) {
213
245
  // First run OR user passed --key for a one-shot setup — init.js prints the big header
214
246
  const { runInit } = await import('./init.js');
215
- await runInit({ apiKey });
247
+ await runInit({ apiKey, codexExtraHome });
216
248
  } else {
217
249
  // Already configured: small header + sync
218
250
  printSmallHeader();
219
251
  const { runSync } = await import('./sync.js');
220
- await runSync();
252
+ await runSync({ codexExtraHome });
221
253
  }
222
254
  break;
223
255
  }
package/src/init.js CHANGED
@@ -31,7 +31,7 @@ function isDaemonPlatform() {
31
31
  }
32
32
 
33
33
  export async function runInit(options = {}) {
34
- const { apiKey: providedKey } = options;
34
+ const { apiKey: providedKey, codexExtraHome } = options;
35
35
 
36
36
  console.log(bigHeader());
37
37
 
@@ -40,7 +40,7 @@ export async function runInit(options = {}) {
40
40
  if (providedKey && existing.apiKey === providedKey) {
41
41
  console.log(dim('已配置同一个 Key,直接同步数据。'));
42
42
  console.log();
43
- await runSync();
43
+ await runSync({ codexExtraHome });
44
44
  return;
45
45
  }
46
46
  const answer = await prompt('检测到已有配置,是否覆盖? (y/N) ');
@@ -80,10 +80,11 @@ export async function runInit(options = {}) {
80
80
  apiKey,
81
81
  apiUrl,
82
82
  hostname: host,
83
+ ...(existing?.codexExtraHome ? { codexExtraHome: existing.codexExtraHome } : {}),
83
84
  };
84
85
  saveConfig(config);
85
86
 
86
- const tools = detectInstalledTools();
87
+ const tools = detectInstalledTools({ codexExtraHome: config.codexExtraHome });
87
88
  if (tools.length > 0) {
88
89
  console.log(success(`检测到 ${tools.length} 款工具: ${dim(tools.map(t => t.name).join(' · '))}`));
89
90
  } else {
@@ -94,7 +95,7 @@ export async function runInit(options = {}) {
94
95
  console.log(divider());
95
96
  console.log();
96
97
 
97
- await runSync();
98
+ await runSync({ codexExtraHome });
98
99
 
99
100
  if (isDaemonPlatform()) {
100
101
  if (process.stdin.isTTY) {
@@ -272,7 +272,9 @@ export async function parse() {
272
272
  warnings: [],
273
273
  incomplete: false,
274
274
  };
275
- const roots = getClaudeRoots();
275
+ const roots = getClaudeRoots({
276
+ onWarning: (message) => addWarning(ctx, message),
277
+ });
276
278
  const projectGroups = collectCandidates(roots, 'projects', ctx);
277
279
  const projectSessionIds = new Set();
278
280
 
@@ -8,10 +8,14 @@ import {
8
8
  statSync,
9
9
  } from 'node:fs';
10
10
  import { join } from 'node:path';
11
- import { homedir } from 'node:os';
12
11
  import { createInterface } from 'node:readline';
13
12
  import { createHash } from 'node:crypto';
14
13
  import { aggregateToBuckets } from './index.js';
14
+ import {
15
+ codexSessionDirs,
16
+ resolveCodexHomes,
17
+ validateExtraCodexHome,
18
+ } from '../codex-roots.js';
15
19
  import {
16
20
  codexCacheEnabled,
17
21
  fileSignature,
@@ -28,17 +32,6 @@ import {
28
32
  // both, index them together so fork replay-skip works across directories, and
29
33
  // select the most complete physical file when the same session briefly exists
30
34
  // in both locations during an archive move.
31
- function getCodexHome() {
32
- return process.env.CODEX_HOME?.trim() || join(homedir(), '.codex');
33
- }
34
-
35
- function sessionsDirs(codexHome) {
36
- return [
37
- join(codexHome, 'sessions'),
38
- join(codexHome, 'archived_sessions'),
39
- ];
40
- }
41
-
42
35
  /**
43
36
  * Recursively find all .jsonl files under a directory.
44
37
  * Codex CLI stores sessions as: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
@@ -456,10 +449,10 @@ function boundaryKey(boundary) {
456
449
  return `${boundary.rawTokenCount}:${boundary.recordIndex ?? ''}`;
457
450
  }
458
451
 
459
- function updateFileCache(codexHome, file, changes) {
452
+ function updateFileCache(file, changes) {
460
453
  const data = cacheData(file.cache, changes);
461
454
  try {
462
- saveCodexFileCache(codexHome, file.filePath, file.signature, data);
455
+ saveCodexFileCache(file.codexHome, file.filePath, file.signature, data);
463
456
  } catch {
464
457
  // A read-only home, full disk, or antivirus race must only disable the
465
458
  // optimization for this run. Raw-log parsing remains the source of truth.
@@ -803,10 +796,24 @@ function mergeFileResults(results) {
803
796
  return { buckets: aggregateToBuckets(entries), sessions };
804
797
  }
805
798
 
806
- export async function parse() {
807
- const codexHome = getCodexHome();
808
- const dirs = sessionsDirs(codexHome);
809
- if (!dirs.some(existsSync)) return { buckets: [], sessions: [] };
799
+ export async function parse({ codexExtraHome } = {}) {
800
+ if (codexExtraHome?.trim()) {
801
+ const validation = validateExtraCodexHome(codexExtraHome);
802
+ if (!validation.ok) {
803
+ return {
804
+ buckets: [],
805
+ sessions: [],
806
+ skipped: true,
807
+ warnings: [`codex: 额外 Codex Home 不可用,已跳过本次 Codex 同步: ${validation.path}`],
808
+ };
809
+ }
810
+ }
811
+
812
+ const codexHomes = resolveCodexHomes(codexExtraHome);
813
+ const dirs = codexHomes.flatMap(codexHome => (
814
+ codexSessionDirs(codexHome).map(dir => ({ codexHome, dir }))
815
+ ));
816
+ if (!dirs.some(({ dir }) => existsSync(dir))) return { buckets: [], sessions: [] };
810
817
 
811
818
  const startedAt = Date.now();
812
819
  const budget = workBudgetMs();
@@ -820,29 +827,32 @@ export async function parse() {
820
827
  audited: 0,
821
828
  };
822
829
  const files = [];
823
- for (const filePath of dirs.flatMap(findJsonlFiles)) {
824
- try {
825
- const stat = statSync(filePath);
826
- if (stat.size <= 0) continue;
827
- const signature = fileSignature(stat);
828
- const cache = loadCodexFileCache(codexHome, filePath, signature);
829
- const priorCache = cache || loadCodexFileCache(codexHome, filePath);
830
- const priorTail = cache ? null : loadCodexFileTail(codexHome, filePath);
831
- const file = {
832
- filePath,
833
- snapshotSize: stat.size,
834
- signature,
835
- cache,
836
- priorCache,
837
- priorTail,
838
- header: null,
839
- appendTail: null,
840
- };
841
- if (!cache && priorCache) file.appendTail = tailStateFor(file);
842
- files.push(file);
843
- } catch {
844
- // The file may move to archived_sessions between discovery and stat.
845
- // Its archived copy will be picked up on the next sync.
830
+ for (const { codexHome, dir } of dirs) {
831
+ for (const filePath of findJsonlFiles(dir)) {
832
+ try {
833
+ const stat = statSync(filePath);
834
+ if (stat.size <= 0) continue;
835
+ const signature = fileSignature(stat);
836
+ const cache = loadCodexFileCache(codexHome, filePath, signature);
837
+ const priorCache = cache || loadCodexFileCache(codexHome, filePath);
838
+ const priorTail = cache ? null : loadCodexFileTail(codexHome, filePath);
839
+ const file = {
840
+ codexHome,
841
+ filePath,
842
+ snapshotSize: stat.size,
843
+ signature,
844
+ cache,
845
+ priorCache,
846
+ priorTail,
847
+ header: null,
848
+ appendTail: null,
849
+ };
850
+ if (!cache && priorCache) file.appendTail = tailStateFor(file);
851
+ files.push(file);
852
+ } catch {
853
+ // The file may move to archived_sessions between discovery and stat.
854
+ // Its archived copy will be picked up on the next sync.
855
+ }
846
856
  }
847
857
  }
848
858
  if (files.length === 0) return { buckets: [], sessions: [] };
@@ -875,7 +885,7 @@ export async function parse() {
875
885
  try {
876
886
  file.header = await readSessionHeader(file.filePath, file.snapshotSize);
877
887
  cacheStats.filesRead++;
878
- updateFileCache(codexHome, file, { header: file.header });
888
+ updateFileCache(file, { header: file.header });
879
889
  } catch {
880
890
  continue;
881
891
  }
@@ -928,7 +938,7 @@ export async function parse() {
928
938
  try {
929
939
  meta = await indexSessionFile(file.filePath, file.snapshotSize);
930
940
  cacheStats.filesRead++;
931
- updateFileCache(codexHome, file, { index: meta });
941
+ updateFileCache(file, { index: meta });
932
942
  } catch {
933
943
  continue;
934
944
  }
@@ -984,14 +994,14 @@ export async function parse() {
984
994
  const { tail, ...summary } = parsed;
985
995
  if (tail) {
986
996
  try {
987
- saveCodexFileTail(codexHome, file.filePath, file.signature, tail);
997
+ saveCodexFileTail(file.codexHome, file.filePath, file.signature, tail);
988
998
  } catch {
989
999
  // Same fail-open rule as the summary cache: tail acceleration is
990
1000
  // optional and a write failure must not fail the parser.
991
1001
  }
992
1002
  }
993
1003
  result = { boundaryKey: key, ...summary };
994
- updateFileCache(codexHome, file, { result, lastAuditedAt: Date.now() });
1004
+ updateFileCache(file, { result, lastAuditedAt: Date.now() });
995
1005
  file.appendTail = null;
996
1006
  file.priorTail = null;
997
1007
  if (auditPaths.has(file.filePath)) cacheStats.audited++;
@@ -0,0 +1,133 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { aggregateToBuckets, extractSessions } from './index.js';
3
+ import { queryDbJson } from './sqlite.js';
4
+ import { getDimAgentDbPath } from '../tools.js';
5
+
6
+ const SOURCE = 'dimagent';
7
+ const FORKED_LEDGER_ID = /^ledger_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+
9
+ function projectName(cwd) {
10
+ if (!cwd) return 'unknown';
11
+ const parts = String(cwd).replace(/[/\\]+$/, '').split(/[/\\]/);
12
+ return parts.at(-1) || 'unknown';
13
+ }
14
+
15
+ function tokenCount(value) {
16
+ const count = Number(value);
17
+ return Number.isFinite(count) && count > 0 ? count : 0;
18
+ }
19
+
20
+ function usageSignature(row) {
21
+ return [
22
+ row.runId || '',
23
+ row.providerId || '',
24
+ row.modelId || '',
25
+ row.usage || '',
26
+ row.cost ?? '',
27
+ row.createdAt || '',
28
+ ].join('\0');
29
+ }
30
+
31
+ function parseUsageRows(rows) {
32
+ const entries = [];
33
+ const originalSignatures = new Set(
34
+ rows
35
+ .filter(row => !FORKED_LEDGER_ID.test(row.ledgerId || ''))
36
+ .map(usageSignature),
37
+ );
38
+ const keptOrphanClones = new Set();
39
+
40
+ for (const row of rows) {
41
+ const signature = usageSignature(row);
42
+ if (FORKED_LEDGER_ID.test(row.ledgerId || '')) {
43
+ if (originalSignatures.has(signature) || keptOrphanClones.has(signature)) continue;
44
+ keptOrphanClones.add(signature);
45
+ }
46
+
47
+ let usage;
48
+ try {
49
+ usage = typeof row.usage === 'string' ? JSON.parse(row.usage) : row.usage;
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (!usage || typeof usage !== 'object') continue;
54
+
55
+ const timestamp = new Date(row.createdAt);
56
+ if (Number.isNaN(timestamp.getTime())) continue;
57
+
58
+ const promptTokens = tokenCount(usage.promptTokens);
59
+ const cachedInputTokens = tokenCount(usage.cacheReadTokens);
60
+ const inputTokens = Math.max(0, promptTokens - cachedInputTokens);
61
+ const outputTokens = tokenCount(usage.completionTokens);
62
+ if (inputTokens + outputTokens + cachedInputTokens === 0) continue;
63
+
64
+ entries.push({
65
+ source: SOURCE,
66
+ model: row.modelId || 'unknown',
67
+ project: projectName(row.cwd),
68
+ timestamp,
69
+ inputTokens,
70
+ outputTokens,
71
+ cachedInputTokens,
72
+ reasoningOutputTokens: 0,
73
+ });
74
+ }
75
+
76
+ return entries;
77
+ }
78
+
79
+ function queryDb(dbPath, sql) {
80
+ try {
81
+ return queryDbJson(dbPath, sql);
82
+ } catch (err) {
83
+ if (err.code === 'ENOENT' || err.status === 127 || err.message?.includes('ENOENT')) {
84
+ throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync DimAgent data.');
85
+ }
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ export async function parse() {
91
+ const dbPath = getDimAgentDbPath();
92
+ if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
93
+
94
+ const usageRows = queryDb(dbPath, `SELECT
95
+ u.ledgerId,
96
+ u.runId,
97
+ u.providerId,
98
+ u.modelId,
99
+ u.usage,
100
+ u.cost,
101
+ u.createdAt,
102
+ s.cwd
103
+ FROM usage_ledger u
104
+ LEFT JOIN sessions s ON s.sessionId = u.sessionId`);
105
+
106
+ const messageRows = queryDb(dbPath, `SELECT
107
+ m.sessionId,
108
+ m.role,
109
+ m.createdAt,
110
+ s.cwd
111
+ FROM messages m
112
+ LEFT JOIN sessions s ON s.sessionId = m.sessionId
113
+ WHERE m.role IN ('user', 'assistant')
114
+ AND m.messageId NOT LIKE 'msg_fork_%'`);
115
+
116
+ const sessionEvents = [];
117
+ for (const row of messageRows) {
118
+ const timestamp = new Date(row.createdAt);
119
+ if (Number.isNaN(timestamp.getTime())) continue;
120
+ sessionEvents.push({
121
+ sessionId: row.sessionId,
122
+ source: SOURCE,
123
+ project: projectName(row.cwd),
124
+ timestamp,
125
+ role: row.role,
126
+ });
127
+ }
128
+
129
+ return {
130
+ buckets: aggregateToBuckets(parseUsageRows(usageRows)),
131
+ sessions: extractSessions(sessionEvents),
132
+ };
133
+ }
@@ -4,6 +4,7 @@ import { parse as parseCline } from './cline.js';
4
4
  import { parse as parseCodex } from './codex.js';
5
5
  import { parse as parseCopilotCli } from './copilot-cli.js';
6
6
  import { parse as parseCursor } from './cursor.js';
7
+ import { parse as parseDimAgent } from './dimagent.js';
7
8
  import { parse as parseRooCode } from './roo-code.js';
8
9
  import { parse as parseGeminiCli } from './gemini-cli.js';
9
10
  import { parse as parseGrok } from './grok.js';
@@ -16,6 +17,7 @@ import { parse as parseDroid } from './droid.js';
16
17
  import { parse as parseAntigravity } from './antigravity.js';
17
18
  import { parse as parseHermes } from './hermes.js';
18
19
  import { parse as parseKiro } from './kiro.js';
20
+ import { parse as parseMimocode } from './mimocode.js';
19
21
  import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
20
22
  import { parse as parseZcode } from './zcode.js';
21
23
  import { parse as parseTraeCli } from './trae-cli.js';
@@ -26,6 +28,7 @@ export const parsers = {
26
28
  'grok': parseGrok,
27
29
  'copilot-cli': parseCopilotCli,
28
30
  'cursor': parseCursor,
31
+ 'dimagent': parseDimAgent,
29
32
  'gemini-cli': parseGeminiCli,
30
33
  'opencode': parseOpencode,
31
34
  'openclaw': parseOpenclaw,
@@ -38,6 +41,7 @@ export const parsers = {
38
41
  'trae-cli': parseTraeCli,
39
42
  'hermes': parseHermes,
40
43
  'kiro': parseKiro,
44
+ 'mimocode': parseMimocode,
41
45
  'cline': parseCline,
42
46
  'roo-code': parseRooCode,
43
47
  'zcode': parseZcode,
@@ -0,0 +1,90 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { getMimocodeDbPath } from '../tools.js';
4
+ import { aggregateToBuckets, extractSessions } from './index.js';
5
+ import { queryDbJson } from './sqlite.js';
6
+
7
+ export { getMimocodeDbPath as resolveMimocodeDbPath };
8
+
9
+ export async function parse() {
10
+ const dbPath = getMimocodeDbPath();
11
+ if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
12
+
13
+ let rows;
14
+ try {
15
+ const hasExternalImports = queryDbJson(dbPath, `
16
+ SELECT 1
17
+ FROM sqlite_master
18
+ WHERE type = 'table' AND name = 'external_import'
19
+ LIMIT 1
20
+ `).length > 0;
21
+ const externalImportJoin = hasExternalImports
22
+ ? 'LEFT JOIN external_import ON external_import.session_id = message.session_id'
23
+ : '';
24
+ const externalImportFilter = hasExternalImports
25
+ ? 'WHERE external_import.session_id IS NULL'
26
+ : '';
27
+ rows = queryDbJson(dbPath, `
28
+ SELECT
29
+ message.session_id AS sessionID,
30
+ message.time_created AS created,
31
+ message.data AS data,
32
+ session.directory AS directory
33
+ FROM message
34
+ JOIN session ON session.id = message.session_id
35
+ ${externalImportJoin}
36
+ ${externalImportFilter}
37
+ `);
38
+ } catch (err) {
39
+ if (err.status === 127 || err.message?.includes('ENOENT')) {
40
+ throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync MiMoCode data.');
41
+ }
42
+ throw err;
43
+ }
44
+
45
+ const entries = [];
46
+ const events = [];
47
+ for (const row of rows) {
48
+ let data;
49
+ try {
50
+ data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
51
+ } catch {
52
+ continue;
53
+ }
54
+ if (data?.role !== 'user' && data?.role !== 'assistant') continue;
55
+
56
+ const timestamp = new Date(data.time?.created ?? row.created);
57
+ if (Number.isNaN(timestamp.getTime())) continue;
58
+ const project = row.directory ? basename(row.directory) : 'unknown';
59
+ events.push({
60
+ sessionId: row.sessionID || 'unknown',
61
+ source: 'mimocode',
62
+ project,
63
+ timestamp,
64
+ role: data.role,
65
+ });
66
+
67
+ const tokens = data.tokens;
68
+ if (data.role !== 'assistant' || !data.modelID || !tokens) continue;
69
+ const inputTokens = (Number(tokens.input) || 0) + (Number(tokens.cache?.write) || 0);
70
+ const outputTokens = Number(tokens.output) || 0;
71
+ const reasoningOutputTokens = Number(tokens.reasoning) || 0;
72
+ const cachedInputTokens = Number(tokens.cache?.read) || 0;
73
+ if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens <= 0) continue;
74
+ entries.push({
75
+ source: 'mimocode',
76
+ model: data.modelID,
77
+ project,
78
+ timestamp,
79
+ inputTokens,
80
+ outputTokens,
81
+ cachedInputTokens,
82
+ reasoningOutputTokens,
83
+ });
84
+ }
85
+
86
+ return {
87
+ buckets: aggregateToBuckets(entries),
88
+ sessions: extractSessions(events),
89
+ };
90
+ }
package/src/sync.js CHANGED
@@ -27,7 +27,16 @@ export function resolveUploadProjectSetting(settings) {
27
27
  return settings.uploadProject;
28
28
  }
29
29
 
30
- export async function runSync({ throws = false, quiet = false, surface = 'cli' } = {}) {
30
+ export function resolveCodexExtraHome(configured, temporary) {
31
+ return temporary ?? configured;
32
+ }
33
+
34
+ export async function runSync({
35
+ throws = false,
36
+ quiet = false,
37
+ surface = 'cli',
38
+ codexExtraHome,
39
+ } = {}) {
31
40
  const config = loadConfig();
32
41
  if (!config?.apiKey) {
33
42
  console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
@@ -72,7 +81,9 @@ export async function runSync({ throws = false, quiet = false, surface = 'cli' }
72
81
 
73
82
  for (const [source, parse] of Object.entries(parsers)) {
74
83
  try {
75
- const result = await parse();
84
+ const result = source === 'codex'
85
+ ? await parse({ codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome) })
86
+ : await parse();
76
87
  const buckets = Array.isArray(result) ? result : result.buckets;
77
88
  const sessions = Array.isArray(result) ? [] : (result.sessions || []);
78
89
  if (!Array.isArray(buckets) || !Array.isArray(sessions)) {
package/src/tools.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { findClaudeCodeDataDirs } from './claude-roots.js';
5
+ import { codexSessionDirs, resolveCodexHomes } from './codex-roots.js';
5
6
 
6
7
  function getCursorStateDbPath() {
7
8
  const rel = join('User', 'globalStorage', 'state.vscdb');
@@ -84,11 +85,10 @@ function findOpenclawDataDirs() {
84
85
  // Codex keeps live sessions in ~/.codex/sessions and moves completed ones to
85
86
  // ~/.codex/archived_sessions. Detect Codex if either dir exists, so a user
86
87
  // whose sessions have all been archived is still recognized.
87
- function findCodexDataDirs() {
88
- return [
89
- join(homedir(), '.codex', 'sessions'),
90
- join(homedir(), '.codex', 'archived_sessions'),
91
- ].filter(existsSync);
88
+ export function findCodexDataDirs(codexExtraHome) {
89
+ return resolveCodexHomes(codexExtraHome)
90
+ .flatMap(codexSessionDirs)
91
+ .filter(existsSync);
92
92
  }
93
93
 
94
94
  // Kimi Code moved its store from ~/.kimi to ~/.kimi-code; recognize either so
@@ -100,6 +100,17 @@ function findKimiCodeDataDirs() {
100
100
  ].filter(existsSync);
101
101
  }
102
102
 
103
+ export function getMimocodeDbPath(env = process.env) {
104
+ if (env.MIMOCODE_HOME && !isAbsolute(env.MIMOCODE_HOME)) {
105
+ throw new Error(`MIMOCODE_HOME must be an absolute path, got: ${JSON.stringify(env.MIMOCODE_HOME)}`);
106
+ }
107
+ const dataDir = env.MIMOCODE_HOME
108
+ ? join(env.MIMOCODE_HOME, 'data')
109
+ : join(env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mimocode');
110
+ if (!env.MIMOCODE_DB) return join(dataDir, 'mimocode.db');
111
+ return isAbsolute(env.MIMOCODE_DB) ? env.MIMOCODE_DB : join(dataDir, env.MIMOCODE_DB);
112
+ }
113
+
103
114
  function findAntigravityDataDirs() {
104
115
  return [
105
116
  join(homedir(), '.gemini', 'antigravity'),
@@ -145,6 +156,24 @@ export function findGrokDataDirs() {
145
156
  return [join(getGrokHome(), 'sessions')].filter(existsSync);
146
157
  }
147
158
 
159
+ export function getDimAgentDbPath() {
160
+ const override = process.env.VIBE_USAGE_DIMAGENT_DB?.trim();
161
+ if (override) return resolve(override);
162
+
163
+ const explicitHome = process.env.DIMCODE_HOME?.trim();
164
+ if (explicitHome) return join(resolve(explicitHome), 'dimcode.sqlite');
165
+
166
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim();
167
+ const home = xdgConfigHome
168
+ ? resolve(xdgConfigHome, '.dimcode', 'v2')
169
+ : join(homedir(), '.dimcode', 'v2');
170
+ return join(home, 'dimcode.sqlite');
171
+ }
172
+
173
+ export function findDimAgentDataDirs() {
174
+ return [getDimAgentDbPath()].filter(existsSync);
175
+ }
176
+
148
177
  export const TOOLS = [
149
178
  {
150
179
  name: 'Claude Code',
@@ -156,7 +185,7 @@ export const TOOLS = [
156
185
  name: 'Codex CLI',
157
186
  id: 'codex',
158
187
  dataDir: join(homedir(), '.codex', 'sessions'),
159
- detectDataDirs: findCodexDataDirs,
188
+ detectDataDirs: ({ codexExtraHome } = {}) => findCodexDataDirs(codexExtraHome),
160
189
  },
161
190
  {
162
191
  name: 'Grok',
@@ -174,6 +203,12 @@ export const TOOLS = [
174
203
  id: 'cursor',
175
204
  dataDir: getCursorStateDbPath(),
176
205
  },
206
+ {
207
+ name: 'DimAgent',
208
+ id: 'dimagent',
209
+ dataDir: getDimAgentDbPath(),
210
+ detectDataDirs: findDimAgentDataDirs,
211
+ },
177
212
  {
178
213
  name: 'Gemini CLI',
179
214
  id: 'gemini-cli',
@@ -208,6 +243,12 @@ export const TOOLS = [
208
243
  dataDir: join(homedir(), '.kimi-code', 'sessions'),
209
244
  detectDataDirs: findKimiCodeDataDirs,
210
245
  },
246
+ {
247
+ name: 'MiMoCode',
248
+ id: 'mimocode',
249
+ dataDir: join(homedir(), '.local', 'share', 'mimocode', 'mimocode.db'),
250
+ detectDataDirs: () => [getMimocodeDbPath()].filter(existsSync),
251
+ },
211
252
  {
212
253
  name: 'Amp',
213
254
  id: 'amp',
@@ -259,9 +300,9 @@ export const TOOLS = [
259
300
  },
260
301
  ];
261
302
 
262
- export function detectInstalledTools() {
303
+ export function detectInstalledTools(options = {}) {
263
304
  return TOOLS.filter(t => {
264
- if (t.detectDataDirs) return t.detectDataDirs().length > 0;
305
+ if (t.detectDataDirs) return t.detectDataDirs(options).length > 0;
265
306
  return existsSync(t.dataDir);
266
307
  });
267
308
  }