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 +7 -3
- package/src/commands/doctor.js +5 -2
- package/src/commands/sync.js +55 -1
- package/src/lib/cursor-scrub.js +164 -0
- package/src/lib/fs.js +126 -11
- package/src/lib/ops/sources/claude.js +18 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeusage",
|
|
3
|
-
"version": "0.6.
|
|
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
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
`
|
|
150
|
-
|
|
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
|
}
|
package/src/commands/sync.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 === "
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
const dir =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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;
|