@vibe-cafe/vibe-usage 0.10.19 → 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.
- package/README.md +27 -6
- package/package.json +1 -1
- package/src/daemon-service.js +347 -7
- package/src/extra-roots.js +107 -0
- package/src/index.js +70 -3
- package/src/init.js +6 -2
- package/src/parsers/antigravity-db.js +9 -8
- package/src/parsers/antigravity.js +88 -11
- package/src/parsers/codex.js +62 -11
- package/src/parsers/cursor.js +21 -2
- package/src/parsers/grok.js +102 -18
- package/src/parsers/index.js +2 -0
- package/src/parsers/mcode.js +182 -0
- package/src/parsers/pi-session-jsonl.js +4 -1
- package/src/pi-roots.js +33 -10
- package/src/sync.js +14 -3
- package/src/tools.js +43 -11
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ npx @vibe-cafe/vibe-usage sync --extra-codex-home /path/to/.codex # Add another
|
|
|
35
35
|
npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
|
|
36
36
|
npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
|
|
37
37
|
npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
|
|
38
|
-
npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd)
|
|
38
|
+
npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd/Task Scheduler)
|
|
39
39
|
npx @vibe-cafe/vibe-usage daemon uninstall # Remove background service
|
|
40
40
|
npx @vibe-cafe/vibe-usage daemon status # Show background service status
|
|
41
41
|
npx @vibe-cafe/vibe-usage daemon stop # Stop background service
|
|
@@ -54,8 +54,8 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
54
54
|
| Alma | Electron app-data `alma/chat_threads.db` (macOS: `~/Library/Application Support/alma/chat_threads.db`; fixture/relocation override: `VIBE_USAGE_ALMA_DB`). Reads the `usage_records` ledger plus workspace names without selecting chat bodies, message metadata, provider credentials, or full workspace paths. Provider-prefixed model identifiers are normalized to their final model segment. Cache writes are included in input usage. The ledger contains assistant responses only, so Alma emits token buckets without session timing. |
|
|
55
55
|
| 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. |
|
|
56
56
|
| Cindy | Per-owner SQLite ledgers in the two regional Electron user-data roots: macOS `~/Library/Application Support/{CindyGlobal,Cindy}/cindy-*.db`, Windows `%APPDATA%\{CindyGlobal,Cindy}\cindy-*.db`, Linux `${XDG_CONFIG_HOME:-~/.config}/{CindyGlobal,Cindy}/cindy-*.db` (fixture/relocation override: `VIBE_USAGE_CINDY_DIRS`). Cindy-launched Claude Code already writes ordinary `~/.claude` transcripts, so it remains attributed to **Claude Code** and is not read again. Cindy's otherwise-private Codex and Pi daily/model ledger rows augment the existing **Codex** and **pi** sources. Currency rows are summed and cache creation joins input; chat messages, credentials, costs, and owner ids are never selected. The ledger adds token buckets only, without project or session timing. |
|
|
57
|
-
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home` or
|
|
58
|
-
| 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;
|
|
57
|
+
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home`, legacy `codexExtraHome`, or explicitly added Codex/Multica roots; 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 |
|
|
58
|
+
| Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`) plus explicitly added Grok Homes; token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; copied sessions keep the more complete local record |
|
|
59
59
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
60
60
|
| CraftAgent | `~/.craft-agent/workspaces/*/sessions/*/.pi-sessions/*.jsonl`; honors `$CRAFT_AGENT_DIR` / `$CRAFTAGENT_DIR`; cache writes are included in input usage |
|
|
61
61
|
| 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 |
|
|
@@ -64,9 +64,10 @@ 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
|
|
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 |
|
|
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
|
+
| 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. |
|
|
70
71
|
| 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) |
|
|
71
72
|
| Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
|
|
72
73
|
| Droid | `~/.factory/sessions/` |
|
|
@@ -76,7 +77,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
76
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 |
|
|
77
78
|
| Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
|
|
78
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. |
|
|
79
|
-
| Antigravity | App 2.0 `~/.gemini/antigravity/conversations/*.db` and `agy` CLI `~/.gemini/antigravity-cli/conversations/*.db
|
|
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
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. |
|
|
81
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`) |
|
|
82
83
|
|
|
@@ -157,6 +158,7 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`).
|
|
|
157
158
|
| `apiUrl` | Server URL (default: `https://vibecafe.ai`) |
|
|
158
159
|
| `hostname` | Stable device name for usage tracking (set at init, reused across syncs) |
|
|
159
160
|
| `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` |
|
|
160
162
|
|
|
161
163
|
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:
|
|
162
164
|
|
|
@@ -164,6 +166,25 @@ The `hostname` is captured once during `init` and reused for all future syncs. T
|
|
|
164
166
|
npx @vibe-cafe/vibe-usage config set hostname my-device-name
|
|
165
167
|
```
|
|
166
168
|
|
|
169
|
+
Add isolated runtime data without editing JSON by hand:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# Codex accepts either one direct Codex Home or a Multica container whose
|
|
173
|
+
# <workspace>/<task>/codex-home directories are at most three levels below it.
|
|
174
|
+
npx @vibe-cafe/vibe-usage config add-root codex /path/to/multica-container
|
|
175
|
+
|
|
176
|
+
# Grok expects a Grok Home containing sessions/.
|
|
177
|
+
npx @vibe-cafe/vibe-usage config add-root grok /path/to/grok-home
|
|
178
|
+
|
|
179
|
+
# Antigravity expects an alternate HOME containing .gemini/antigravity*/conversations/.
|
|
180
|
+
npx @vibe-cafe/vibe-usage config add-root antigravity /path/to/alternate-home
|
|
181
|
+
|
|
182
|
+
npx @vibe-cafe/vibe-usage config roots
|
|
183
|
+
npx @vibe-cafe/vibe-usage config remove-root grok /path/to/grok-home
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Default roots are always scanned and existing `codexExtraHome` configurations remain valid. Additional roots are only scanned after they are explicitly added. If a configured root later becomes unavailable, that tool is skipped for the current sync so its incremental upload state is not pruned.
|
|
187
|
+
|
|
167
188
|
## Daemon Mode
|
|
168
189
|
|
|
169
190
|
### Background service (recommended)
|
|
@@ -174,7 +195,7 @@ Install as a system service for automatic background syncing:
|
|
|
174
195
|
npx @vibe-cafe/vibe-usage daemon install
|
|
175
196
|
```
|
|
176
197
|
|
|
177
|
-
This creates a user-level service (systemd on Linux, launchd on macOS) that syncs every 30 minutes and starts automatically on login. Manage with:
|
|
198
|
+
This creates a user-level service (systemd on Linux, launchd on macOS, Task Scheduler on Windows — no admin rights needed) that syncs every 30 minutes and starts automatically on login. Manage with:
|
|
178
199
|
|
|
179
200
|
```bash
|
|
180
201
|
npx @vibe-cafe/vibe-usage daemon status
|
package/package.json
CHANGED
package/src/daemon-service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { writeFileSync, readFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { homedir, platform } from 'node:os';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -17,6 +17,9 @@ function detectPlatform() {
|
|
|
17
17
|
if (os === 'darwin') {
|
|
18
18
|
return 'launchd';
|
|
19
19
|
}
|
|
20
|
+
if (os === 'win32') {
|
|
21
|
+
return 'taskscheduler';
|
|
22
|
+
}
|
|
20
23
|
return null;
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -25,8 +28,9 @@ function resolvePaths() {
|
|
|
25
28
|
const thisFile = fileURLToPath(import.meta.url);
|
|
26
29
|
const binPath = join(thisFile, '..', '..', 'bin', 'vibe-usage.js');
|
|
27
30
|
|
|
28
|
-
// npx cache paths are unstable — service will break when cache is cleared
|
|
29
|
-
|
|
31
|
+
// npx cache paths are unstable — service will break when cache is cleared.
|
|
32
|
+
// POSIX: ~/.npm/_npx/<hash>/...; Windows: %LocalAppData%\npm-cache\_npx\...
|
|
33
|
+
const isNpxCache = /[\\/]_npx[\\/]/.test(binPath);
|
|
30
34
|
|
|
31
35
|
return { nodePath, binPath, isNpxCache };
|
|
32
36
|
}
|
|
@@ -40,6 +44,15 @@ function getServicePaths(plat) {
|
|
|
40
44
|
const dir = join(homedir(), 'Library', 'LaunchAgents');
|
|
41
45
|
return { dir, file: join(dir, `${LAUNCHD_LABEL}.plist`) };
|
|
42
46
|
}
|
|
47
|
+
if (plat === 'taskscheduler') {
|
|
48
|
+
const dir = join(homedir(), '.vibe-usage');
|
|
49
|
+
return {
|
|
50
|
+
dir,
|
|
51
|
+
file: join(dir, 'daemon-task.xml'),
|
|
52
|
+
cmd: join(dir, 'daemon-task.cmd'),
|
|
53
|
+
vbs: join(dir, 'daemon-task.vbs'),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
43
56
|
return null;
|
|
44
57
|
}
|
|
45
58
|
|
|
@@ -47,6 +60,15 @@ function escapeSystemdEnvironment(value) {
|
|
|
47
60
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
48
61
|
}
|
|
49
62
|
|
|
63
|
+
// In `set "KEY=VALUE"`, cmd expands %VAR% on the line, so literal percents must double
|
|
64
|
+
function escapeCmdValue(value) {
|
|
65
|
+
return value.replace(/%/g, '%%');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function psString(value) {
|
|
69
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
function escapeXml(value) {
|
|
51
73
|
return value
|
|
52
74
|
.replace(/&/g, '&')
|
|
@@ -56,7 +78,17 @@ function escapeXml(value) {
|
|
|
56
78
|
.replace(/'/g, ''');
|
|
57
79
|
}
|
|
58
80
|
|
|
59
|
-
|
|
81
|
+
// Variables that relocate a tool's on-disk store. The service runs from a
|
|
82
|
+
// launchd/systemd unit that inherits nothing, so anything the parsers read for
|
|
83
|
+
// discovery has to be captured into the unit at install time.
|
|
84
|
+
const PRESERVED_SERVICE_ENV = [
|
|
85
|
+
'MCODE_HOME',
|
|
86
|
+
'MIMOCODE_HOME',
|
|
87
|
+
'MIMOCODE_DB',
|
|
88
|
+
'XDG_DATA_HOME',
|
|
89
|
+
'PI_CODING_AGENT_DIR',
|
|
90
|
+
'PI_CODING_AGENT_SESSION_DIR',
|
|
91
|
+
];
|
|
60
92
|
|
|
61
93
|
function serviceEnvironment(claudeConfigDir, env) {
|
|
62
94
|
const values = {
|
|
@@ -134,11 +166,88 @@ ${environment} </dict>
|
|
|
134
166
|
`;
|
|
135
167
|
}
|
|
136
168
|
|
|
169
|
+
export function generateWindowsTaskCmd(
|
|
170
|
+
nodePath,
|
|
171
|
+
binPath,
|
|
172
|
+
claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim(),
|
|
173
|
+
env = process.env,
|
|
174
|
+
) {
|
|
175
|
+
const logPath = join(homedir(), '.vibe-usage', 'daemon.log');
|
|
176
|
+
const lines = [
|
|
177
|
+
'@echo off',
|
|
178
|
+
'rem Generated by `vibe-usage daemon install` - reinstalling overwrites this file',
|
|
179
|
+
'set "NODE_ENV=production"',
|
|
180
|
+
];
|
|
181
|
+
for (const [key, value] of serviceEnvironment(claudeConfigDir, env)) {
|
|
182
|
+
lines.push(`set "${key}=${escapeCmdValue(value)}"`);
|
|
183
|
+
}
|
|
184
|
+
lines.push(
|
|
185
|
+
`"${escapeCmdValue(nodePath)}" "${escapeCmdValue(binPath)}" daemon >> "${escapeCmdValue(logPath)}" 2>&1`,
|
|
186
|
+
);
|
|
187
|
+
return lines.join('\r\n') + '\r\n';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function generateWindowsTaskVbs(cmdPath) {
|
|
191
|
+
const lines = [
|
|
192
|
+
"' Vibe Usage daemon launcher - window style 0 keeps the logon session console-free;",
|
|
193
|
+
"' waitOnReturn=True keeps the task's Running state true to the daemon's lifetime",
|
|
194
|
+
"' (so MultipleInstancesPolicy can veto a second start while one daemon lives)",
|
|
195
|
+
`CreateObject("WScript.Shell").Run """${cmdPath}""", 0, True`,
|
|
196
|
+
];
|
|
197
|
+
return lines.join('\r\n') + '\r\n';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Task Scheduler kills tasks after its default 72h execution limit unless the XML
|
|
201
|
+
// pins ExecutionTimeLimit to PT0S — the daemon is meant to run indefinitely.
|
|
202
|
+
// Registered via `Register-ScheduledTask -Xml`: `schtasks /create /sc onlogon`
|
|
203
|
+
// demands elevation, while a self-scoped LogonTrigger does not (verified non-admin).
|
|
204
|
+
// The declaration must say UTF-16 or the Task Scheduler COM parser rejects the
|
|
205
|
+
// registration with an XML format error, even though the string handed to
|
|
206
|
+
// Register-ScheduledTask is read from the UTF-8 file via Get-Content -Raw.
|
|
207
|
+
export function generateWindowsTaskXml(userId, wscriptPath, vbsPath) {
|
|
208
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
209
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
210
|
+
<RegistrationInfo>
|
|
211
|
+
<Description>VibeCafe Usage Tracker background sync daemon</Description>
|
|
212
|
+
</RegistrationInfo>
|
|
213
|
+
<Triggers>
|
|
214
|
+
<LogonTrigger>
|
|
215
|
+
<UserId>${escapeXml(userId)}</UserId>
|
|
216
|
+
</LogonTrigger>
|
|
217
|
+
</Triggers>
|
|
218
|
+
<Principals>
|
|
219
|
+
<Principal id="Author">
|
|
220
|
+
<UserId>${escapeXml(userId)}</UserId>
|
|
221
|
+
<LogonType>InteractiveToken</LogonType>
|
|
222
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
223
|
+
</Principal>
|
|
224
|
+
</Principals>
|
|
225
|
+
<Settings>
|
|
226
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
227
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
228
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
229
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
230
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
231
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
232
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
233
|
+
<Priority>7</Priority>
|
|
234
|
+
</Settings>
|
|
235
|
+
<Actions Context="Author">
|
|
236
|
+
<Exec>
|
|
237
|
+
<Command>${escapeXml(wscriptPath)}</Command>
|
|
238
|
+
<Arguments>"${escapeXml(vbsPath)}"</Arguments>
|
|
239
|
+
</Exec>
|
|
240
|
+
</Actions>
|
|
241
|
+
</Task>
|
|
242
|
+
`;
|
|
243
|
+
}
|
|
244
|
+
|
|
137
245
|
function run(cmd, args) {
|
|
138
246
|
try {
|
|
139
247
|
const output = execFileSync(cmd, args, {
|
|
140
248
|
encoding: 'utf-8',
|
|
141
249
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
250
|
+
windowsHide: true,
|
|
142
251
|
});
|
|
143
252
|
return { ok: true, output: output.trim() };
|
|
144
253
|
} catch (err) {
|
|
@@ -146,11 +255,92 @@ function run(cmd, args) {
|
|
|
146
255
|
}
|
|
147
256
|
}
|
|
148
257
|
|
|
258
|
+
function runPowerShell(script) {
|
|
259
|
+
return run('powershell.exe', [
|
|
260
|
+
'-NoProfile',
|
|
261
|
+
'-NonInteractive',
|
|
262
|
+
'-ExecutionPolicy',
|
|
263
|
+
'Bypass',
|
|
264
|
+
'-Command',
|
|
265
|
+
script,
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// $ErrorActionPreference='Stop' turns cmdlet failures into the catch block's exit 1,
|
|
270
|
+
// since -Command otherwise exits 0 even after a non-terminating error.
|
|
271
|
+
function psTry(scriptLines) {
|
|
272
|
+
return [
|
|
273
|
+
"$ErrorActionPreference = 'Stop'",
|
|
274
|
+
'try {',
|
|
275
|
+
...scriptLines.map(line => ` ${line}`),
|
|
276
|
+
' exit 0',
|
|
277
|
+
'} catch {',
|
|
278
|
+
' [Console]::Error.WriteLine($_.Exception.Message)',
|
|
279
|
+
' exit 1',
|
|
280
|
+
'}',
|
|
281
|
+
].join('\n');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function taskExists() {
|
|
285
|
+
const result = runPowerShell(
|
|
286
|
+
`if (Get-ScheduledTask -TaskName ${psString(SERVICE_NAME)} -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }`,
|
|
287
|
+
);
|
|
288
|
+
return result.ok;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function windowsUserId() {
|
|
292
|
+
const domain = process.env.USERDOMAIN?.trim();
|
|
293
|
+
const user = process.env.USERNAME?.trim();
|
|
294
|
+
if (domain && user) return `${domain}\\${user}`;
|
|
295
|
+
const result = run('whoami', []);
|
|
296
|
+
return result.ok ? result.output : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The cmd wrapper is the install-time record of the exact runtime + CLI
|
|
300
|
+
// invocation. Process matching uses both paths so it works for Node and Bun
|
|
301
|
+
// without touching foreground daemons from another checkout.
|
|
302
|
+
export function parseWindowsTaskInvocation(cmd) {
|
|
303
|
+
const match = cmd.match(/^"([^"]+)" "([^"]+)" daemon\b/m);
|
|
304
|
+
if (!match) return null;
|
|
305
|
+
const unescapeCmdValue = value => value.replace(/%%/g, '%');
|
|
306
|
+
return {
|
|
307
|
+
runtimePath: unescapeCmdValue(match[1]),
|
|
308
|
+
binPath: unescapeCmdValue(match[2]),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readTaskInvocation(paths) {
|
|
313
|
+
try {
|
|
314
|
+
return parseWindowsTaskInvocation(readFileSync(paths.cmd, 'utf-8'));
|
|
315
|
+
} catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// -like treats []*? as wildcards; a literal path containing any of them must be
|
|
321
|
+
// bracket-escaped to still match.
|
|
322
|
+
function psLikeFragment(value) {
|
|
323
|
+
return value.replace(/[[\]*?]/g, '[$&]');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function windowsDaemonProcessExpression(invocation) {
|
|
327
|
+
if (!invocation) return null;
|
|
328
|
+
const binPattern = `'*${psLikeFragment(invocation.binPath)}* daemon*'`;
|
|
329
|
+
return `Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -ieq ${psString(invocation.runtimePath)} -and $_.CommandLine -like ${binPattern} }`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function daemonProcessKillLines(invocation) {
|
|
333
|
+
const expression = windowsDaemonProcessExpression(invocation);
|
|
334
|
+
return expression
|
|
335
|
+
? [`${expression} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`]
|
|
336
|
+
: [];
|
|
337
|
+
}
|
|
338
|
+
|
|
149
339
|
function install() {
|
|
150
340
|
const plat = detectPlatform();
|
|
151
341
|
if (!plat) {
|
|
152
342
|
console.log(failure('当前平台不支持 daemon。'));
|
|
153
|
-
console.log(dim(' 支持: Linux (systemd) / macOS (launchd)'));
|
|
343
|
+
console.log(dim(' 支持: Linux (systemd) / macOS (launchd) / Windows (Task Scheduler)'));
|
|
154
344
|
return;
|
|
155
345
|
}
|
|
156
346
|
|
|
@@ -164,7 +354,10 @@ function install() {
|
|
|
164
354
|
|
|
165
355
|
const paths = getServicePaths(plat);
|
|
166
356
|
|
|
167
|
-
|
|
357
|
+
// A leftover daemon-task.xml without a registration (crashed uninstall) must
|
|
358
|
+
// not block a fresh install, so Task Scheduler checks the live task instead
|
|
359
|
+
const alreadyInstalled = plat === 'taskscheduler' ? taskExists() : existsSync(paths.file);
|
|
360
|
+
if (alreadyInstalled) {
|
|
168
361
|
console.log(warn('Daemon 已安装,运行 `vibe-usage daemon restart` 或 `uninstall` 先处理。'));
|
|
169
362
|
return;
|
|
170
363
|
}
|
|
@@ -197,6 +390,36 @@ function install() {
|
|
|
197
390
|
console.log(success('服务已加载并启动。'));
|
|
198
391
|
}
|
|
199
392
|
|
|
393
|
+
if (plat === 'taskscheduler') {
|
|
394
|
+
const userId = windowsUserId();
|
|
395
|
+
if (!userId) {
|
|
396
|
+
console.error(failure('无法确定当前 Windows 用户,安装中止。'));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const wscriptPath = join(
|
|
400
|
+
process.env.WINDIR?.trim() || process.env.SystemRoot?.trim() || 'C:\\Windows',
|
|
401
|
+
'System32',
|
|
402
|
+
'wscript.exe',
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
writeFileSync(paths.cmd, generateWindowsTaskCmd(nodePath, binPath), 'utf-8');
|
|
406
|
+
writeFileSync(paths.vbs, generateWindowsTaskVbs(paths.cmd), 'utf-8');
|
|
407
|
+
writeFileSync(paths.file, generateWindowsTaskXml(userId, wscriptPath, paths.vbs), 'utf-8');
|
|
408
|
+
console.log(dim(` 已写入 ${paths.file}`));
|
|
409
|
+
|
|
410
|
+
// Get-Content must force UTF8: the XML is written without a BOM and default
|
|
411
|
+
// decoding on e.g. zh-CN systems is ANSI, which mangles non-ASCII paths
|
|
412
|
+
const result = runPowerShell(psTry([
|
|
413
|
+
`Register-ScheduledTask -TaskName ${psString(SERVICE_NAME)} -Xml (Get-Content -Raw -Encoding UTF8 ${psString(paths.file)}) -Force | Out-Null`,
|
|
414
|
+
`Start-ScheduledTask -TaskName ${psString(SERVICE_NAME)}`,
|
|
415
|
+
]));
|
|
416
|
+
if (!result.ok) {
|
|
417
|
+
console.error(failure(`注册/启动计划任务失败: ${result.output}`));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
console.log(success('计划任务已注册并启动。'));
|
|
421
|
+
}
|
|
422
|
+
|
|
200
423
|
console.log();
|
|
201
424
|
console.log(success('Daemon 已安装,用量数据将每 30 分钟自动同步。'));
|
|
202
425
|
console.log(dim(' 运行 `vibe-usage daemon status` 查看状态。'));
|
|
@@ -211,7 +434,10 @@ function uninstall() {
|
|
|
211
434
|
|
|
212
435
|
const paths = getServicePaths(plat);
|
|
213
436
|
|
|
214
|
-
|
|
437
|
+
const installed = plat === 'taskscheduler'
|
|
438
|
+
? taskExists() || existsSync(paths.file)
|
|
439
|
+
: existsSync(paths.file);
|
|
440
|
+
if (!installed) {
|
|
215
441
|
console.log(dim('未安装 daemon 服务。'));
|
|
216
442
|
return;
|
|
217
443
|
}
|
|
@@ -229,6 +455,24 @@ function uninstall() {
|
|
|
229
455
|
unlinkSync(paths.file);
|
|
230
456
|
console.log(success('服务已卸载并删除。'));
|
|
231
457
|
}
|
|
458
|
+
|
|
459
|
+
if (plat === 'taskscheduler') {
|
|
460
|
+
if (taskExists()) {
|
|
461
|
+
const result = runPowerShell(psTry([
|
|
462
|
+
`Stop-ScheduledTask -TaskName ${psString(SERVICE_NAME)} -ErrorAction SilentlyContinue`,
|
|
463
|
+
...daemonProcessKillLines(readTaskInvocation(paths)),
|
|
464
|
+
`Unregister-ScheduledTask -TaskName ${psString(SERVICE_NAME)} -Confirm:$false`,
|
|
465
|
+
]));
|
|
466
|
+
if (!result.ok) {
|
|
467
|
+
console.error(failure(`删除计划任务失败: ${result.output}`));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const file of [paths.file, paths.cmd, paths.vbs]) {
|
|
472
|
+
if (existsSync(file)) unlinkSync(file);
|
|
473
|
+
}
|
|
474
|
+
console.log(success('服务已卸载并删除。'));
|
|
475
|
+
}
|
|
232
476
|
}
|
|
233
477
|
|
|
234
478
|
function status() {
|
|
@@ -240,6 +484,63 @@ function status() {
|
|
|
240
484
|
|
|
241
485
|
const paths = getServicePaths(plat);
|
|
242
486
|
|
|
487
|
+
if (plat === 'taskscheduler') {
|
|
488
|
+
if (!taskExists()) {
|
|
489
|
+
console.log(dim('未安装 daemon 服务。'));
|
|
490
|
+
console.log(dim(' 运行 `vibe-usage daemon install` 安装。'));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const invocation = readTaskInvocation(paths);
|
|
494
|
+
const processExpression = windowsDaemonProcessExpression(invocation);
|
|
495
|
+
const scriptLines = [
|
|
496
|
+
`$t = Get-ScheduledTask -TaskName ${psString(SERVICE_NAME)} -ErrorAction SilentlyContinue`,
|
|
497
|
+
'if (-not $t) { exit 1 }',
|
|
498
|
+
'Write-Output ("State=" + $t.State)',
|
|
499
|
+
];
|
|
500
|
+
if (processExpression) {
|
|
501
|
+
scriptLines.push(
|
|
502
|
+
`$p = ${processExpression} | Select-Object -First 1`,
|
|
503
|
+
'if ($p) { Write-Output "DaemonProcess=running" } else { Write-Output "DaemonProcess=stopped" }',
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
scriptLines.push(
|
|
507
|
+
'$i = Get-ScheduledTaskInfo -TaskName ' + psString(SERVICE_NAME) + ' -ErrorAction SilentlyContinue',
|
|
508
|
+
'if ($i -and $i.LastRunTime.Year -gt 1) { Write-Output ("LastRunTime=" + $i.LastRunTime.ToString("s")) }',
|
|
509
|
+
'if ($i) { Write-Output ("LastTaskResult=" + $i.LastTaskResult) }',
|
|
510
|
+
);
|
|
511
|
+
const result = runPowerShell(scriptLines.join('\n'));
|
|
512
|
+
if (!result.ok) {
|
|
513
|
+
console.log(warn('无法读取计划任务状态。'));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const lines = Object.fromEntries(result.output
|
|
517
|
+
.split(/\r?\n/)
|
|
518
|
+
.map(line => line.trim())
|
|
519
|
+
.filter(Boolean)
|
|
520
|
+
.map(line => {
|
|
521
|
+
const idx = line.indexOf('=');
|
|
522
|
+
return idx === -1 ? [line, ''] : [line.slice(0, idx), line.slice(idx + 1)];
|
|
523
|
+
}));
|
|
524
|
+
// The live runtime process is the source of truth; the task State can lag.
|
|
525
|
+
const running = processExpression
|
|
526
|
+
? lines.DaemonProcess === 'running'
|
|
527
|
+
: lines.State === 'Running';
|
|
528
|
+
if (running) {
|
|
529
|
+
console.log(success('服务运行中。'));
|
|
530
|
+
} else if (lines.State === 'Ready' || lines.State === 'Stopped' || lines.State === 'Running') {
|
|
531
|
+
console.log(warn('服务已安装但当前未运行。'));
|
|
532
|
+
} else {
|
|
533
|
+
console.log(warn(`服务状态异常: ${lines.State || '未知'}`));
|
|
534
|
+
}
|
|
535
|
+
for (const key of ['LastRunTime', 'LastTaskResult']) {
|
|
536
|
+
if (lines[key]) console.log(dim(` ${key}=${lines[key]}`));
|
|
537
|
+
}
|
|
538
|
+
if (!running) {
|
|
539
|
+
console.log(dim(` 查看日志: ${join(homedir(), '.vibe-usage', 'daemon.log')},或运行 \`vibe-usage daemon restart\`。`));
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
243
544
|
if (!existsSync(paths.file)) {
|
|
244
545
|
console.log(dim('未安装 daemon 服务。'));
|
|
245
546
|
console.log(dim(' 运行 `vibe-usage daemon install` 安装。'));
|
|
@@ -287,6 +588,24 @@ function stop() {
|
|
|
287
588
|
const result = run('launchctl', ['unload', paths.file]);
|
|
288
589
|
console.log(result.ok ? success('服务已停止。') : failure(`停止失败: ${result.output}`));
|
|
289
590
|
}
|
|
591
|
+
|
|
592
|
+
if (plat === 'taskscheduler') {
|
|
593
|
+
if (!taskExists()) {
|
|
594
|
+
console.log(dim('未安装 daemon 服务。'));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// The logon trigger stays registered, so like launchd's unload the daemon
|
|
598
|
+
// stays stopped until `restart` or the next logon. Stop-ScheduledTask only
|
|
599
|
+
// terminates the wscript wrapper, so the real runtime daemon is matched and
|
|
600
|
+
// killed explicitly.
|
|
601
|
+
const paths = getServicePaths(plat);
|
|
602
|
+
const invocation = readTaskInvocation(paths);
|
|
603
|
+
const result = runPowerShell(psTry([
|
|
604
|
+
`Stop-ScheduledTask -TaskName ${psString(SERVICE_NAME)} -ErrorAction SilentlyContinue`,
|
|
605
|
+
...daemonProcessKillLines(invocation),
|
|
606
|
+
]));
|
|
607
|
+
console.log(result.ok ? success('服务已停止。') : failure(`停止失败: ${result.output}`));
|
|
608
|
+
}
|
|
290
609
|
}
|
|
291
610
|
|
|
292
611
|
function restart() {
|
|
@@ -309,6 +628,27 @@ function restart() {
|
|
|
309
628
|
const result = run('launchctl', ['load', paths.file]);
|
|
310
629
|
console.log(result.ok ? success('服务已重启。') : failure(`重启失败: ${result.output}`));
|
|
311
630
|
}
|
|
631
|
+
|
|
632
|
+
if (plat === 'taskscheduler') {
|
|
633
|
+
if (!taskExists()) {
|
|
634
|
+
console.log(dim('未安装 daemon 服务。'));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const paths = getServicePaths(plat);
|
|
638
|
+
const invocation = readTaskInvocation(paths);
|
|
639
|
+
const taskName = psString(SERVICE_NAME);
|
|
640
|
+
const result = runPowerShell(psTry([
|
|
641
|
+
`$t = Get-ScheduledTask -TaskName ${taskName}`,
|
|
642
|
+
"if ($t.State -eq 'Running') {",
|
|
643
|
+
` Stop-ScheduledTask -TaskName ${taskName} -ErrorAction SilentlyContinue`,
|
|
644
|
+
...daemonProcessKillLines(invocation),
|
|
645
|
+
' $deadline = (Get-Date).AddSeconds(5)',
|
|
646
|
+
` while ((Get-ScheduledTask -TaskName ${taskName}).State -eq 'Running' -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 200 }`,
|
|
647
|
+
'}',
|
|
648
|
+
`Start-ScheduledTask -TaskName ${taskName}`,
|
|
649
|
+
]));
|
|
650
|
+
console.log(result.ok ? success('服务已重启。') : failure(`重启失败: ${result.output}`));
|
|
651
|
+
}
|
|
312
652
|
}
|
|
313
653
|
|
|
314
654
|
const SUBCOMMANDS = { install, uninstall, status, stop, restart };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { accessSync, constants, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import { codexSessionDirs } from './codex-roots.js';
|
|
5
|
+
|
|
6
|
+
export const EXTRA_ROOT_SOURCES = ['antigravity', 'codex', 'grok'];
|
|
7
|
+
|
|
8
|
+
export function extraRootList(value) {
|
|
9
|
+
return Array.isArray(value) ? value.filter(root => typeof root === 'string' && root.trim()) : [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeExtraRoot(value) {
|
|
13
|
+
const trimmed = value.trim();
|
|
14
|
+
if (trimmed === '~') return homedir();
|
|
15
|
+
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|
16
|
+
return resolve(homedir(), trimmed.slice(2));
|
|
17
|
+
}
|
|
18
|
+
return resolve(trimmed);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isReadableDirectory(path) {
|
|
22
|
+
try {
|
|
23
|
+
if (!statSync(path).isDirectory()) return false;
|
|
24
|
+
accessSync(path, constants.R_OK);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isCodexHome(path) {
|
|
32
|
+
return isReadableDirectory(path) && codexSessionDirs(path).some(isReadableDirectory);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Multica stores task-local Codex homes below a bounded
|
|
36
|
+
// <container>/<workspace>/<task>/codex-home hierarchy. Do not follow symlinks
|
|
37
|
+
// or descend beyond that shape: configured containers may also contain large
|
|
38
|
+
// workdirs that are unrelated to usage logs.
|
|
39
|
+
export function discoverCodexHomes(value, maxDepth = 3) {
|
|
40
|
+
const root = normalizeExtraRoot(value);
|
|
41
|
+
if (isCodexHome(root)) return { root, homes: [root], readable: true };
|
|
42
|
+
if (!isReadableDirectory(root)) return { root, homes: [], readable: false };
|
|
43
|
+
|
|
44
|
+
const homes = [];
|
|
45
|
+
const queue = [{ path: root, depth: 0 }];
|
|
46
|
+
let readable = true;
|
|
47
|
+
while (queue.length > 0) {
|
|
48
|
+
const current = queue.shift();
|
|
49
|
+
let children;
|
|
50
|
+
try {
|
|
51
|
+
children = readdirSync(current.path, { withFileTypes: true });
|
|
52
|
+
} catch {
|
|
53
|
+
readable = false;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const child of children) {
|
|
57
|
+
// Dirent#isDirectory is false for symbolic links, so traversal stays
|
|
58
|
+
// inside the explicitly selected tree.
|
|
59
|
+
if (!child.isDirectory()) continue;
|
|
60
|
+
const childPath = join(current.path, child.name);
|
|
61
|
+
const depth = current.depth + 1;
|
|
62
|
+
if (basename(childPath) === 'codex-home' && isCodexHome(childPath)) {
|
|
63
|
+
homes.push(childPath);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (depth < maxDepth) queue.push({ path: childPath, depth });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { root, homes: [...new Set(homes)], readable };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function grokSessionsDir(value) {
|
|
73
|
+
return join(normalizeExtraRoot(value), 'sessions');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function antigravityConversationDirs(value) {
|
|
77
|
+
const root = normalizeExtraRoot(value);
|
|
78
|
+
return [
|
|
79
|
+
join(root, '.gemini', 'antigravity', 'conversations'),
|
|
80
|
+
join(root, '.gemini', 'antigravity-cli', 'conversations'),
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function validateExtraRoot(source, value) {
|
|
85
|
+
if (!EXTRA_ROOT_SOURCES.includes(source)) {
|
|
86
|
+
return { ok: false, path: value, reason: `不支持的工具: ${source}` };
|
|
87
|
+
}
|
|
88
|
+
const path = normalizeExtraRoot(value);
|
|
89
|
+
if (source === 'codex') {
|
|
90
|
+
const result = discoverCodexHomes(path);
|
|
91
|
+
return {
|
|
92
|
+
ok: result.readable && result.homes.length > 0,
|
|
93
|
+
path,
|
|
94
|
+
reason: '需要是 Codex Home,或包含 */*/codex-home 的 Multica 容器',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const dirs = source === 'grok'
|
|
98
|
+
? [grokSessionsDir(path)]
|
|
99
|
+
: antigravityConversationDirs(path);
|
|
100
|
+
return {
|
|
101
|
+
ok: dirs.some(isReadableDirectory),
|
|
102
|
+
path,
|
|
103
|
+
reason: source === 'grok'
|
|
104
|
+
? '需要包含 sessions/'
|
|
105
|
+
: '需要包含 .gemini/antigravity*/conversations/',
|
|
106
|
+
};
|
|
107
|
+
}
|