@vibe-cafe/vibe-usage 0.10.18 → 0.10.20

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.
@@ -1,7 +1,8 @@
1
- import { createReadStream, existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { join } from 'node:path';
4
4
  import { findGrokDataDirs, getGrokSessionsDir } from '../tools.js';
5
+ import { grokSessionsDir, normalizeExtraRoot } from '../extra-roots.js';
5
6
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
6
7
  import { readJsonSafe, projectFromPath } from './fs-utils.js';
7
8
 
@@ -25,14 +26,14 @@ const SOURCE = 'grok';
25
26
  */
26
27
 
27
28
  /** Decode a sessions group dirname; fall back to basename after decode. */
28
- function projectFromGroupDir(groupName, groupPath) {
29
+ function projectFromGroupDir(groupName, groupPath, strict = false) {
29
30
  const cwdFile = join(groupPath, '.cwd');
30
31
  if (existsSync(cwdFile)) {
31
32
  try {
32
33
  const raw = readFileSync(cwdFile, 'utf-8').trim();
33
34
  if (raw) return projectFromPath(raw);
34
- } catch {
35
- // ignore
35
+ } catch (err) {
36
+ if (strict) throw err;
36
37
  }
37
38
  }
38
39
  try {
@@ -115,12 +116,13 @@ function emitTurnUsage(entries, { usage, project, timestamp, fallbackModel }) {
115
116
  });
116
117
  }
117
118
 
118
- async function forEachJsonlLine(filePath, onLine) {
119
+ async function forEachJsonlLine(filePath, onLine, strict = false) {
119
120
  if (!existsSync(filePath)) return;
120
121
  let stream;
121
122
  try {
122
123
  stream = createReadStream(filePath, { encoding: 'utf-8' });
123
- } catch {
124
+ } catch (err) {
125
+ if (strict) throw err;
124
126
  return;
125
127
  }
126
128
 
@@ -137,7 +139,8 @@ async function forEachJsonlLine(filePath, onLine) {
137
139
  }
138
140
  onLine(obj);
139
141
  }
140
- } catch {
142
+ } catch (err) {
143
+ if (strict) throw err;
141
144
  // unreadable / truncated mid-write — keep what we have
142
145
  } finally {
143
146
  rl.close();
@@ -145,14 +148,18 @@ async function forEachJsonlLine(filePath, onLine) {
145
148
  }
146
149
  }
147
150
 
148
- function listSessionDirs(sessionsDir) {
151
+ function listSessionDirs(sessionsDir, strict = false) {
149
152
  const results = [];
150
- if (!existsSync(sessionsDir)) return results;
153
+ if (!existsSync(sessionsDir)) {
154
+ if (strict) throw new Error(`missing sessions directory: ${sessionsDir}`);
155
+ return results;
156
+ }
151
157
 
152
158
  let groups;
153
159
  try {
154
160
  groups = readdirSync(sessionsDir, { withFileTypes: true });
155
- } catch {
161
+ } catch (err) {
162
+ if (strict) throw err;
156
163
  return results;
157
164
  }
158
165
 
@@ -163,11 +170,12 @@ function listSessionDirs(sessionsDir) {
163
170
  let children;
164
171
  try {
165
172
  children = readdirSync(groupPath, { withFileTypes: true });
166
- } catch {
173
+ } catch (err) {
174
+ if (strict) throw err;
167
175
  continue;
168
176
  }
169
177
 
170
- const projectFallback = projectFromGroupDir(group.name, groupPath);
178
+ const projectFallback = projectFromGroupDir(group.name, groupPath, strict);
171
179
 
172
180
  for (const child of children) {
173
181
  if (!child.isDirectory()) continue;
@@ -194,8 +202,29 @@ function listSessionDirs(sessionsDir) {
194
202
  * Parse all Grok sessions under the configured sessions root(s).
195
203
  * @returns {Promise<{ buckets: object[], sessions: object[] }>}
196
204
  */
197
- export async function parse() {
198
- const sessionRoots = findGrokDataDirs();
205
+ export async function parse({ extraRoots = [] } = {}) {
206
+ if (!process.env.VIBE_USAGE_GROK_SESSIONS?.trim()) {
207
+ for (const root of extraRoots) {
208
+ const sessionsDir = grokSessionsDir(root);
209
+ try {
210
+ if (!statSync(sessionsDir).isDirectory()) throw new Error('not a directory');
211
+ } catch {
212
+ return {
213
+ buckets: [],
214
+ sessions: [],
215
+ skipped: true,
216
+ warnings: [`grok: 额外根目录不可用,已跳过本次 Grok 同步: ${normalizeExtraRoot(root)}`],
217
+ };
218
+ }
219
+ }
220
+ }
221
+ const strictRoots = process.env.VIBE_USAGE_GROK_SESSIONS?.trim()
222
+ ? new Set()
223
+ : new Set(extraRoots.map(grokSessionsDir));
224
+ const sessionRoots = findGrokDataDirs(extraRoots);
225
+ for (const configuredRoot of strictRoots) {
226
+ if (!sessionRoots.includes(configuredRoot)) sessionRoots.push(configuredRoot);
227
+ }
199
228
  // findGrokDataDirs returns sessions dirs; also allow empty → try default once
200
229
  const roots = sessionRoots.length > 0 ? sessionRoots : [getGrokSessionsDir()].filter(existsSync);
201
230
  if (roots.length === 0) return { buckets: [], sessions: [] };
@@ -203,9 +232,58 @@ export async function parse() {
203
232
  const entries = [];
204
233
  const sessionEvents = [];
205
234
 
235
+ const candidates = [];
206
236
  for (const sessionsDir of roots) {
207
- for (const { sessionId, sessionPath, projectFallback } of listSessionDirs(sessionsDir)) {
208
- const summary = readJsonSafe(join(sessionPath, 'summary.json')) || {};
237
+ const strict = strictRoots.has(sessionsDir);
238
+ try {
239
+ for (const session of listSessionDirs(sessionsDir, strict)) {
240
+ candidates.push({ ...session, strict, configuredRoot: sessionsDir });
241
+ }
242
+ } catch {
243
+ return {
244
+ buckets: [], sessions: [], skipped: true,
245
+ warnings: [`grok: 额外根目录读取失败,已保留上次同步数据: ${sessionsDir}`],
246
+ };
247
+ }
248
+ }
249
+
250
+ let sessionsToParse = candidates;
251
+ if (roots.length > 1) {
252
+ const selectedSessions = new Map();
253
+ for (const session of candidates) {
254
+ const fileSize = (name) => {
255
+ try {
256
+ return statSync(join(session.sessionPath, name)).size;
257
+ } catch {
258
+ return 0;
259
+ }
260
+ };
261
+ const score = [fileSize('updates.jsonl'), fileSize('events.jsonl'), fileSize('summary.json')];
262
+ const previous = selectedSessions.get(session.sessionId);
263
+ const moreComplete = !previous || score.some((value, index) => (
264
+ value !== previous.score[index] && value > previous.score[index]
265
+ && score.slice(0, index).every((prior, priorIndex) => prior === previous.score[priorIndex])
266
+ ));
267
+ if (moreComplete) selectedSessions.set(session.sessionId, { ...session, score });
268
+ }
269
+ sessionsToParse = [...selectedSessions.values()];
270
+ }
271
+
272
+ for (const {
273
+ sessionId,
274
+ sessionPath,
275
+ projectFallback,
276
+ strict,
277
+ configuredRoot,
278
+ } of sessionsToParse) {
279
+ try {
280
+ const summaryPath = join(sessionPath, 'summary.json');
281
+ let summary;
282
+ if (strict && existsSync(summaryPath)) {
283
+ summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
284
+ } else {
285
+ summary = readJsonSafe(summaryPath) || {};
286
+ }
209
287
  const cwd = summary.info?.cwd || summary.git_root_dir || null;
210
288
  const project = cwd ? projectFromPath(cwd) : projectFallback;
211
289
  const fallbackModel = summary.current_model_id || 'unknown';
@@ -249,7 +327,7 @@ export async function parse() {
249
327
  role: 'assistant',
250
328
  });
251
329
  }
252
- });
330
+ }, strict);
253
331
 
254
332
  // Fallback timing from events.jsonl when updates lack message chunks
255
333
  // (short/aborted sessions, older builds).
@@ -274,7 +352,7 @@ export async function parse() {
274
352
  role: 'assistant',
275
353
  });
276
354
  }
277
- });
355
+ }, strict);
278
356
  }
279
357
 
280
358
  // Last-resort session envelope from summary timestamps so a session with
@@ -301,6 +379,12 @@ export async function parse() {
301
379
  });
302
380
  }
303
381
  }
382
+ } catch (err) {
383
+ if (!strict) throw err;
384
+ return {
385
+ buckets: [], sessions: [], skipped: true,
386
+ warnings: [`grok: 额外根目录读取失败,已保留上次同步数据: ${configuredRoot}`],
387
+ };
304
388
  }
305
389
  }
306
390
 
@@ -20,6 +20,7 @@ import { parse as parseDsh } from './dsh.js';
20
20
  import { parse as parseAntigravity } from './antigravity.js';
21
21
  import { parse as parseHermes } from './hermes.js';
22
22
  import { parse as parseKiro } from './kiro.js';
23
+ import { parse as parseMcode } from './mcode.js';
23
24
  import { parse as parseMimocode } from './mimocode.js';
24
25
  import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
25
26
  import { parse as parseZcode } from './zcode.js';
@@ -49,6 +50,7 @@ export const parsers = {
49
50
  'trae-cli': parseTraeCli,
50
51
  'hermes': parseHermes,
51
52
  'kiro': parseKiro,
53
+ 'mcode': parseMcode,
52
54
  'mimocode': parseMimocode,
53
55
  'cline': parseCline,
54
56
  'roo-code': parseRooCode,
@@ -0,0 +1,182 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { projectFromPath } from './fs-utils.js';
3
+ import { aggregateToBuckets } from './aggregate.js';
4
+ import {
5
+ queryDbJsonSnapshotOnLock,
6
+ isSqliteUnavailableError,
7
+ sqliteUnavailableError,
8
+ } from './sqlite.js';
9
+ import { getMcodeDbPath } from '../tools.js';
10
+
11
+ const SOURCE = 'mcode';
12
+
13
+ // Strict column allow-list. The mcode token table also stores a `raw` JSON
14
+ // payload (and the sessions table stores `record_json` / `extra_data_json`)
15
+ // that contains message bodies — we never select those, neither in this
16
+ // parser nor in any test fixture.
17
+ const TOKEN_COLUMNS = [
18
+ 'session_id',
19
+ 'model',
20
+ 'ts',
21
+ 'input_tokens',
22
+ 'output_tokens',
23
+ 'reasoning_tokens',
24
+ 'cache_read_tokens',
25
+ 'cache_write_tokens',
26
+ ];
27
+
28
+ // `local_runtime_sessions` carries both `workspace_dir` (per-session scratch
29
+ // dir, always present) and `project_workspace_dir` (the project root when one
30
+ // is known). We pick the project column first, then the workspace, then
31
+ // fall back to "unknown". Never read `record_json` / `extra_data_json`.
32
+ const SESSION_COLUMNS = ['session_id', 'workspace_dir', 'project_workspace_dir'];
33
+
34
+ // Keep token rows and their project metadata in one SQLite statement. Separate
35
+ // reads can observe different WAL snapshots while mcode is writing, causing a
36
+ // token row to be uploaded once as "unknown" and again under its real project.
37
+ const USAGE_SQL = `
38
+ SELECT
39
+ ${TOKEN_COLUMNS.map(column => `token.${column}`).join(', ')},
40
+ session.workspace_dir,
41
+ session.project_workspace_dir
42
+ FROM local_runtime_token_usage AS token
43
+ LEFT JOIN local_runtime_sessions AS session
44
+ ON session.session_id = token.session_id
45
+ `;
46
+
47
+ /**
48
+ * Resolve the mcode runtime-state SQLite database. Mirrors the precedence used
49
+ * by sibling tools (MiMoCode, DimAgent): explicit env var wins, then a
50
+ * tool-specific HOME, then the default layout.
51
+ *
52
+ * Defaults to `<homedir()>/.minimax/v2/sqlite/runtime-state.sqlite`, which is
53
+ * where the mcode CLI keeps its WAL database on macOS / Linux.
54
+ */
55
+ export function resolveMcodeDbPath(env = process.env) {
56
+ return getMcodeDbPath(env);
57
+ }
58
+
59
+ /**
60
+ * Token count (ms vs seconds). The mcode runtime writes ts as integer
61
+ * milliseconds — confirmed against the live schema (`typeof(ts)=integer`,
62
+ * values ≈ 1.787e12 for 2026-08-29). Stay defensive: anything < 1e12 is
63
+ * treated as seconds and scaled up.
64
+ */
65
+ function tsToDate(value) {
66
+ const n = Number(value);
67
+ if (!Number.isFinite(n)) return null;
68
+ const ms = n < 1e12 ? n * 1000 : n;
69
+ const d = new Date(ms);
70
+ return Number.isNaN(d.getTime()) ? null : d;
71
+ }
72
+
73
+
74
+
75
+ function toNonNegative(value) {
76
+ const n = Number(value);
77
+ if (!Number.isFinite(n) || n < 0) return 0;
78
+ return n;
79
+ }
80
+
81
+ function dbHasColumns(dbPath, table, columns) {
82
+ const info = queryDbJsonSnapshotOnLock(
83
+ dbPath,
84
+ `PRAGMA table_info(${table})`,
85
+ { tempPrefix: 'vibe-usage-mcode' },
86
+ );
87
+ const present = new Set(info.map(row => String(row.name)));
88
+ return columns.every(col => present.has(col));
89
+ }
90
+
91
+ export async function parse() {
92
+ const dbPath = resolveMcodeDbPath();
93
+ if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
94
+
95
+ // Schema guard: every allow-listed column must exist. If the mcode
96
+ // runtime ever renames / drops a column, fail soft (skipped) so the
97
+ // incremental sync keeps the last good upload state for this source.
98
+ let schemaOk;
99
+ try {
100
+ schemaOk =
101
+ dbHasColumns(dbPath, 'local_runtime_token_usage', TOKEN_COLUMNS) &&
102
+ dbHasColumns(dbPath, 'local_runtime_sessions', SESSION_COLUMNS);
103
+ } catch (err) {
104
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
105
+ return { buckets: [], sessions: [], skipped: true };
106
+ }
107
+ if (!schemaOk) {
108
+ return { buckets: [], sessions: [], skipped: true };
109
+ }
110
+
111
+ // Read tokens and session project metadata from one statement/snapshot.
112
+ let usageRows;
113
+ try {
114
+ usageRows = queryDbJsonSnapshotOnLock(dbPath, USAGE_SQL, {
115
+ tempPrefix: 'vibe-usage-mcode',
116
+ });
117
+ } catch (err) {
118
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
119
+ return { buckets: [], sessions: [], skipped: true };
120
+ }
121
+
122
+ const entries = [];
123
+
124
+ for (const row of usageRows) {
125
+ const sessionId = row.session_id != null ? String(row.session_id) : '';
126
+ if (!sessionId) continue;
127
+ const ts = tsToDate(row.ts);
128
+ if (!ts) continue;
129
+
130
+ // MCode stores output and reasoning as separate counters. Its own
131
+ // summary code computes total = input + output + reasoning, so do not
132
+ // subtract reasoning from output here.
133
+ const inputRaw = toNonNegative(row.input_tokens);
134
+ const cacheWrite = toNonNegative(row.cache_write_tokens);
135
+ const outputRaw = toNonNegative(row.output_tokens);
136
+ const reasoningRaw = toNonNegative(row.reasoning_tokens);
137
+ const cacheRead = toNonNegative(row.cache_read_tokens);
138
+
139
+ const inputTokens = inputRaw + cacheWrite;
140
+ const reasoningOutputTokens = reasoningRaw;
141
+ const outputTokens = outputRaw;
142
+ const cachedInputTokens = cacheRead;
143
+
144
+ if (
145
+ inputTokens +
146
+ outputTokens +
147
+ cachedInputTokens +
148
+ reasoningOutputTokens ===
149
+ 0
150
+ ) {
151
+ continue;
152
+ }
153
+
154
+ const projectPath = row.project_workspace_dir || row.workspace_dir;
155
+ const project = projectPath ? projectFromPath(String(projectPath)) : 'unknown';
156
+ const model = row.model != null && String(row.model).trim()
157
+ ? String(row.model).trim()
158
+ : 'unknown';
159
+
160
+ entries.push({
161
+ source: SOURCE,
162
+ model,
163
+ project,
164
+ timestamp: ts,
165
+ inputTokens,
166
+ outputTokens,
167
+ cachedInputTokens,
168
+ reasoningOutputTokens,
169
+ });
170
+ }
171
+
172
+ return {
173
+ buckets: aggregateToBuckets(entries),
174
+ // The token ledger contains assistant usage rows only. Reconstructing
175
+ // user prompts would require reading message payloads, which this parser
176
+ // deliberately never selects, so mcode emits buckets only like Alma.
177
+ sessions: [],
178
+ };
179
+ }
180
+
181
+ // Re-export for tests / external consumers.
182
+ export { SOURCE as MCODE_SOURCE };
@@ -99,7 +99,10 @@ export async function parsePiSessionJsonl({
99
99
  if (message.role !== 'assistant' || !message.usage) continue;
100
100
  const usage = message.usage;
101
101
  const inputTokens = toCount(usage.input) + toCount(usage.cacheWrite);
102
- const reasoningOutputTokens = toCount(usage.reasoningTokens);
102
+ // Pi's Usage type names this field `reasoning` (a documented subset of
103
+ // `output`); older/adjacent stores wrote `reasoningTokens`. Reading only
104
+ // the latter left every Pi reasoning token inside outputTokens.
105
+ const reasoningOutputTokens = toCount(usage.reasoning ?? usage.reasoningTokens);
103
106
  // OMP/Pi usage.output includes reasoning; the shared bucket contract
104
107
  // stores non-reasoning output and reasoning separately.
105
108
  const outputTokens = Math.max(0, toCount(usage.output) - reasoningOutputTokens);
package/src/pi-roots.js CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, readdirSync } from 'node:fs';
2
- import { delimiter, join } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { delimiter, isAbsolute, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
5
  function expandHome(value) {
@@ -41,18 +41,41 @@ export function looksLikeOmpAgentDir(agentDir) {
41
41
  || existsSync(join(agentDir, 'agent.db'));
42
42
  }
43
43
 
44
+ // Pi resolves a session directory from `--session-dir`, then
45
+ // PI_CODING_AGENT_SESSION_DIR, then `sessionDir` in settings.json. Only the
46
+ // last two are discoverable after the fact, and both name the sessions
47
+ // directory itself (no `sessions` segment is appended).
48
+ function settingsSessionDir(agentDir) {
49
+ try {
50
+ const raw = readFileSync(join(agentDir, 'settings.json'), 'utf-8');
51
+ const value = JSON.parse(raw)?.sessionDir;
52
+ if (typeof value !== 'string') return null;
53
+ const trimmed = value.trim();
54
+ // Pi also accepts project-relative values. Those resolve against a cwd we
55
+ // do not have here, so only absolute and ~-anchored paths are scanned.
56
+ if (!trimmed || !(isAbsolute(trimmed) || trimmed.startsWith('~'))) return null;
57
+ return expandHome(trimmed);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
44
63
  export function getPiSessionDirs() {
45
64
  const override = process.env.VIBE_USAGE_PI_SESSION_DIRS?.trim();
46
65
  if (override) return uniqueExistingDirs(override.split(delimiter));
47
66
 
48
- const agentDir = process.env.PI_CODING_AGENT_DIR?.trim();
49
- if (agentDir) {
50
- const expanded = expandHome(agentDir);
51
- // OMP inherits PI_CODING_AGENT_DIR from Pi. Do not parse an identifiable
52
- // OMP store again as source=pi-coding-agent.
53
- return looksLikeOmpAgentDir(expanded) ? [] : uniqueExistingDirs([join(expanded, 'sessions')]);
54
- }
55
- return uniqueExistingDirs([join(homedir(), '.pi', 'agent', 'sessions')]);
67
+ const envAgentDir = process.env.PI_CODING_AGENT_DIR?.trim();
68
+ const agentDir = envAgentDir ? expandHome(envAgentDir) : join(homedir(), '.pi', 'agent');
69
+ // OMP inherits PI_CODING_AGENT_DIR from Pi. Do not parse an identifiable
70
+ // OMP store again as source=pi-coding-agent.
71
+ if (envAgentDir && looksLikeOmpAgentDir(agentDir)) return [];
72
+
73
+ const dirs = [join(agentDir, 'sessions')];
74
+ const envSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
75
+ if (envSessionDir) dirs.push(expandHome(envSessionDir));
76
+ const configured = settingsSessionDir(agentDir);
77
+ if (configured) dirs.push(configured);
78
+ return uniqueExistingDirs(dirs);
56
79
  }
57
80
 
58
81
  export function getOmpSessionDirs() {
package/src/sync.js CHANGED
@@ -9,6 +9,7 @@ import { createSyncClient, forBatch } from './client-meta.js';
9
9
  import { parsers } from './parsers/index.js';
10
10
  import { aggregateToBuckets } from './parsers/aggregate.js';
11
11
  import { normalizeParserResult } from './parsers/contract.js';
12
+ import { extraRootList } from './extra-roots.js';
12
13
  import { success, failure, warn, arrow, link, dim } from './output.js';
13
14
 
14
15
  const BATCH_SIZE = 100;
@@ -20,6 +21,12 @@ function formatBytes(bytes) {
20
21
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
21
22
  }
22
23
 
24
+ /** Hide only Cursor's intentional transient fetch soft-skip in quiet (daemon) syncs. */
25
+ export function shouldSuppressParserWarning(source, message, quiet) {
26
+ if (!quiet) return false;
27
+ return source === 'cursor' && message.startsWith('cursor: Cursor usage export skipped (');
28
+ }
29
+
23
30
  export function resolveUploadProjectSetting(settings) {
24
31
  if (typeof settings?.uploadProject !== 'boolean') {
25
32
  const error = new Error('SETTINGS_UNAVAILABLE');
@@ -147,9 +154,12 @@ export async function runSync({
147
154
  PARSER_CONCURRENCY,
148
155
  async ([source, parse]) => {
149
156
  try {
150
- const result = source === 'codex'
151
- ? await parse({ codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome) })
152
- : await parse();
157
+ const result = await parse({
158
+ extraRoots: extraRootList(config.extraRoots?.[source]),
159
+ ...(source === 'codex' ? {
160
+ codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome),
161
+ } : {}),
162
+ });
153
163
  return { source, result };
154
164
  } catch (err) {
155
165
  return { source, error: err };
@@ -175,6 +185,7 @@ export async function runSync({
175
185
  parserProgress.push({ source, ...indexing });
176
186
  }
177
187
  for (const message of warnings) {
188
+ if (shouldSuppressParserWarning(source, message, quiet)) continue;
178
189
  process.stderr.write(`${dim(` ${message}`)}\n`);
179
190
  }
180
191
  // A parser may deliberately suppress a transient error (Cursor network
package/src/tools.js CHANGED
@@ -1,9 +1,15 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { isAbsolute, join, posix, resolve, win32 } from 'node:path';
2
+ import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { findClaudeCodeDataDirs } from './claude-roots.js';
5
5
  import { findCindyDataDirs, getCindyDataRoots } from './cindy-roots.js';
6
6
  import { codexSessionDirs, resolveCodexHomes } from './codex-roots.js';
7
+ import {
8
+ antigravityConversationDirs,
9
+ discoverCodexHomes,
10
+ extraRootList,
11
+ grokSessionsDir,
12
+ } from './extra-roots.js';
7
13
  import { findClineDataDirs } from './cline-roots.js';
8
14
  import { findCraftDataDirs } from './craft-roots.js';
9
15
  import { findOmpDataDirs, findPiDataDirs } from './pi-roots.js';
@@ -106,8 +112,9 @@ function findOpenclawDataDirs() {
106
112
  // Codex keeps live sessions in ~/.codex/sessions and moves completed ones to
107
113
  // ~/.codex/archived_sessions. Detect Codex if either dir exists, so a user
108
114
  // whose sessions have all been archived is still recognized.
109
- export function findCodexDataDirs(codexExtraHome) {
110
- return resolveCodexHomes(codexExtraHome)
115
+ export function findCodexDataDirs(codexExtraHome, extraRoots = []) {
116
+ const configuredHomes = extraRoots.flatMap(root => discoverCodexHomes(root).homes);
117
+ return [...new Set([...resolveCodexHomes(codexExtraHome), ...configuredHomes])]
111
118
  .flatMap(codexSessionDirs)
112
119
  .filter(existsSync);
113
120
  }
@@ -143,6 +150,17 @@ export function findDshDataDirs() {
143
150
  return [getDshSessionsDir()].filter(existsSync);
144
151
  }
145
152
 
153
+ /** mcode runtime database: VIBE_USAGE_MCODE_DB wins, then MCODE_HOME, then ~/.minimax. */
154
+ export function getMcodeDbPath(env = process.env, home = homedir()) {
155
+ const override = env.VIBE_USAGE_MCODE_DB?.trim();
156
+ if (override) return isAbsolute(override) ? override : resolve(override);
157
+ if (env.MCODE_HOME && !isAbsolute(env.MCODE_HOME)) {
158
+ throw new Error(`MCODE_HOME must be an absolute path, got: ${JSON.stringify(env.MCODE_HOME)}`);
159
+ }
160
+ const root = env.MCODE_HOME || join(home, '.minimax');
161
+ return join(root, 'v2', 'sqlite', 'runtime-state.sqlite');
162
+ }
163
+
146
164
  export function getMimocodeDbPath(env = process.env) {
147
165
  if (env.MIMOCODE_HOME && !isAbsolute(env.MIMOCODE_HOME)) {
148
166
  throw new Error(`MIMOCODE_HOME must be an absolute path, got: ${JSON.stringify(env.MIMOCODE_HOME)}`);
@@ -154,11 +172,12 @@ export function getMimocodeDbPath(env = process.env) {
154
172
  return isAbsolute(env.MIMOCODE_DB) ? env.MIMOCODE_DB : join(dataDir, env.MIMOCODE_DB);
155
173
  }
156
174
 
157
- function findAntigravityDataDirs() {
158
- return [
175
+ export function findAntigravityDataDirs(extraRoots = []) {
176
+ return [...new Set([
159
177
  join(homedir(), '.gemini', 'antigravity'),
160
178
  join(homedir(), '.gemini', 'antigravity-cli'),
161
- ].filter(existsSync);
179
+ ...extraRoots.flatMap(root => antigravityConversationDirs(root).map(dirname)),
180
+ ])].filter(existsSync);
162
181
  }
163
182
 
164
183
  export function findTraeCliDataDirs() {
@@ -193,10 +212,13 @@ export function getGrokSessionsDir() {
193
212
  }
194
213
 
195
214
  // Detect Grok when sessions/ exists under GROK_HOME (or the test override).
196
- export function findGrokDataDirs() {
215
+ export function findGrokDataDirs(extraRoots = []) {
197
216
  const testDir = process.env.VIBE_USAGE_GROK_SESSIONS?.trim();
198
217
  if (testDir) return [testDir].filter(existsSync);
199
- return [join(getGrokHome(), 'sessions')].filter(existsSync);
218
+ return [...new Set([
219
+ join(getGrokHome(), 'sessions'),
220
+ ...extraRoots.map(grokSessionsDir),
221
+ ])].filter(existsSync);
200
222
  }
201
223
 
202
224
  export function getDimAgentDbPath() {
@@ -240,13 +262,15 @@ export const TOOLS = [
240
262
  name: 'Codex CLI',
241
263
  id: 'codex',
242
264
  dataDir: join(homedir(), '.codex', 'sessions'),
243
- detectDataDirs: ({ codexExtraHome } = {}) => findCodexDataDirs(codexExtraHome),
265
+ detectDataDirs: ({ codexExtraHome, extraRoots } = {}) => (
266
+ findCodexDataDirs(codexExtraHome, extraRootList(extraRoots?.codex))
267
+ ),
244
268
  },
245
269
  {
246
270
  name: 'Grok',
247
271
  id: 'grok',
248
272
  dataDir: join(homedir(), '.grok', 'sessions'),
249
- detectDataDirs: findGrokDataDirs,
273
+ detectDataDirs: ({ extraRoots } = {}) => findGrokDataDirs(extraRootList(extraRoots?.grok)),
250
274
  },
251
275
  {
252
276
  name: 'GitHub Copilot CLI',
@@ -311,6 +335,12 @@ export const TOOLS = [
311
335
  dataDir: join(homedir(), '.kimi-code', 'sessions'),
312
336
  detectDataDirs: findKimiCodeDataDirs,
313
337
  },
338
+ {
339
+ name: 'MiniMax Code',
340
+ id: 'mcode',
341
+ dataDir: join(homedir(), '.minimax', 'v2', 'sqlite', 'runtime-state.sqlite'),
342
+ detectDataDirs: () => [getMcodeDbPath()].filter(existsSync),
343
+ },
314
344
  {
315
345
  name: 'MiMoCode',
316
346
  id: 'mimocode',
@@ -337,7 +367,9 @@ export const TOOLS = [
337
367
  name: 'Antigravity',
338
368
  id: 'antigravity',
339
369
  dataDir: join(homedir(), '.gemini', 'antigravity'),
340
- detectDataDirs: findAntigravityDataDirs,
370
+ detectDataDirs: ({ extraRoots } = {}) => (
371
+ findAntigravityDataDirs(extraRootList(extraRoots?.antigravity))
372
+ ),
341
373
  },
342
374
  {
343
375
  name: 'Trae CLI',