@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 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 manually persisted `codexExtraHome`; a versioned local index avoids re-reading unchanged rollouts and reads only safe append tails for ordinary sessions, while fork/sub-agent replay matching, duplicate suppression, and live/archive/cross-root deduplication retain their existing semantics |
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; honors `GROK_HOME` |
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/`; cache writes are included in input usage |
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` 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 while the language server is running |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.19",
3
+ "version": "0.10.20",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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
- const isNpxCache = binPath.includes('.npm/_npx');
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, '&amp;')
@@ -56,7 +78,17 @@ function escapeXml(value) {
56
78
  .replace(/'/g, '&apos;');
57
79
  }
58
80
 
59
- const PRESERVED_SERVICE_ENV = ['MIMOCODE_HOME', 'MIMOCODE_DB', 'XDG_DATA_HOME'];
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
- if (existsSync(paths.file)) {
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
- if (!existsSync(paths.file)) {
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
+ }