vibeusage 0.6.1 → 0.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeusage",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,7 +49,8 @@
49
49
  "validate:ui-hardcode": "node scripts/ops/validate-ui-hardcode.cjs"
50
50
  },
51
51
  "dependencies": {
52
- "@insforge/sdk": "1.2.2"
52
+ "@insforge/sdk": "1.2.2",
53
+ "proper-lockfile": "^4.1.2"
53
54
  },
54
55
  "devDependencies": {
55
56
  "@sourcegraph/scip-typescript": "^0.3.6",
@@ -60,5 +61,8 @@
60
61
  ],
61
62
  "engines": {
62
63
  "node": "20.x"
63
- }
64
+ },
65
+ "bundleDependencies": [
66
+ "@insforge/sdk"
67
+ ]
64
68
  }
@@ -146,8 +146,11 @@ function runAuditTokens({ opts, config }) {
146
146
  if (result.exceedsThreshold) {
147
147
  process.stderr.write(
148
148
  `\nFAIL drift ${result.maxDriftPct.toFixed(2)}% exceeds threshold ${result.thresholdPct}%.\n` +
149
- `If vibeusage >= 0.5.0, scrub the Claude/OpenCode cursor block in\n` +
150
- `~/.vibeusage/tracker/cursors.json and rerun \`vibeusage sync --drain\`.\n`,
149
+ `Rebuild this source from its local session files: ` +
150
+ `\`vibeusage sync --rebuild ${result.source}\`\n` +
151
+ `(That clears the source's file/bucket/group cursors atomically and ` +
152
+ `re-parses every session — fixing drift caused by interrupted uploads ` +
153
+ `or partial cursor edits.)\n`,
151
154
  );
152
155
  process.exitCode = 1;
153
156
  }
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
4
4
  const cp = require("node:child_process");
5
5
 
6
6
  const { ensureDir, readJson, writeJson, openLock } = require("../lib/fs");
7
+ const { scrubSourceCursors, listSupportedSources } = require("../lib/cursor-scrub");
7
8
  const {
8
9
  listRolloutFiles,
9
10
  listClaudeProjectFiles,
@@ -65,6 +66,30 @@ async function cmdSync(argv) {
65
66
 
66
67
  const config = await readJson(configPath);
67
68
  const cursors = (await readJson(cursorsPath)) || { version: 1, files: {}, updatedAt: null };
69
+
70
+ if (opts.rebuild) {
71
+ const scrub = scrubSourceCursors({
72
+ cursors,
73
+ sourceId: opts.rebuild,
74
+ home,
75
+ env: process.env,
76
+ });
77
+ // Persist the cleared cursors before parsing begins. If we crash mid-
78
+ // rebuild, the next sync resumes from a clean state for this source
79
+ // rather than re-accumulating onto cached totals (the original bug).
80
+ await writeJson(cursorsPath, cursors);
81
+ process.stdout.write(
82
+ `Rebuilding source=${scrub.sourceId}: cleared ${scrub.filesRemoved} file ` +
83
+ `cursors, ${scrub.bucketsRemoved} hourly buckets, ` +
84
+ `${scrub.projectBucketsRemoved} project buckets, ` +
85
+ `${scrub.groupsRemoved} groupQueued entries` +
86
+ (scrub.extraCursorsCleared.length
87
+ ? `, ${scrub.extraCursorsCleared.join("+")}`
88
+ : "") +
89
+ `.\n`,
90
+ );
91
+ }
92
+
68
93
  const uploadThrottle = normalizeUploadState(await readJson(uploadThrottlePath));
69
94
  let uploadThrottleState = uploadThrottle;
70
95
 
@@ -445,6 +470,7 @@ function parseArgs(argv) {
445
470
  fromRetry: false,
446
471
  fromOpenclaw: false,
447
472
  drain: false,
473
+ rebuild: null,
448
474
  };
449
475
  for (let i = 0; i < argv.length; i++) {
450
476
  const a = argv[i];
@@ -453,7 +479,35 @@ function parseArgs(argv) {
453
479
  else if (a === "--from-retry") out.fromRetry = true;
454
480
  else if (a === "--from-openclaw") out.fromOpenclaw = true;
455
481
  else if (a === "--drain") out.drain = true;
456
- else throw new Error(`Unknown option: ${a}`);
482
+ else if (a === "--rebuild") {
483
+ const v = argv[++i];
484
+ if (!v || v.startsWith("--")) {
485
+ throw new Error("--rebuild requires a source id (e.g. --rebuild claude)");
486
+ }
487
+ out.rebuild = v;
488
+ } else if (a.startsWith("--rebuild=")) {
489
+ const v = a.slice("--rebuild=".length);
490
+ if (!v) throw new Error("--rebuild= requires a source id");
491
+ out.rebuild = v;
492
+ } else throw new Error(`Unknown option: ${a}`);
493
+ }
494
+ if (out.rebuild) {
495
+ const supported = listSupportedSources();
496
+ if (!supported.includes(out.rebuild)) {
497
+ throw new Error(
498
+ `--rebuild: unknown source '${out.rebuild}'. Supported: ${supported.join(", ")}`,
499
+ );
500
+ }
501
+ // A rebuild always wants a full upload pass, otherwise the freshly-rebuilt
502
+ // buckets would sit in queue.jsonl behind the default 10-batch cap.
503
+ out.drain = true;
504
+ // OpenClaw is the only source whose ledger parsing is gated behind an
505
+ // explicit flag (see sync flow's `opts.fromOpenclaw ? parseOpenclaw... : noop`).
506
+ // A `--rebuild=openclaw` that doesn't also turn that flag on would scrub
507
+ // the OpenClaw cursors and persist the cleared state without ever re-
508
+ // aggregating from the ledger — leaving totals stale until a later
509
+ // plugin-triggered sync. Force the flag so rebuild actually rebuilds.
510
+ if (out.rebuild === "openclaw") out.fromOpenclaw = true;
457
511
  }
458
512
  return out;
459
513
  }
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+
5
+ // scrubSourceCursors clears every cursor field that, if left in place, would
6
+ // cause a re-parse of a source's session files to *accumulate* into
7
+ // previously-uploaded buckets instead of *rebuilding* them from scratch.
8
+ //
9
+ // This is the helper that fixes the bug behind the recent "DB tokens doubled"
10
+ // incident: clearing only `cursors.files` causes the parser to re-read the
11
+ // jsonl files and add their token totals on top of whatever was still cached
12
+ // in `cursors.hourly.buckets`. The result is buckets at roughly 2x the
13
+ // ground truth.
14
+ //
15
+ // Four cursor surfaces must be cleared in lockstep for a source rebuild to
16
+ // be correct:
17
+ // 1. cursors.files entries whose path lives under that source's session
18
+ // root (so the parser re-reads each file from offset 0).
19
+ // 2. cursors.hourly.buckets keyed `<source>|<model>|<hour>` (so per-bucket
20
+ // totals restart at zero before re-aggregation).
21
+ // 3. cursors.hourly.groupQueued keys for that source (so the next sync
22
+ // re-enqueues each touched bucket to queue.jsonl for upload).
23
+ // 4. cursors.projectHourly.buckets keyed `<project>|<source>|<hour>`
24
+ // (project-scoped totals are aggregated independently from the global
25
+ // hourly state and would otherwise stay doubled in the dashboard's
26
+ // per-project views).
27
+ //
28
+ // `cursors.projectHourly.projects` holds project metadata (git remotes,
29
+ // display names) and is intentionally preserved — it carries no token
30
+ // totals, just identity.
31
+ //
32
+ // Sources that don't use `cursors.files` (sqlite-backed opencode, ledger-
33
+ // backed hermes/openclaw) carry their progress in dedicated cursor fields;
34
+ // those are reset directly.
35
+
36
+ const SOURCES = {
37
+ claude: {
38
+ sessionRoot: ({ home }) => path.join(home, ".claude", "projects"),
39
+ },
40
+ codex: {
41
+ sessionRoot: ({ home, env }) =>
42
+ path.join(env.CODEX_HOME || path.join(home, ".codex"), "sessions"),
43
+ },
44
+ "every-code": {
45
+ sessionRoot: ({ home, env }) =>
46
+ path.join(env.CODE_HOME || path.join(home, ".code"), "sessions"),
47
+ },
48
+ gemini: {
49
+ sessionRoot: ({ home, env }) =>
50
+ path.join(env.GEMINI_HOME || path.join(home, ".gemini"), "tmp"),
51
+ },
52
+ kimi: {
53
+ sessionRoot: ({ home, env }) =>
54
+ path.join(env.KIMI_HOME || path.join(home, ".kimi"), "sessions"),
55
+ },
56
+ opencode: {
57
+ extraCursorKeys: ["opencode", "opencodeSqlite"],
58
+ },
59
+ hermes: {
60
+ extraCursorKeys: ["hermesLedger"],
61
+ },
62
+ openclaw: {
63
+ extraCursorKeys: ["openclawLedger"],
64
+ },
65
+ };
66
+
67
+ function listSupportedSources() {
68
+ return Object.keys(SOURCES);
69
+ }
70
+
71
+ function scrubSourceCursors({ cursors, sourceId, home, env = process.env }) {
72
+ if (!cursors || typeof cursors !== "object") {
73
+ throw new Error("scrubSourceCursors: cursors must be an object");
74
+ }
75
+ const config = SOURCES[sourceId];
76
+ if (!config) {
77
+ throw new Error(
78
+ `scrubSourceCursors: unknown sourceId '${sourceId}'. Supported: ${listSupportedSources().join(", ")}`,
79
+ );
80
+ }
81
+
82
+ const result = {
83
+ sourceId,
84
+ filesRemoved: 0,
85
+ bucketsRemoved: 0,
86
+ groupsRemoved: 0,
87
+ projectBucketsRemoved: 0,
88
+ extraCursorsCleared: [],
89
+ };
90
+
91
+ // 1) cursors.files — strip every entry whose path lives under this source's
92
+ // session root, so the parser re-reads them from byte 0.
93
+ if (config.sessionRoot && cursors.files && typeof cursors.files === "object") {
94
+ const prefix = config.sessionRoot({ home, env });
95
+ for (const key of Object.keys(cursors.files)) {
96
+ if (typeof key !== "string") continue;
97
+ if (key.startsWith(prefix)) {
98
+ delete cursors.files[key];
99
+ result.filesRemoved += 1;
100
+ }
101
+ }
102
+ }
103
+
104
+ // 2) cursors.hourly.buckets — strip every bucket keyed for this source so
105
+ // its totals restart at zero before re-aggregation.
106
+ if (cursors.hourly && typeof cursors.hourly === "object") {
107
+ const bucketPrefix = `${sourceId}|`;
108
+ if (cursors.hourly.buckets && typeof cursors.hourly.buckets === "object") {
109
+ for (const key of Object.keys(cursors.hourly.buckets)) {
110
+ if (typeof key === "string" && key.startsWith(bucketPrefix)) {
111
+ delete cursors.hourly.buckets[key];
112
+ result.bucketsRemoved += 1;
113
+ }
114
+ }
115
+ }
116
+ // 3) cursors.hourly.groupQueued — strip per-source enqueue records so
117
+ // the next sync re-enqueues each touched bucket for upload.
118
+ if (cursors.hourly.groupQueued && typeof cursors.hourly.groupQueued === "object") {
119
+ for (const key of Object.keys(cursors.hourly.groupQueued)) {
120
+ if (typeof key === "string" && key.startsWith(bucketPrefix)) {
121
+ delete cursors.hourly.groupQueued[key];
122
+ result.groupsRemoved += 1;
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ // 4) cursors.projectHourly.buckets — keyed `<project_key>|<source>|<hour>`.
129
+ // Strip the buckets where the middle segment matches this source, leaving
130
+ // every other source's project-scoped totals (and the projects metadata
131
+ // map) untouched.
132
+ if (
133
+ cursors.projectHourly &&
134
+ typeof cursors.projectHourly === "object" &&
135
+ cursors.projectHourly.buckets &&
136
+ typeof cursors.projectHourly.buckets === "object"
137
+ ) {
138
+ for (const key of Object.keys(cursors.projectHourly.buckets)) {
139
+ if (typeof key !== "string") continue;
140
+ const parts = key.split("|");
141
+ if (parts.length >= 2 && parts[1] === sourceId) {
142
+ delete cursors.projectHourly.buckets[key];
143
+ result.projectBucketsRemoved += 1;
144
+ }
145
+ }
146
+ }
147
+
148
+ // 5) Source-specific top-level cursor fields (opencode sqlite progress,
149
+ // hermes/openclaw ledger offsets). Resetting these is what makes a
150
+ // rebuild work for non-file sources.
151
+ for (const key of config.extraCursorKeys || []) {
152
+ if (key in cursors) {
153
+ delete cursors[key];
154
+ result.extraCursorsCleared.push(key);
155
+ }
156
+ }
157
+
158
+ return result;
159
+ }
160
+
161
+ module.exports = {
162
+ scrubSourceCursors,
163
+ listSupportedSources,
164
+ };
package/src/lib/fs.js CHANGED
@@ -1,6 +1,12 @@
1
1
  const fs = require("node:fs/promises");
2
2
  const path = require("node:path");
3
3
 
4
+ // proper-lockfile is required lazily inside openLock(): some callers copy
5
+ // src/lib/fs.js into sandboxes that have no node_modules (e.g. the openclaw
6
+ // session plugin test, which materializes src/ under a tmp dir to test the
7
+ // ledger). Those callers only use ensureDir/readJson/etc and would crash on
8
+ // a top-level require.
9
+
4
10
  async function ensureDir(p) {
5
11
  await fs.mkdir(p, { recursive: true });
6
12
  }
@@ -47,23 +53,132 @@ async function chmod600IfPossible(filePath) {
47
53
  } catch (_e) {}
48
54
  }
49
55
 
50
- async function openLock(lockPath, { quietIfLocked }) {
56
+ // proper-lockfile gives us atomic mkdir-based mutual exclusion plus a heart-
57
+ // beat mechanism that auto-recovers from orphan locks without TOCTOU races:
58
+ //
59
+ // - The holder process refreshes the lock-directory's mtime every `update`
60
+ // ms. As long as that interval keeps running, the lock is "fresh".
61
+ // - Any acquirer that finds the existing lock with mtime older than `stale`
62
+ // ms takes it over via a compare-and-swap that is safe under concurrent
63
+ // attempts (the library's own contract).
64
+ // - If the holder dies (crash, SIGKILL, reboot) the heartbeat stops; the
65
+ // next acquirer sees the stale mtime and recovers automatically.
66
+ //
67
+ // We deliberately set `stale` larger than the default to give a working sync
68
+ // some headroom against transient event-loop pauses (large JSON.parse, GC).
69
+ // We pass `realpath: false` because the lock target may not exist as a file
70
+ // — proper-lockfile creates the lock-directory at `lockPath` directly.
71
+ const LOCK_STALE_MS = 60_000;
72
+ const LOCK_UPDATE_MS = 10_000;
73
+
74
+ async function openLock(lockPath, { quietIfLocked } = {}) {
75
+ // Lazy require: see top-of-file note about sandboxed callers.
76
+ const lockfile = require("proper-lockfile");
77
+
78
+ // Migration path: pre-proper-lockfile versions of vibeusage created the
79
+ // lock as a regular *file* (fs.open with "wx"). proper-lockfile creates
80
+ // it as a *directory* (mkdir). If we hand a stale legacy file off to
81
+ // proper-lockfile, its mkdir will EEXIST and its internal rmdir-fallback
82
+ // will then ENOTDIR, throwing instead of returning ELOCKED. Detect and
83
+ // resolve that mismatch up front.
84
+ const migration = await migrateLegacyLockFile(lockPath);
85
+ if (migration === "yield-to-legacy-holder") {
86
+ if (!quietIfLocked) process.stdout.write("Another sync is already running.\n");
87
+ return null;
88
+ }
89
+
90
+ let release;
51
91
  try {
52
- const handle = await fs.open(lockPath, "wx");
53
- return {
54
- async release() {
55
- await handle.close().catch(() => {});
56
- },
57
- };
92
+ release = await lockfile.lock(lockPath, {
93
+ lockfilePath: lockPath,
94
+ realpath: false,
95
+ stale: LOCK_STALE_MS,
96
+ update: LOCK_UPDATE_MS,
97
+ retries: 0,
98
+ });
58
99
  } catch (e) {
59
- if (e && e.code === "EEXIST") {
60
- if (!quietIfLocked) {
61
- process.stdout.write("Another sync is already running.\n");
62
- }
100
+ if (e && e.code === "ELOCKED") {
101
+ if (!quietIfLocked) process.stdout.write("Another sync is already running.\n");
63
102
  return null;
64
103
  }
65
104
  throw e;
66
105
  }
106
+ return {
107
+ async release() {
108
+ try {
109
+ await release();
110
+ } catch (_e) {
111
+ // Best-effort cleanup. proper-lockfile throws if the lock was already
112
+ // compromised (e.g. taken over by another process while we were
113
+ // running) — there is nothing useful to do at that point.
114
+ }
115
+ },
116
+ };
117
+ }
118
+
119
+ // Detect a leftover lock file from the previous wx-based scheme. Three cases:
120
+ // - "orphan" — proven dead by PID liveness; safe to unlink and migrate.
121
+ // - "alive" — recorded PID is still running; yield with the standard
122
+ // "another sync running" UX.
123
+ // - "indeterminate" — empty / corrupt / unreadable file. The original
124
+ // production openLock wrote a *zero-byte* file (it never
125
+ // called writeFile after fs.open(path, "wx")), so this
126
+ // is the **expected** legacy format. We cannot prove
127
+ // its holder is dead and we MUST NOT auto-delete: a
128
+ // still-running legacy sync would lose its lock and a
129
+ // new-format sync would start in parallel. Yield and
130
+ // print an actionable manual-cleanup notice.
131
+ async function migrateLegacyLockFile(lockPath) {
132
+ let stat;
133
+ try {
134
+ stat = await fs.lstat(lockPath);
135
+ } catch (e) {
136
+ if (e && e.code === "ENOENT") return "no-legacy";
137
+ throw e;
138
+ }
139
+ if (stat.isDirectory()) return "no-legacy"; // already in proper-lockfile format
140
+
141
+ const verdict = await classifyLegacyFileLock(lockPath);
142
+ if (verdict === "orphan") {
143
+ await fs.unlink(lockPath).catch(() => {});
144
+ return "migrated";
145
+ }
146
+ if (verdict === "indeterminate") {
147
+ process.stderr.write(
148
+ `vibeusage: legacy sync.lock at ${lockPath} carries no PID payload, ` +
149
+ `so we cannot prove its owner is dead. Auto-deletion is unsafe — a ` +
150
+ `still-running legacy sync would lose its lock. If no legacy ` +
151
+ `vibeusage sync is actually running, remove it manually: rm ${JSON.stringify(
152
+ lockPath,
153
+ )}\n`,
154
+ );
155
+ }
156
+ return "yield-to-legacy-holder";
157
+ }
158
+
159
+ async function classifyLegacyFileLock(lockPath) {
160
+ let raw;
161
+ try {
162
+ raw = await fs.readFile(lockPath, "utf8");
163
+ } catch (_e) {
164
+ return "indeterminate";
165
+ }
166
+ if (!raw) return "indeterminate";
167
+ let parsed;
168
+ try {
169
+ parsed = JSON.parse(raw);
170
+ } catch (_e) {
171
+ return "indeterminate";
172
+ }
173
+ const pid = parsed?.pid;
174
+ if (!Number.isFinite(pid)) return "indeterminate";
175
+ try {
176
+ process.kill(pid, 0);
177
+ return "alive";
178
+ } catch (e) {
179
+ if (e && e.code === "ESRCH") return "orphan";
180
+ return "alive"; // EPERM = pid exists but belongs to another user
181
+ }
67
182
  }
68
183
 
69
184
  module.exports = {
@@ -11,14 +11,25 @@ module.exports = {
11
11
  },
12
12
  walkSessions({ root }) {
13
13
  if (!fs.existsSync(root)) return [];
14
+ // Recurse: Claude Code writes the main thread under
15
+ // `projects/<project>/<session>.jsonl` AND subagent threads under
16
+ // `projects/<project>/<sessionId>/subagents/agent-*.jsonl`. Subagents
17
+ // burn real Anthropic tokens, so the audit must include them. Sync's
18
+ // walkClaudeProjects (rollout.js) already recurses; this mirrors it.
14
19
  const out = [];
15
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
16
- if (!entry.isDirectory()) continue;
17
- const dir = path.join(root, entry.name);
18
- for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
19
- if (!f.isFile()) continue;
20
- if (!f.name.endsWith(".jsonl")) continue;
21
- out.push(path.join(dir, f.name));
20
+ const stack = [root];
21
+ while (stack.length) {
22
+ const dir = stack.pop();
23
+ let entries;
24
+ try {
25
+ entries = fs.readdirSync(dir, { withFileTypes: true });
26
+ } catch (_err) {
27
+ continue;
28
+ }
29
+ for (const entry of entries) {
30
+ const p = path.join(dir, entry.name);
31
+ if (entry.isDirectory()) stack.push(p);
32
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(p);
22
33
  }
23
34
  }
24
35
  return out;