@vibe-cafe/vibe-usage 0.10.20 → 0.10.22

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
@@ -64,7 +64,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
64
64
  | OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
65
65
  | OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments); cache-creation/cache-write tokens are included in input usage |
66
66
  | Oh My Pi | `~/.omp/agent/sessions/`, `~/.omp/profiles/*/agent/sessions/`, and `$XDG_DATA_HOME/omp/{sessions,profiles/*/sessions}`; recognizes OMP's `$PI_CODING_AGENT_DIR`, current v3 title slots and path/hashed session directories, deduplicates copied records, includes cache writes in input, and splits reasoning from OMP's inclusive output count |
67
- | pi | `~/.pi/agent/sessions/` or `$PI_CODING_AGENT_DIR/sessions/`, plus the session directory Pi itself was pointed at via `PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in `~/.pi/agent/settings.json` (fixture/relocation override: `VIBE_USAGE_PI_SESSION_DIRS`). Cache writes are included in input usage; reasoning is read from Pi's `usage.reasoning` (legacy `usage.reasoningTokens` still accepted) and split out of the inclusive output total |
67
+ | pi | `~/.pi/agent/sessions/` or `$PI_CODING_AGENT_DIR/sessions/`, plus the session directory Pi itself was pointed at via `PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in `~/.pi/agent/settings.json`, plus explicitly added `pi-coding-agent` roots for stores only reachable through `pi --session <file>` (fixture/relocation override: `VIBE_USAGE_PI_SESSION_DIRS`). Cache writes are included in input usage; reasoning is read from Pi's `usage.reasoning` (legacy `usage.reasoningTokens` still accepted) and split out of the inclusive output total |
68
68
  | Qwen Code | `~/.qwen/tmp/` |
69
69
  | 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) |
70
70
  | MiniMax Code (mcode) | `$MCODE_HOME/v2/sqlite/runtime-state.sqlite` (default `~/.minimax/v2/sqlite/runtime-state.sqlite`; fixture override: `VIBE_USAGE_MCODE_DB`). Reads only allow-listed token ledger fields and session workspace/project paths, uses basename-only projects, folds cache writes into input, keeps cache reads and reasoning separate, and never selects raw/message JSON payloads. WAL/lock reads use a disposable snapshot; malformed or incompatible databases are skipped to preserve incremental state. |
@@ -72,12 +72,12 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
72
72
  | Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
73
73
  | Droid | `~/.factory/sessions/` |
74
74
  | DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads multi-frame Zstandard `session.jsonl.zstd` logs (built-in `node:zlib` zstd on Node ≥ 22.15, `zstd` CLI fallback) and plain `session.jsonl` logs. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork/subagent history is de-duplicated from the immutable header: `parentSession` identifies the source and `seedLength` gives the exact leading event boundary. Inherited messages are skipped only when matching source seqs remain in the parent file; missing parents fail open. `session/end-seed` positions are not used because resumes can append the marker after real history. |
75
- | Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
75
+ | Hermes | `$HERMES_HOME/state.db` (default `~/.hermes/state.db`) + `profiles/<name>/state.db` (SQLite, multi-profile). Cache writes join input; reasoning is separated from inclusive output. Usage is currently a cumulative session total attributed to session start: a session spanning several days does **not** yet provide an accurate daily breakdown. |
76
76
  | Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
77
77
  | Cline | Standalone `~/.cline/` plus `<host>/User/globalStorage/saoudrizwan.claude-dev/` across VSCode-fork hosts; migrated copies are deduplicated and empty leftover extension stores no longer count as installed |
78
78
  | Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
79
79
  | Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported). Token usage is summed per unique LLM call (`model.stream.eino`, plus `model.generate` failovers); nested duplicate spans that share a session `traceID` are not max-merged. `traces.jsonl` / `events.jsonl` are streamed line-by-line so a multi-hundred-MB events file cannot hit Node's string-length limit. |
80
- | Antigravity | App 2.0 `~/.gemini/antigravity/conversations/*.db` and `agy` CLI `~/.gemini/antigravity-cli/conversations/*.db`, plus the same paths below explicitly added alternate Homes, are parsed offline (tokens, real model display name when present, project, sessions). Gemini 3.7 CLI blobs omit `chatStartMetadata.createdAt` and `modelDisplayName`; usage still comes from `gen_metadata`, timestamps fall back to `steps.metadata` at the same idx, and the model name falls back to `responseModel`. Legacy App `.pb` history falls back to Connect RPC only for the primary App store while the language server is running |
80
+ | Antigravity | Scans App 2.0 `~/.gemini/antigravity/conversations/`, `agy` CLI `~/.gemini/antigravity-cli/conversations/`, and standalone IDE `~/.gemini/antigravity-ide/conversations/`. `.db` stores, including the same paths below explicitly added alternate Homes, are parsed offline (tokens, model, project, sessions). When Gemini blobs omit `chatStartMetadata.createdAt` or `modelDisplayName`, timestamps fall back to `steps.metadata` and model names to `responseModel`. `.pb` history in the default stores requires the corresponding App/IDE language server to be running; when several servers are open, the parser tries the others for unreadable conversations. Unavailable legacy history produces a warning and preserves prior sync state. |
81
81
  | WorkBuddy | Current releases: `~/.workbuddy-ai/projects/**/*.jsonl`; legacy releases: `~/.workbuddy/projects/**/*.jsonl` (fixture/relocation override: `VIBE_USAGE_WORKBUDDY_DIRS`). Reads usage-bearing completed assistant and `function_call` records, using the routed model identifier exposed as `providerData.requestModelId`. Splits cache reads and reasoning from inclusive input/output totals, deduplicates copied record IDs, and extracts local session timing without uploading message content. |
82
82
  | ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`) |
83
83
 
@@ -93,6 +93,24 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
93
93
  - SQLite-backed tools are read via Node's built-in `node:sqlite` on Node ≥ 22.5 — no `sqlite3` binary needed (works on Windows out of the box); on older Node the CLI falls back to the system `sqlite3` executable
94
94
  - For continuous syncing, use `npx @vibe-cafe/vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
95
95
 
96
+ ## Cursor 网络排查
97
+
98
+ Cursor 用量需要从 `cursor.com` 下载 CSV。`Cursor usage export skipped (network: …)` 表示这次下载失败;CLI 会保留 Cursor 的历史同步状态,下次同步重新尝试。
99
+
100
+ - `ENOTFOUND` / `EAI_AGAIN`:检查 DNS 和终端网络。
101
+ - `UND_ERR_CONNECT_TIMEOUT` / `ETIMEDOUT` / `ECONNRESET`:检查到 Cursor 的连接及终端代理。浏览器或 Cursor 应用能联网,不代表 Node.js 使用了相同代理。
102
+ - `CERT_*` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE` 等:检查系统时间和代理或公司网络的 CA 证书配置;自定义 CA 可通过 `NODE_EXTRA_CA_CERTS` 指定。
103
+ - `timeout after …ms`:导出或下载超时,稍后重试;网络较慢时可设置 `VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS=60000`。
104
+ - `Cursor session rejected`:在 Cursor 的 Account 设置中重新登录,再同步。
105
+
106
+ 需要 HTTP/HTTPS 代理时,Node.js **22.21+ 或 24.5+** 可用 `NODE_USE_ENV_PROXY=1` 启用环境变量代理([Node.js 官方说明](https://nodejs.org/en/learn/http/enterprise-network-configuration))。以下为 macOS/Linux 终端示例,代理地址须替换为实际地址:
107
+
108
+ ```bash
109
+ HTTPS_PROXY=http://127.0.0.1:7890 NODE_USE_ENV_PROXY=1 npx @vibe-cafe/vibe-usage sync
110
+ ```
111
+
112
+ 这些环境变量只影响当前命令;已经运行的桌面应用和后台服务有各自的进程环境。持续失败时,请提供完整的 Cursor 报错、操作系统、`node -v` 和 CLI 版本,以便区分具体原因。
113
+
96
114
  ## Trust Model
97
115
 
98
116
  vibe-usage parses **local tool logs and local application state** on a machine the user fully controls. The reported data is self-reported telemetry — local logs, parsers, and upload requests can all be modified by the user.
@@ -158,7 +176,7 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`).
158
176
  | `apiUrl` | Server URL (default: `https://vibecafe.ai`) |
159
177
  | `hostname` | Stable device name for usage tracking (set at init, reused across syncs) |
160
178
  | `codexExtraHome` | Optional additional Codex Home scanned together with `$CODEX_HOME` / `~/.codex` |
161
- | `extraRoots` | Tool-specific additional roots managed by the commands below; currently supports `codex`, `grok`, and `antigravity` |
179
+ | `extraRoots` | Tool-specific additional roots managed by the commands below; currently supports `codex`, `grok`, `antigravity`, and `pi-coding-agent` |
162
180
 
163
181
  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:
164
182
 
@@ -179,6 +197,12 @@ npx @vibe-cafe/vibe-usage config add-root grok /path/to/grok-home
179
197
  # Antigravity expects an alternate HOME containing .gemini/antigravity*/conversations/.
180
198
  npx @vibe-cafe/vibe-usage config add-root antigravity /path/to/alternate-home
181
199
 
200
+ # Pi accepts a directory that holds session .jsonl files directly, or a Pi
201
+ # agent directory containing sessions/. Use this when a harness starts Pi with
202
+ # `pi --session <file>`: that path is recorded nowhere Pi's own settings can
203
+ # report, so it is invisible to discovery.
204
+ npx @vibe-cafe/vibe-usage config add-root pi-coding-agent /path/to/pi-sessions
205
+
182
206
  npx @vibe-cafe/vibe-usage config roots
183
207
  npx @vibe-cafe/vibe-usage config remove-root grok /path/to/grok-home
184
208
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.20",
3
+ "version": "0.10.22",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,9 +1,17 @@
1
- import { accessSync, constants, readdirSync, statSync } from 'node:fs';
1
+ import { accessSync, closeSync, constants, openSync, readSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join, resolve } from 'node:path';
4
4
  import { codexSessionDirs } from './codex-roots.js';
5
5
 
6
- export const EXTRA_ROOT_SOURCES = ['antigravity', 'codex', 'grok'];
6
+ export const EXTRA_ROOT_SOURCES = ['antigravity', 'codex', 'grok', 'pi-coding-agent'];
7
+
8
+ // Probing a candidate Pi store has three outcomes, never two: a confirmed
9
+ // session, a directory proven to hold none, and one that could not be read.
10
+ // Collapsing the last two into a single false is what let an unreadable subtree
11
+ // be treated as an empty one.
12
+ const PI_SESSIONS_FOUND = 'found';
13
+ const PI_SESSIONS_ABSENT = 'absent';
14
+ const PI_SESSIONS_UNREADABLE = 'unreadable';
7
15
 
8
16
  export function extraRootList(value) {
9
17
  return Array.isArray(value) ? value.filter(root => typeof root === 'string' && root.trim()) : [];
@@ -78,9 +86,198 @@ export function antigravityConversationDirs(value) {
78
86
  return [
79
87
  join(root, '.gemini', 'antigravity', 'conversations'),
80
88
  join(root, '.gemini', 'antigravity-cli', 'conversations'),
89
+ join(root, '.gemini', 'antigravity-ide', 'conversations'),
81
90
  ];
82
91
  }
83
92
 
93
+ // Pi's own discoverable settings (PI_CODING_AGENT_SESSION_DIR, `sessionDir` in
94
+ // settings.json) name a sessions directory directly, and a harness that calls
95
+ // `pi --session <file>` writes bare session trees with no agent home above
96
+ // them. Accept either shape, but resolve to exactly one directory per root:
97
+ // the parser walks nested directories, so returning both a root and its
98
+ // `sessions/` child would read every file twice. Canonical-path dedup in the
99
+ // parser makes the overlap harmless even for a mixed root that holds sessions
100
+ // directly *and* under `sessions/`.
101
+ //
102
+ // Every candidate is confirmed by content, never by name alone. A readable but
103
+ // unconfirmed `sessions/` child used to win unconditionally, so creating an
104
+ // empty `<root>/sessions` was enough to redirect the scan away from a bare
105
+ // store's own files — a successful sync reporting zero, which then pruned the
106
+ // source's incremental state.
107
+ //
108
+ // The shape is re-resolved on every run rather than remembered from add-root
109
+ // time, and an unresolvable root returns null instead of falling back to the
110
+ // root itself. Falling back would turn an agent home that lost its `sessions/`
111
+ // child into a readable directory holding no sessions, i.e. exactly the silent
112
+ // zero this feature exists to prevent.
113
+ //
114
+ // Probing is tri-state on purpose. A boolean collapsed "holds no sessions" into
115
+ // "could not be read", so making one sibling store unreadable was enough to
116
+ // make a container look like an agent home and narrow the scan to `sessions/`,
117
+ // dropping every readable sibling with no `skipped` flag. Absence has to be
118
+ // proven; where it is only assumed, resolution gives up and the caller skips.
119
+ export function piSessionsDir(value) {
120
+ const root = normalizeExtraRoot(value);
121
+ if (!isReadableDirectory(root)) return null;
122
+ // A bare store is identified by the session files it holds directly, and
123
+ // outranks any `sessions/` child: those files are what the user configured.
124
+ const direct = probePiSessions(root, 0);
125
+ if (direct === PI_SESSIONS_FOUND) return root;
126
+
127
+ const outside = probePiSessionsOutsideNested(root);
128
+ // Both decisions below rest on absence: that the root holds no sessions
129
+ // directly, and that no sibling of `sessions/` holds any. An unreadable
130
+ // candidate proves neither, so stop rather than narrow past it.
131
+ if (direct === PI_SESSIONS_UNREADABLE || outside === PI_SESSIONS_UNREADABLE) return null;
132
+
133
+ const nested = join(root, 'sessions');
134
+ // Agent-home shape: narrowing to the child is only safe when every confirmed
135
+ // session lives below it. A container whose per-task stores happen to include
136
+ // one named `sessions` is still a container, and resolving it to that child
137
+ // would drop all its siblings — the same silent undercount as above.
138
+ if (
139
+ outside === PI_SESSIONS_ABSENT
140
+ && isReadableDirectory(nested)
141
+ && probePiSessions(nested) === PI_SESSIONS_FOUND
142
+ ) {
143
+ return nested;
144
+ }
145
+ // A configured container holding per-task stores somewhere below it. Anything
146
+ // else — no sessions at all, or a subtree that could not be read — resolves
147
+ // to null, which the parser reports as skipped instead of as an empty sync.
148
+ return probePiSessions(root) === PI_SESSIONS_FOUND ? root : null;
149
+ }
150
+
151
+ // Confirmed sessions in some child other than `sessions/`. The per-child depth
152
+ // is one less than the root scan's own so both reach the same files.
153
+ function probePiSessionsOutsideNested(root) {
154
+ let children;
155
+ try {
156
+ children = readdirSync(root, { withFileTypes: true });
157
+ } catch {
158
+ return PI_SESSIONS_UNREADABLE;
159
+ }
160
+ let unreadable = false;
161
+ for (const child of children) {
162
+ if (!child.isDirectory() || child.name === 'sessions') continue;
163
+ const probe = probePiSessions(join(root, child.name), 1);
164
+ if (probe === PI_SESSIONS_FOUND) return PI_SESSIONS_FOUND;
165
+ if (probe === PI_SESSIONS_UNREADABLE) unreadable = true;
166
+ }
167
+ return unreadable ? PI_SESSIONS_UNREADABLE : PI_SESSIONS_ABSENT;
168
+ }
169
+
170
+ const PI_PROBE_BYTES = 16 * 1024;
171
+ const PI_PROBE_LINES = 10;
172
+ // Roles the Pi parser turns into events; anything else contributes nothing.
173
+ const PI_MESSAGE_ROLES = new Set(['user', 'assistant', 'toolResult']);
174
+
175
+ function isNonEmptyString(value) {
176
+ return typeof value === 'string' && value.trim() !== '';
177
+ }
178
+
179
+ // Pi's session format opens a store with a full SessionHeader. `version` is
180
+ // written as both a number and a string across real stores, so only its
181
+ // presence is required.
182
+ function isPiSessionHeader(obj) {
183
+ return obj.type === 'session'
184
+ && obj.version !== undefined
185
+ && obj.version !== null
186
+ && isNonEmptyString(obj.id)
187
+ && isNonEmptyString(obj.timestamp)
188
+ && typeof obj.cwd === 'string';
189
+ }
190
+
191
+ // A store an external harness appends to may carry no header inside the probed
192
+ // prefix, so a message record alone can confirm the directory — but only a
193
+ // complete one. Matching on `type` and an object-valued `message` accepted
194
+ // `{"type":"message","message":{}}`, which the parser reads to exactly zero.
195
+ function isPiSessionMessage(obj) {
196
+ return obj.type === 'message'
197
+ && isNonEmptyString(obj.id)
198
+ && 'parentId' in obj
199
+ && isNonEmptyString(obj.timestamp)
200
+ && Boolean(obj.message)
201
+ && typeof obj.message === 'object'
202
+ && PI_MESSAGE_ROLES.has(obj.message.role);
203
+ }
204
+
205
+ // A `.jsonl` extension proves nothing: an unrelated log would validate an
206
+ // entirely wrong directory, which the parser then ignores without complaining.
207
+ // Neither does a bare `type` name — validation has to require the fields the
208
+ // parser reads, or a malformed lookalike is accepted and still syncs zero.
209
+ // A file that cannot be opened is reported as unreadable, not as "not a
210
+ // session": it may well be the store the user configured.
211
+ function probePiSessionFile(filePath) {
212
+ let text;
213
+ let fd;
214
+ try {
215
+ fd = openSync(filePath, 'r');
216
+ const buffer = Buffer.alloc(PI_PROBE_BYTES);
217
+ const read = readSync(fd, buffer, 0, PI_PROBE_BYTES, 0);
218
+ text = buffer.subarray(0, read).toString('utf8');
219
+ // A prefix read can cut the final line in half; drop the partial tail.
220
+ if (read === PI_PROBE_BYTES) text = text.slice(0, text.lastIndexOf('\n') + 1);
221
+ } catch {
222
+ return PI_SESSIONS_UNREADABLE;
223
+ } finally {
224
+ if (fd !== undefined) {
225
+ try {
226
+ closeSync(fd);
227
+ } catch { /* already closed */ }
228
+ }
229
+ }
230
+
231
+ let checked = 0;
232
+ for (const line of text.split('\n')) {
233
+ if (!line.trim()) continue;
234
+ if (++checked > PI_PROBE_LINES) return PI_SESSIONS_ABSENT;
235
+ let obj;
236
+ try {
237
+ obj = JSON.parse(line);
238
+ } catch {
239
+ continue;
240
+ }
241
+ if (!obj || typeof obj !== 'object') continue;
242
+ if (isPiSessionHeader(obj) || isPiSessionMessage(obj)) return PI_SESSIONS_FOUND;
243
+ }
244
+ return PI_SESSIONS_ABSENT;
245
+ }
246
+
247
+ // A session store is only recognizable by the Pi session files in it. Stay
248
+ // shallow: a configured root may sit next to large unrelated trees.
249
+ //
250
+ // `found` outranks `unreadable`: one confirmed session is enough to resolve the
251
+ // shape, and the shared parser reports whatever it cannot read on the way in.
252
+ // `unreadable` outranks `absent`, so a caller never mistakes a subtree it could
253
+ // not open for one it proved empty.
254
+ function probePiSessions(dir, depth = 2) {
255
+ let children;
256
+ try {
257
+ children = readdirSync(dir, { withFileTypes: true });
258
+ } catch {
259
+ return PI_SESSIONS_UNREADABLE;
260
+ }
261
+ let unreadable = false;
262
+ // Dirent#isDirectory is false for symbolic links, matching the parser's own
263
+ // walk: linked session files are read, linked directories are not entered.
264
+ for (const child of children) {
265
+ if (child.isDirectory() || !child.name.endsWith('.jsonl')) continue;
266
+ const probe = probePiSessionFile(join(dir, child.name));
267
+ if (probe === PI_SESSIONS_FOUND) return PI_SESSIONS_FOUND;
268
+ if (probe === PI_SESSIONS_UNREADABLE) unreadable = true;
269
+ }
270
+ if (depth > 0) {
271
+ for (const child of children) {
272
+ if (!child.isDirectory()) continue;
273
+ const probe = probePiSessions(join(dir, child.name), depth - 1);
274
+ if (probe === PI_SESSIONS_FOUND) return PI_SESSIONS_FOUND;
275
+ if (probe === PI_SESSIONS_UNREADABLE) unreadable = true;
276
+ }
277
+ }
278
+ return unreadable ? PI_SESSIONS_UNREADABLE : PI_SESSIONS_ABSENT;
279
+ }
280
+
84
281
  export function validateExtraRoot(source, value) {
85
282
  if (!EXTRA_ROOT_SOURCES.includes(source)) {
86
283
  return { ok: false, path: value, reason: `不支持的工具: ${source}` };
@@ -94,6 +291,15 @@ export function validateExtraRoot(source, value) {
94
291
  reason: '需要是 Codex Home,或包含 */*/codex-home 的 Multica 容器',
95
292
  };
96
293
  }
294
+ if (source === 'pi-coding-agent') {
295
+ // piSessionsDir only returns a directory it has already confirmed by
296
+ // content, so there is nothing left to re-check here.
297
+ return {
298
+ ok: piSessionsDir(path) !== null,
299
+ path,
300
+ reason: '需要是直接包含 Pi 会话 .jsonl 的目录,或包含 sessions/ 的 Pi agent 目录',
301
+ };
302
+ }
97
303
  const dirs = source === 'grok'
98
304
  ? [grokSessionsDir(path)]
99
305
  : antigravityConversationDirs(path);
package/src/index.js CHANGED
@@ -121,7 +121,7 @@ function handleConfig(args) {
121
121
  const source = args[1];
122
122
  const value = args[2];
123
123
  if (!source || value === undefined) {
124
- console.error('Usage: vibe-usage config add-root <codex|grok|antigravity> <path>');
124
+ console.error(`Usage: vibe-usage config add-root <${EXTRA_ROOT_SOURCES.join('|')}> <path>`);
125
125
  process.exit(1);
126
126
  }
127
127
  const validation = validateExtraRoot(source, value);
@@ -142,7 +142,7 @@ function handleConfig(args) {
142
142
  const source = args[1];
143
143
  const value = args[2];
144
144
  if (!EXTRA_ROOT_SOURCES.includes(source) || value === undefined) {
145
- console.error('Usage: vibe-usage config remove-root <codex|grok|antigravity> <path>');
145
+ console.error(`Usage: vibe-usage config remove-root <${EXTRA_ROOT_SOURCES.join('|')}> <path>`);
146
146
  process.exit(1);
147
147
  }
148
148
  const config = loadConfig() || {};
@@ -297,7 +297,7 @@ export async function run(rawArgs) {
297
297
  npx @vibe-cafe/vibe-usage config get <key> Get a config value
298
298
  npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
299
299
  npx @vibe-cafe/vibe-usage config set codexExtraHome <path> Persist another Codex Home
300
- npx @vibe-cafe/vibe-usage config add-root <tool> <path> Add a Codex, Grok, or Antigravity data root
300
+ npx @vibe-cafe/vibe-usage config add-root <tool> <path> Add a Codex, Grok, Antigravity, or Pi data root
301
301
  npx @vibe-cafe/vibe-usage config remove-root <tool> <path> Remove an added data root
302
302
  npx @vibe-cafe/vibe-usage config roots Show added data roots as JSON
303
303
  npx @vibe-cafe/vibe-usage help Show this help
@@ -20,9 +20,8 @@ import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEv
20
20
  */
21
21
 
22
22
  const SOURCE = 'antigravity';
23
- const CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity', 'conversations');
24
- // `agy` CLI stores conversations in a separate, App-independent directory.
25
- const CLI_CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'conversations');
23
+ // App, CLI, and the standalone IDE each have their own conversation store.
24
+ const CONVERSATIONS_DIRS = antigravityConversationDirs(homedir());
26
25
 
27
26
  // User sources → role 'user'; Model source → role 'assistant'; System sources → skip
28
27
  const USER_SOURCES = new Set([
@@ -33,24 +32,25 @@ const ASSISTANT_SOURCES = new Set([
33
32
  'CORTEX_STEP_SOURCE_MODEL',
34
33
  ]);
35
34
 
36
- // ── Process discovery (single instance) ──────────────────────────────
35
+ // ── Process discovery ───────────────────────────────────────────────
37
36
 
38
37
  const IS_WIN = process.platform === 'win32';
39
38
 
40
39
  /**
41
- * Find ONE running language server process with a CSRF token.
42
- * Returns { pid, csrfToken } or null.
40
+ * Find running language servers with CSRF tokens. The App and standalone IDE
41
+ * can run together, and one server cannot read the other's legacy cascades.
43
42
  */
44
- function findLanguageServer() {
43
+ function findLanguageServers() {
45
44
  try {
46
- return IS_WIN ? findLanguageServerWin() : findLanguageServerUnix();
45
+ return IS_WIN ? findLanguageServersWin() : findLanguageServersUnix();
47
46
  } catch {
48
- return null;
47
+ return [];
49
48
  }
50
49
  }
51
50
 
52
- function findLanguageServerUnix() {
51
+ function findLanguageServersUnix() {
53
52
  const out = execSync("ps aux | grep -i 'antigravity.*language_server'", { encoding: 'utf-8', timeout: 5000 });
53
+ const servers = [];
54
54
  for (const line of out.split('\n')) {
55
55
  if (!line.trim()) continue;
56
56
  if (line.includes('grep')) continue;
@@ -59,19 +59,19 @@ function findLanguageServerUnix() {
59
59
  const pid = parts[1];
60
60
  const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]+)/);
61
61
  const csrfToken = csrfMatch ? csrfMatch[1] : '';
62
- if (csrfToken) return { pid, csrfToken };
62
+ if (csrfToken) servers.push({ pid, csrfToken });
63
63
  }
64
- return null;
64
+ return servers;
65
65
  }
66
66
 
67
- function findLanguageServerWin() {
67
+ function findLanguageServersWin() {
68
68
  // Prefer PowerShell/CIM: wmic is disabled by default on Windows 11 23H2+
69
69
  // and removed entirely from 25H2 onward. Fall back to wmic for old/stripped
70
70
  // environments without PowerShell. Each probe is independently time-boxed and
71
71
  // failures are swallowed, so a missing/hung tool never blocks the next one or
72
72
  // the parsers that run after antigravity.
73
73
  const out = queryProcessesWinPowerShell() ?? queryProcessesWinWmic();
74
- if (!out) return null;
74
+ if (!out) return [];
75
75
  return parseWinProcessList(out);
76
76
  }
77
77
 
@@ -117,20 +117,20 @@ function queryProcessesWinWmic() {
117
117
 
118
118
  /**
119
119
  * Parse "ProcessId=..." / "CommandLine=..." records (from either PowerShell or
120
- * wmic /format:list) and return the first language_server that carries a
121
- * --csrf_token, or null. PowerShell emits an explicit "---" separator per
120
+ * wmic /format:list) and return each language_server that carries a
121
+ * --csrf_token. PowerShell emits an explicit "---" separator per
122
122
  * process; wmic does not and may emit the two fields in either order, so a
123
123
  * record also ends whenever a field we've already captured reappears.
124
124
  */
125
- function parseWinProcessList(out) {
125
+ export function parseWinProcessList(out) {
126
+ const servers = [];
126
127
  let pid = '';
127
128
  let cmdLine = '';
128
129
  const finish = () => {
129
130
  if (pid && cmdLine && !/WMIC\.exe|powershell\.exe|pwsh\.exe/i.test(cmdLine)) {
130
131
  const csrfMatch = cmdLine.match(/--csrf_token\s+([0-9a-f-]+)/);
131
- if (csrfMatch) return { pid, csrfToken: csrfMatch[1] };
132
+ if (csrfMatch) servers.push({ pid, csrfToken: csrfMatch[1] });
132
133
  }
133
- return null;
134
134
  };
135
135
  const reset = () => { pid = ''; cmdLine = ''; };
136
136
  for (const line of out.split('\n')) {
@@ -140,14 +140,14 @@ function parseWinProcessList(out) {
140
140
  // Record boundary: explicit "---", or a field that would overwrite one we
141
141
  // already hold (next process began without a separator, e.g. wmic output).
142
142
  if (trimmed === '---' || (isPid && pid) || (isCmd && cmdLine)) {
143
- const found = finish();
144
- if (found) return found;
143
+ finish();
145
144
  reset();
146
145
  }
147
146
  if (isPid) pid = trimmed.slice('ProcessId='.length);
148
147
  else if (isCmd) cmdLine = trimmed.slice('CommandLine='.length);
149
148
  }
150
- return finish();
149
+ finish();
150
+ return servers;
151
151
  }
152
152
 
153
153
  function findListeningPorts(pid) {
@@ -296,7 +296,7 @@ function projectFromUri(uri) {
296
296
  * List cascade IDs backed by a legacy `.pb` file (App history). `.db` cascades
297
297
  * are handled separately via offline parsing.
298
298
  */
299
- function listPbCascades(conversationsDir = CONVERSATIONS_DIR) {
299
+ function listPbCascades(conversationsDir) {
300
300
  try {
301
301
  const out = [];
302
302
  for (const f of readdirSync(conversationsDir)) {
@@ -320,6 +320,7 @@ function modelFromRecord(rec) {
320
320
  export async function parse({ extraRoots = [] } = {}) {
321
321
  const entries = [];
322
322
  const sessionEvents = [];
323
+ const warnings = [];
323
324
  const seenResponseIds = new Set();
324
325
 
325
326
  const extraDirs = [];
@@ -356,7 +357,7 @@ export async function parse({ extraRoots = [] } = {}) {
356
357
  const fixtureDirs = process.env.VIBE_USAGE_ANTIGRAVITY_DIRS?.trim();
357
358
  const defaultDirs = fixtureDirs
358
359
  ? fixtureDirs.split(delimiter).filter(Boolean)
359
- : [CONVERSATIONS_DIR, CLI_CONVERSATIONS_DIR];
360
+ : CONVERSATIONS_DIRS;
360
361
  const strictDirs = new Set(extraDirs);
361
362
  const conversationDirs = [...new Set([...defaultDirs, ...extraDirs])];
362
363
  const candidates = [];
@@ -444,13 +445,14 @@ export async function parse({ extraRoots = [] } = {}) {
444
445
  }
445
446
 
446
447
  // ── Path 2: RPC fallback, only for legacy .pb cascades not already parsed ──
447
- const pbDir = defaultDirs[0] || CONVERSATIONS_DIR;
448
- const pbCascades = listPbCascades(pbDir).filter((id) => !dbHandled.has(id));
448
+ const pbCascades = [...new Set(defaultDirs.flatMap(listPbCascades))]
449
+ .filter((id) => !dbHandled.has(id));
449
450
  if (pbCascades.length > 0) {
450
- const server = findLanguageServer();
451
- const ports = server ? findListeningPorts(server.pid) : [];
452
- const baseUrl = ports.length > 0 ? await probeHttpPort(ports, server.csrfToken) : null;
453
- if (baseUrl) {
451
+ const pending = new Set(pbCascades);
452
+ for (const server of findLanguageServers()) {
453
+ const ports = findListeningPorts(server.pid);
454
+ const baseUrl = ports.length > 0 ? await probeHttpPort(ports, server.csrfToken) : null;
455
+ if (!baseUrl) continue;
454
456
  const rpc = (method, body) =>
455
457
  rpcPost(
456
458
  baseUrl,
@@ -459,7 +461,7 @@ export async function parse({ extraRoots = [] } = {}) {
459
461
  server.csrfToken,
460
462
  );
461
463
 
462
- for (const cascadeId of pbCascades) {
464
+ for (const cascadeId of pending) {
463
465
  let resp;
464
466
  try {
465
467
  resp = await rpc('GetCascadeTrajectory', { cascadeId });
@@ -468,6 +470,7 @@ export async function parse({ extraRoots = [] } = {}) {
468
470
  }
469
471
  const trajectory = resp?.trajectory;
470
472
  if (!trajectory) continue;
473
+ pending.delete(cascadeId);
471
474
 
472
475
  const steps = trajectory.steps || [];
473
476
  const metadataList = trajectory.generatorMetadata || [];
@@ -519,11 +522,16 @@ export async function parse({ extraRoots = [] } = {}) {
519
522
  sessionEvents.push({ sessionId: cascadeId, source: SOURCE, project, timestamp: ts, role });
520
523
  }
521
524
  }
525
+ if (pending.size === 0) break;
526
+ }
527
+ if (pending.size > 0) {
528
+ warnings.push(`antigravity: ${pending.size} 个旧格式会话未能读取,请打开对应的 Antigravity IDE/App 后重新同步;已保留上次同步状态。`);
522
529
  }
523
530
  }
524
531
 
525
532
  return {
526
533
  buckets: aggregateToBuckets(entries),
527
534
  sessions: extractSessions(sessionEvents),
535
+ ...(warnings.length > 0 ? { skipped: true, warnings } : {}),
528
536
  };
529
537
  }
@@ -82,6 +82,41 @@ export function resolveCursorFetchTimeout(value) {
82
82
 
83
83
  const FETCH_TIMEOUT_MS = resolveCursorFetchTimeout(process.env.VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS);
84
84
 
85
+ function usageNetworkError(error, signal) {
86
+ // Node fetch usually exposes only "fetch failed" at the top level. DNS,
87
+ // TLS and connection codes live in cause (or AggregateError.errors when
88
+ // both IPv4 and IPv6 fail). Report codes, not raw messages that may contain
89
+ // URLs with proxy credentials or authentication headers.
90
+ const codes = new Set();
91
+ const seen = new Set();
92
+ function visit(err) {
93
+ if (!err || typeof err !== 'object' || seen.has(err)) return;
94
+ seen.add(err);
95
+ if (typeof err.code === 'string' && /^[A-Z][A-Z0-9_]{1,63}$/.test(err.code)) {
96
+ codes.add(err.code);
97
+ }
98
+ visit(err.cause);
99
+ if (Array.isArray(err.errors)) err.errors.forEach(visit);
100
+ }
101
+ visit(error);
102
+
103
+ const timedOut = error?.name === 'TimeoutError' || signal.reason?.name === 'TimeoutError';
104
+ const reason = timedOut
105
+ ? `timeout after ${FETCH_TIMEOUT_MS}ms`
106
+ : `network: ${[...codes].join(', ') || 'fetch failed'}`;
107
+ let hint = '请检查终端网络和代理配置;浏览器能访问 Cursor 不代表 Node.js 使用了相同代理。';
108
+ if (timedOut) {
109
+ hint = 'Cursor 用量导出超时,请稍后重试;可通过 VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS 增大导出等待时间。';
110
+ } else if (codes.has('ENOTFOUND') || codes.has('EAI_AGAIN')) {
111
+ hint = '域名解析失败,请检查 DNS 和终端代理配置。';
112
+ } else if ([...codes].some(code => /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS_/.test(code))) {
113
+ hint = 'TLS 证书校验失败,请检查系统时间及代理或公司网络的 CA 证书配置。';
114
+ }
115
+ const err = new Error(`Cursor usage export skipped (${reason}). ${hint}`);
116
+ err.skip = true;
117
+ return err;
118
+ }
119
+
85
120
  async function fetchUsageCsv(token) {
86
121
  const url = `${(process.env.CURSOR_WEB_BASE_URL?.trim() || 'https://cursor.com').replace(/\/+$/, '')}/api/dashboard/export-usage-events-csv?strategy=tokens`;
87
122
  const sub = decodeJwtSub(token);
@@ -110,20 +145,19 @@ async function fetchUsageCsv(token) {
110
145
  const failures = [];
111
146
  for (const headers of attempts) {
112
147
  let resp;
148
+ const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
113
149
  try {
114
150
  resp = await fetch(url, {
115
151
  headers: { ...baseHeaders, ...headers },
116
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
152
+ signal,
117
153
  });
154
+ // The export may time out or disconnect after headers have arrived.
155
+ // Keep body consumption under the same deadline and soft-skip handling.
156
+ if (resp.ok) return await resp.text();
118
157
  } catch (e) {
119
- // Hard-fail on network/timeout: stop trying further headers (won't fix
120
- // a downed host) and signal a soft skip to the caller.
121
- const reason = e.name === 'TimeoutError' ? 'timeout' : `network: ${e.message}`;
122
- const err = new Error(`Cursor usage export skipped (${reason})`);
123
- err.skip = true;
124
- throw err;
158
+ // Changing credentials cannot repair a network failure.
159
+ throw usageNetworkError(e, signal);
125
160
  }
126
- if (resp.ok) return await resp.text();
127
161
  failures.push(`${resp.status} ${resp.statusText}`);
128
162
  // Only auth rejections are worth retrying with different credentials.
129
163
  // 429/5xx are transient server-side states — soft-skip like network errors
@@ -198,7 +232,7 @@ export async function parse() {
198
232
  try {
199
233
  csv = await fetchUsageCsv(token);
200
234
  } catch (err) {
201
- // Network/timeout → silent skip (avoid noisy daemon logs every 5 min).
235
+ // Network/timeout → skip with diagnostics (quiet sync suppresses these).
202
236
  // Auth failure → bubble up so user sees they need to re-login in Cursor.
203
237
  // Tell sync.js this was not a successful empty snapshot so it preserves
204
238
  // Cursor's incremental state instead of pruning it as dead history.
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { toCount } from './fs-utils.js';
5
6
  import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
7
 
7
8
  const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
@@ -26,6 +27,8 @@ export async function parse() {
26
27
  for (const { path: dbPath, profile } of dbs) {
27
28
  let sessionRows;
28
29
  try {
30
+ const columns = new Set(queryDb(dbPath, 'PRAGMA table_info(sessions)').map(row => row.name));
31
+ const cacheWriteColumn = columns.has('cache_write_tokens') ? 'cache_write_tokens' : '0';
29
32
  sessionRows = queryDb(dbPath, `SELECT
30
33
  id,
31
34
  model,
@@ -33,9 +36,11 @@ export async function parse() {
33
36
  input_tokens as inputTokens,
34
37
  output_tokens as outputTokens,
35
38
  cache_read_tokens as cacheReadTokens,
39
+ ${cacheWriteColumn} as cacheWriteTokens,
36
40
  reasoning_tokens as reasoningTokens
37
41
  FROM sessions
38
- WHERE input_tokens > 0 OR output_tokens > 0`);
42
+ WHERE input_tokens > 0 OR output_tokens > 0
43
+ OR cache_read_tokens > 0 OR ${cacheWriteColumn} > 0 OR reasoning_tokens > 0`);
39
44
  } catch (err) {
40
45
  if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Hermes');
41
46
  throw err;
@@ -47,15 +52,19 @@ export async function parse() {
47
52
  if (isNaN(timestamp.getTime())) continue;
48
53
 
49
54
  // Hermes stores input_tokens exclusive of cache (Anthropic-style semantics)
55
+ // and output_tokens inclusive of reasoning (CanonicalUsage.total_tokens
56
+ // adds prompt + output only). Split reasoning instead of counting it twice.
57
+ const output = toCount(row.outputTokens);
58
+ const reasoning = Math.min(output, toCount(row.reasoningTokens));
50
59
  entries.push({
51
60
  source: 'hermes',
52
61
  model: row.model || 'unknown',
53
62
  project: profile,
54
63
  timestamp,
55
- inputTokens: row.inputTokens || 0,
56
- outputTokens: row.outputTokens || 0,
57
- cachedInputTokens: row.cacheReadTokens || 0,
58
- reasoningOutputTokens: row.reasoningTokens || 0,
64
+ inputTokens: toCount(row.inputTokens) + toCount(row.cacheWriteTokens),
65
+ outputTokens: output - reasoning,
66
+ cachedInputTokens: toCount(row.cacheReadTokens),
67
+ reasoningOutputTokens: reasoning,
59
68
  });
60
69
  }
61
70
 
@@ -1,12 +1,27 @@
1
+ import { normalizeExtraRoot, piSessionsDir } from '../extra-roots.js';
1
2
  import { getPiSessionDirs } from '../pi-roots.js';
2
3
  import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
3
4
  import { parsePiSessionJsonl } from './pi-session-jsonl.js';
4
5
 
5
6
  /** Parse the official Pi agent's Pi-compatible JSONL sessions. */
6
- export async function parse() {
7
+ export async function parse({ extraRoots = [] } = {}) {
8
+ for (const root of extraRoots) {
9
+ // piSessionsDir re-resolves the root's shape, so an agent home that lost
10
+ // its `sessions/` child is caught here too, not just a root that vanished.
11
+ if (piSessionsDir(root) !== null) continue;
12
+ // An explicitly configured root that is momentarily unreadable is not
13
+ // proof that its usage disappeared, so skip instead of reporting empty.
14
+ return {
15
+ buckets: [],
16
+ sessions: [],
17
+ skipped: true,
18
+ warnings: [`pi-coding-agent: 额外根目录不可用,已跳过本次 Pi 同步: ${normalizeExtraRoot(root)}`],
19
+ };
20
+ }
21
+
7
22
  const nativeResult = await parsePiSessionJsonl({
8
23
  source: 'pi-coding-agent',
9
- sessionsDirs: getPiSessionDirs(),
24
+ sessionsDirs: getPiSessionDirs(extraRoots),
10
25
  });
11
26
  return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('pi'));
12
27
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { basename, join, relative } from 'node:path';
3
3
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
4
  import { projectFromCwd, toCount } from './fs-utils.js';
@@ -38,6 +38,19 @@ export function projectFromFirstDir(filePath, sessionsDir) {
38
38
  return first.split('-').filter(Boolean).at(-1) || 'unknown';
39
39
  }
40
40
 
41
+ // Configured stores can overlap: an ancestor and its descendant, or two paths
42
+ // that resolve to the same place through a symlink. Record-level dedup only
43
+ // covers entries carrying an `id`, so the same anonymous record would be
44
+ // counted once per path that reaches it. Collapse on the canonical file path
45
+ // instead, which also folds symlinked duplicates of a single file.
46
+ function canonicalFilePath(filePath) {
47
+ try {
48
+ return realpathSync.native(filePath);
49
+ } catch {
50
+ return filePath;
51
+ }
52
+ }
53
+
41
54
  export async function parsePiSessionJsonl({
42
55
  source,
43
56
  sessionsDirs,
@@ -49,9 +62,14 @@ export async function parsePiSessionJsonl({
49
62
  const anonymousEntries = [];
50
63
  const eventsById = new Map();
51
64
  const anonymousEvents = [];
65
+ const seenFiles = new Set();
52
66
 
53
67
  for (const sessionsDir of sessionsDirs) {
54
68
  for (const filePath of findJsonlFiles(sessionsDir, includeFile, ctx)) {
69
+ const canonical = canonicalFilePath(filePath);
70
+ if (seenFiles.has(canonical)) continue;
71
+ seenFiles.add(canonical);
72
+
55
73
  let content;
56
74
  try {
57
75
  content = readFileSync(filePath, 'utf8');
package/src/pi-roots.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { delimiter, isAbsolute, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { piSessionsDir } from './extra-roots.js';
4
5
 
5
6
  function expandHome(value) {
6
7
  const trimmed = value.trim();
@@ -60,22 +61,30 @@ function settingsSessionDir(agentDir) {
60
61
  }
61
62
  }
62
63
 
63
- export function getPiSessionDirs() {
64
+ export function getPiSessionDirs(extraRoots = []) {
65
+ // Explicitly configured roots are user intent, not a fixture: they are always
66
+ // scanned, and they never replace the default store. A root whose shape no
67
+ // longer resolves drops out here; the parser reports that as skipped.
68
+ const extraDirs = extraRoots.map(piSessionsDir).filter(dir => dir !== null);
69
+
64
70
  const override = process.env.VIBE_USAGE_PI_SESSION_DIRS?.trim();
65
- if (override) return uniqueExistingDirs(override.split(delimiter));
71
+ if (override) return uniqueExistingDirs([...override.split(delimiter), ...extraDirs]);
66
72
 
67
73
  const envAgentDir = process.env.PI_CODING_AGENT_DIR?.trim();
68
74
  const agentDir = envAgentDir ? expandHome(envAgentDir) : join(homedir(), '.pi', 'agent');
69
75
  // OMP inherits PI_CODING_AGENT_DIR from Pi. Do not parse an identifiable
70
76
  // OMP store again as source=pi-coding-agent.
71
- if (envAgentDir && looksLikeOmpAgentDir(agentDir)) return [];
77
+ const isOmpStore = Boolean(envAgentDir) && looksLikeOmpAgentDir(agentDir);
72
78
 
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);
79
+ const dirs = [];
80
+ if (!isOmpStore) {
81
+ dirs.push(join(agentDir, 'sessions'));
82
+ const envSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
83
+ if (envSessionDir) dirs.push(expandHome(envSessionDir));
84
+ const configured = settingsSessionDir(agentDir);
85
+ if (configured) dirs.push(configured);
86
+ }
87
+ return uniqueExistingDirs([...dirs, ...extraDirs]);
79
88
  }
80
89
 
81
90
  export function getOmpSessionDirs() {
package/src/tools.js CHANGED
@@ -174,8 +174,7 @@ export function getMimocodeDbPath(env = process.env) {
174
174
 
175
175
  export function findAntigravityDataDirs(extraRoots = []) {
176
176
  return [...new Set([
177
- join(homedir(), '.gemini', 'antigravity'),
178
- join(homedir(), '.gemini', 'antigravity-cli'),
177
+ ...antigravityConversationDirs(homedir()).map(dirname),
179
178
  ...extraRoots.flatMap(root => antigravityConversationDirs(root).map(dirname)),
180
179
  ])].filter(existsSync);
181
180
  }
@@ -320,7 +319,9 @@ export const TOOLS = [
320
319
  name: 'pi',
321
320
  id: 'pi-coding-agent',
322
321
  dataDir: join(homedir(), '.pi', 'agent', 'sessions'),
323
- detectDataDirs: findPiDataDirs,
322
+ detectDataDirs: ({ extraRoots } = {}) => (
323
+ findPiDataDirs(extraRootList(extraRoots?.['pi-coding-agent']))
324
+ ),
324
325
  },
325
326
  {
326
327
  name: 'Qwen Code',