@indigoai-us/hq-cli 5.104.0 → 5.105.1
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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.105.1] — 2026-08-31
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Token usage reports now automatically include Claude activity stored in
|
|
10
|
+
named profiles such as `.claude-ridge`, with no extra setting required.
|
|
11
|
+
|
|
12
|
+
## [5.105.0] — 2026-08-31
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `hq reindex` now removes HQ-managed Git worktrees whose last Git-visible
|
|
17
|
+
activity is more than 12 hours old. It preserves the current worktree,
|
|
18
|
+
recently edited/deleted/renamed files, and the worktree's branch; ignored
|
|
19
|
+
dependency/build churn does not keep a worktree alive, and malformed or
|
|
20
|
+
uninspectable worktrees fail closed.
|
|
21
|
+
|
|
5
22
|
## [5.104.0] — 2026-08-31
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
/**
|
|
3
|
+
* Remove inactive worktrees created by `hq worktree` without ever deleting
|
|
4
|
+
* directories directly. This runs on the hook path, so it uses a shallow
|
|
5
|
+
* layout scan and Git's status output rather than recursively walking trees.
|
|
6
|
+
*/
|
|
7
|
+
export declare function removeStaleHqWorktrees(hqRoot: string, now?: number, deadline?: number): void;
|
|
2
8
|
/**
|
|
3
9
|
* Check hook health without relying on lifecycle hooks. A fully disabled
|
|
4
10
|
* configuration is repaired only after a successful reindex; partial and
|
package/dist/commands/reindex.js
CHANGED
|
@@ -33,6 +33,9 @@ import { findHqRoot } from '../utils/manifest.js';
|
|
|
33
33
|
import { guardLargeFiles } from '../utils/large-file-guard.js';
|
|
34
34
|
const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
|
|
35
35
|
const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
|
|
36
|
+
const WORKTREE_STALE_AFTER_MS = 12 * 60 * 60 * 1_000;
|
|
37
|
+
const WORKTREE_GIT_TIMEOUT_MS = 2_000;
|
|
38
|
+
const WORKTREE_HOOK_SWEEP_BUDGET_MS = 5_000;
|
|
36
39
|
/** Resolve the same root the repair/check commands must operate on. */
|
|
37
40
|
function resolveHqRoot(repoRoot) {
|
|
38
41
|
const root = repoRoot ?? findHqRoot();
|
|
@@ -43,6 +46,267 @@ function resolveHqRoot(repoRoot) {
|
|
|
43
46
|
return path.resolve(root);
|
|
44
47
|
}
|
|
45
48
|
}
|
|
49
|
+
function runGit(cwd, args, deadline = Number.POSITIVE_INFINITY, finishStartedCommand = false) {
|
|
50
|
+
const remainingMs = deadline - Date.now();
|
|
51
|
+
if (remainingMs <= 0)
|
|
52
|
+
return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const result = spawnSync('git', ['-C', cwd, ...args], {
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
stdio: 'pipe',
|
|
57
|
+
...(finishStartedCommand
|
|
58
|
+
? {}
|
|
59
|
+
: { timeout: Math.max(1, Math.min(WORKTREE_GIT_TIMEOUT_MS, remainingMs)) }),
|
|
60
|
+
// `git status` may otherwise refresh its index while merely measuring
|
|
61
|
+
// activity, making this cleanup itself keep an old worktree alive.
|
|
62
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
63
|
+
});
|
|
64
|
+
if (result.error)
|
|
65
|
+
return undefined;
|
|
66
|
+
return {
|
|
67
|
+
status: result.status,
|
|
68
|
+
stdout: result.stdout ?? '',
|
|
69
|
+
stderr: result.stderr ?? '',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function latestMtime(paths) {
|
|
77
|
+
let latest = 0;
|
|
78
|
+
for (const candidate of paths) {
|
|
79
|
+
try {
|
|
80
|
+
latest = Math.max(latest, fs.lstatSync(candidate).mtimeMs);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// A concurrently removed administrative file is not activity.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return latest;
|
|
87
|
+
}
|
|
88
|
+
function latestContentChange(paths) {
|
|
89
|
+
let latest = 0;
|
|
90
|
+
for (const candidate of paths) {
|
|
91
|
+
try {
|
|
92
|
+
const stat = fs.lstatSync(candidate);
|
|
93
|
+
latest = Math.max(latest, stat.mtimeMs, stat.ctimeMs);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Deleted paths are represented by their surviving parent directory.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return latest;
|
|
100
|
+
}
|
|
101
|
+
function changedWorktreePaths(status) {
|
|
102
|
+
const entries = status.split('\0');
|
|
103
|
+
const paths = [];
|
|
104
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
105
|
+
const entry = entries[index];
|
|
106
|
+
if (!entry || entry.length < 4 || entry[2] !== ' ')
|
|
107
|
+
continue;
|
|
108
|
+
paths.push(entry.slice(3));
|
|
109
|
+
// With -z, rename/copy records carry the second path in the next NUL
|
|
110
|
+
// field. Check both because either end may have recent activity.
|
|
111
|
+
if ((entry[0] === 'R' || entry[0] === 'C' || entry[1] === 'R' || entry[1] === 'C') && entries[index + 1]) {
|
|
112
|
+
paths.push(entries[index + 1]);
|
|
113
|
+
index += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return paths;
|
|
117
|
+
}
|
|
118
|
+
function isDirectChild(parent, candidate) {
|
|
119
|
+
return path.dirname(candidate) === parent;
|
|
120
|
+
}
|
|
121
|
+
function currentProcessIsInside(worktree) {
|
|
122
|
+
let resolvedWorktree;
|
|
123
|
+
try {
|
|
124
|
+
resolvedWorktree = fs.realpathSync(worktree);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
let currentCwd;
|
|
130
|
+
try {
|
|
131
|
+
currentCwd = process.cwd();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
for (const processPath of [currentCwd, process.argv[1]]) {
|
|
137
|
+
if (!processPath)
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
const resolvedProcessPath = fs.realpathSync(path.resolve(processPath));
|
|
141
|
+
if (resolvedProcessPath === resolvedWorktree ||
|
|
142
|
+
resolvedProcessPath.startsWith(`${resolvedWorktree}${path.sep}`)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// A missing argv path says nothing about whether this worktree is active.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
function managedWorktreeCandidates(hqRoot) {
|
|
153
|
+
const worktreesRoot = path.join(hqRoot, 'workspace', 'worktrees');
|
|
154
|
+
let repositories;
|
|
155
|
+
try {
|
|
156
|
+
repositories = fs.readdirSync(worktreesRoot, { withFileTypes: true });
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
const candidates = [];
|
|
162
|
+
for (const repository of repositories) {
|
|
163
|
+
if (!repository.isDirectory())
|
|
164
|
+
continue;
|
|
165
|
+
const repositoryRoot = path.join(worktreesRoot, repository.name);
|
|
166
|
+
let worktrees;
|
|
167
|
+
try {
|
|
168
|
+
worktrees = fs.readdirSync(repositoryRoot, { withFileTypes: true });
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
for (const worktree of worktrees) {
|
|
174
|
+
if (!worktree.isDirectory())
|
|
175
|
+
continue;
|
|
176
|
+
const candidate = path.join(repositoryRoot, worktree.name);
|
|
177
|
+
// Keep the cleanup constrained to the layout that `hq worktree` owns.
|
|
178
|
+
if (isDirectChild(repositoryRoot, candidate))
|
|
179
|
+
candidates.push(candidate);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return candidates;
|
|
183
|
+
}
|
|
184
|
+
function linkedWorktreeGitDir(worktree, deadline = Number.POSITIVE_INFINITY) {
|
|
185
|
+
const gitFile = path.join(worktree, '.git');
|
|
186
|
+
try {
|
|
187
|
+
if (!fs.lstatSync(gitFile).isFile())
|
|
188
|
+
return undefined;
|
|
189
|
+
const match = /^gitdir:\s*(.+)\s*$/i.exec(fs.readFileSync(gitFile, 'utf8'));
|
|
190
|
+
if (!match)
|
|
191
|
+
return undefined;
|
|
192
|
+
const gitDir = path.resolve(worktree, match[1]);
|
|
193
|
+
if (!fs.statSync(gitDir).isDirectory())
|
|
194
|
+
return undefined;
|
|
195
|
+
// A submodule checkout also has a .git *file*. A linked worktree is
|
|
196
|
+
// distinguished by its per-worktree administrative commondir file.
|
|
197
|
+
if (!fs.lstatSync(path.join(gitDir, 'commondir')).isFile())
|
|
198
|
+
return undefined;
|
|
199
|
+
const topLevel = runGit(worktree, ['rev-parse', '--show-toplevel'], deadline);
|
|
200
|
+
if (topLevel?.status !== 0 || !topLevel.stdout.trim())
|
|
201
|
+
return undefined;
|
|
202
|
+
return fs.realpathSync(topLevel.stdout.trim()) === fs.realpathSync(worktree) ? gitDir : undefined;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function nearestExistingAncestor(candidate, worktree) {
|
|
209
|
+
let current = candidate;
|
|
210
|
+
while (current !== worktree && current.startsWith(`${worktree}${path.sep}`)) {
|
|
211
|
+
if (fs.existsSync(current))
|
|
212
|
+
return current;
|
|
213
|
+
current = path.dirname(current);
|
|
214
|
+
}
|
|
215
|
+
return worktree;
|
|
216
|
+
}
|
|
217
|
+
function hasRecentWorktreeActivity(worktree, gitDir, cutoff, deadline = Number.POSITIVE_INFINITY) {
|
|
218
|
+
const gitFile = path.join(worktree, '.git');
|
|
219
|
+
const administrativeActivity = latestMtime([
|
|
220
|
+
worktree,
|
|
221
|
+
gitFile,
|
|
222
|
+
gitDir,
|
|
223
|
+
path.join(gitDir, 'HEAD'),
|
|
224
|
+
path.join(gitDir, 'index'),
|
|
225
|
+
path.join(gitDir, 'logs', 'HEAD'),
|
|
226
|
+
]);
|
|
227
|
+
if (administrativeActivity >= cutoff)
|
|
228
|
+
return true;
|
|
229
|
+
// Ask Git for only changed paths. `all` is necessary to see an untracked
|
|
230
|
+
// file inside an untracked directory; ignored dependency/build trees remain
|
|
231
|
+
// excluded, and we stat only the paths Git reports instead of walking trees.
|
|
232
|
+
const status = runGit(worktree, [
|
|
233
|
+
'status',
|
|
234
|
+
'--porcelain=v1',
|
|
235
|
+
'-z',
|
|
236
|
+
'--untracked-files=all',
|
|
237
|
+
'--ignored=no',
|
|
238
|
+
], deadline);
|
|
239
|
+
// A repository we cannot inspect is safer to retain than force-remove.
|
|
240
|
+
if (status?.status !== 0)
|
|
241
|
+
return true;
|
|
242
|
+
// Node replaces invalid UTF-8 bytes while decoding stdout. The resulting
|
|
243
|
+
// path is no longer safe to stat, so retain the worktree fail-closed.
|
|
244
|
+
if (status.stdout.includes('\uFFFD'))
|
|
245
|
+
return true;
|
|
246
|
+
const changedPaths = changedWorktreePaths(status.stdout);
|
|
247
|
+
// Git produced a record we do not understand. Fail closed instead of
|
|
248
|
+
// treating an unparsed dirty worktree as clean.
|
|
249
|
+
if (status.stdout.length > 0 && changedPaths.length === 0)
|
|
250
|
+
return true;
|
|
251
|
+
for (const relativePath of changedPaths) {
|
|
252
|
+
const candidate = path.resolve(worktree, relativePath);
|
|
253
|
+
if (candidate !== worktree && !candidate.startsWith(`${worktree}${path.sep}`))
|
|
254
|
+
continue;
|
|
255
|
+
try {
|
|
256
|
+
// Parent Git reports submodules and embedded repositories as aggregate
|
|
257
|
+
// directory paths. Descendant edits do not touch that directory's own
|
|
258
|
+
// timestamps, so retain these dirty aggregates rather than recurse into
|
|
259
|
+
// an unbounded tree on every reindex hook.
|
|
260
|
+
if (fs.lstatSync(candidate).isDirectory())
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Missing paths are deletions and are handled through their ancestors.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const dirtyPaths = changedPaths.flatMap((relativePath) => {
|
|
268
|
+
const candidate = path.resolve(worktree, relativePath);
|
|
269
|
+
if (candidate !== worktree && !candidate.startsWith(`${worktree}${path.sep}`))
|
|
270
|
+
return [];
|
|
271
|
+
return [candidate, nearestExistingAncestor(path.dirname(candidate), worktree)];
|
|
272
|
+
});
|
|
273
|
+
return latestContentChange(dirtyPaths) >= cutoff;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Remove inactive worktrees created by `hq worktree` without ever deleting
|
|
277
|
+
* directories directly. This runs on the hook path, so it uses a shallow
|
|
278
|
+
* layout scan and Git's status output rather than recursively walking trees.
|
|
279
|
+
*/
|
|
280
|
+
export function removeStaleHqWorktrees(hqRoot, now = Date.now(), deadline = Number.POSITIVE_INFINITY) {
|
|
281
|
+
if (!isHqRoot(hqRoot))
|
|
282
|
+
return;
|
|
283
|
+
const cutoff = now - WORKTREE_STALE_AFTER_MS;
|
|
284
|
+
for (const worktree of managedWorktreeCandidates(hqRoot)) {
|
|
285
|
+
if (Date.now() >= deadline)
|
|
286
|
+
break;
|
|
287
|
+
if (currentProcessIsInside(worktree))
|
|
288
|
+
continue;
|
|
289
|
+
const gitDir = linkedWorktreeGitDir(worktree, deadline);
|
|
290
|
+
if (!gitDir || hasRecentWorktreeActivity(worktree, gitDir, cutoff, deadline))
|
|
291
|
+
continue;
|
|
292
|
+
// Narrow the destructive TOCTOU window: re-resolve the linked-worktree
|
|
293
|
+
// identity and re-measure activity immediately before asking Git to remove
|
|
294
|
+
// it. Git remains the only component that deletes the directory.
|
|
295
|
+
const confirmedGitDir = linkedWorktreeGitDir(worktree, deadline);
|
|
296
|
+
if (!confirmedGitDir || hasRecentWorktreeActivity(worktree, confirmedGitDir, cutoff, deadline))
|
|
297
|
+
continue;
|
|
298
|
+
// Recursive removal is not transactional. Only start it inside the sweep
|
|
299
|
+
// budget, then let Git finish so a timeout cannot leave a registered,
|
|
300
|
+
// partially deleted worktree behind.
|
|
301
|
+
const removal = runGit(worktree, ['worktree', 'remove', '--force', worktree], deadline, true);
|
|
302
|
+
if (removal?.status === 0) {
|
|
303
|
+
console.log(`reindex: removed stale worktree ${worktree} (branch preserved; stale uncommitted files are not recoverable)`);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const reason = removal?.stderr.trim() || removal?.stdout.trim() || 'Git could not remove it';
|
|
307
|
+
console.warn(`reindex: could not remove stale worktree ${worktree}: ${reason}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
46
310
|
function isHqRoot(hqRoot) {
|
|
47
311
|
return (fs.existsSync(path.join(hqRoot, 'companies')) &&
|
|
48
312
|
(fs.existsSync(path.join(hqRoot, '.claude')) ||
|
|
@@ -254,8 +518,10 @@ export function registerReindexCommand(program) {
|
|
|
254
518
|
if (lockTimeoutSec !== undefined && process.env.HQ_OP_LOCK_TIMEOUT === undefined) {
|
|
255
519
|
process.env.HQ_OP_LOCK_TIMEOUT = String(lockTimeoutSec);
|
|
256
520
|
}
|
|
257
|
-
const { status } = reindex({ repoRoot: opts.repoRoot });
|
|
258
521
|
const hqRoot = resolveHqRoot(opts.repoRoot);
|
|
522
|
+
const sweepStartedAt = Date.now();
|
|
523
|
+
removeStaleHqWorktrees(hqRoot, sweepStartedAt, opts.fromHook ? sweepStartedAt + WORKTREE_HOOK_SWEEP_BUDGET_MS : Number.POSITIVE_INFINITY);
|
|
524
|
+
const { status } = reindex({ repoRoot: opts.repoRoot });
|
|
259
525
|
repairExtremeHookDrift(hqRoot, status === 0);
|
|
260
526
|
if (status === 0)
|
|
261
527
|
await trustHqRuntimeHooks(hqRoot);
|
|
@@ -2,6 +2,8 @@ import { type UtilityIo } from "./common.js";
|
|
|
2
2
|
export type TokenUsageReportOptions = UtilityIo & {
|
|
3
3
|
projectDir?: string;
|
|
4
4
|
now?: Date;
|
|
5
|
+
homeDir?: string;
|
|
6
|
+
menubarPath?: string;
|
|
5
7
|
};
|
|
6
8
|
/** Print token usage totals from Claude session JSONL files. */
|
|
7
9
|
export declare function tokenUsageReport(args?: string[], options?: TokenUsageReportOptions): number;
|
|
@@ -11,12 +11,27 @@ const parseJsonl = (file) => fs.readFileSync(file, "utf8").split(/\r?\n/).flatMa
|
|
|
11
11
|
catch {
|
|
12
12
|
return [];
|
|
13
13
|
} });
|
|
14
|
-
const listJsonl = (dir) => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
const listJsonl = (dir) => {
|
|
15
|
+
const found = [];
|
|
16
|
+
const visit = (current, relative) => {
|
|
17
|
+
let entries;
|
|
18
|
+
try {
|
|
19
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const nextRelative = path.join(relative, entry.name);
|
|
26
|
+
if (entry.isDirectory() && entry.name !== "subagents")
|
|
27
|
+
visit(path.join(current, entry.name), nextRelative);
|
|
28
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
29
|
+
found.push(nextRelative);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
visit(dir, "");
|
|
33
|
+
return found.sort();
|
|
34
|
+
};
|
|
20
35
|
const readNumber = (value) => typeof value === "number" ? value : 0;
|
|
21
36
|
function firstUser(record) { const content = record.message?.content; if (typeof content === "string")
|
|
22
37
|
return content.slice(0, 120); if (Array.isArray(content) && content.length) {
|
|
@@ -24,10 +39,42 @@ function firstUser(record) { const content = record.message?.content; if (typeof
|
|
|
24
39
|
if (first && typeof first === "object" && typeof first.text === "string")
|
|
25
40
|
return first.text.slice(0, 120);
|
|
26
41
|
} return ""; }
|
|
27
|
-
function
|
|
42
|
+
function savedClaudeProjectsDir(menubarPath) {
|
|
43
|
+
try {
|
|
44
|
+
const value = JSON.parse(fs.readFileSync(menubarPath, "utf8")).claudeProjectsDir;
|
|
45
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function resolveProjectDirs(options) {
|
|
52
|
+
if (options.projectDir)
|
|
53
|
+
return [path.resolve(options.projectDir)];
|
|
54
|
+
const projectsEnv = process.env.CLAUDE_PROJECTS_DIR?.trim();
|
|
55
|
+
if (projectsEnv)
|
|
56
|
+
return [path.resolve(projectsEnv)];
|
|
57
|
+
const home = options.homeDir ?? os.homedir();
|
|
58
|
+
const configEnv = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
59
|
+
if (configEnv)
|
|
60
|
+
return [path.resolve(configEnv, "projects")];
|
|
61
|
+
const roots = [
|
|
62
|
+
path.join(home, ".claude", "projects"),
|
|
63
|
+
savedClaudeProjectsDir(options.menubarPath ?? path.join(home, ".hq", "menubar.json")),
|
|
64
|
+
];
|
|
65
|
+
try {
|
|
66
|
+
for (const entry of fs.readdirSync(home, { withFileTypes: true })) {
|
|
67
|
+
if (entry.isDirectory() && (entry.name === ".claude" || entry.name.startsWith(".claude-")))
|
|
68
|
+
roots.push(path.join(home, entry.name, "projects"));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { /* standard and saved fallbacks still apply */ }
|
|
72
|
+
return [...new Set(roots.filter((value) => Boolean(value)).map((value) => path.resolve(value)))];
|
|
73
|
+
}
|
|
74
|
+
function comparison(projectDirs, before, after, minHours, json, stdout) {
|
|
28
75
|
const parseRange = (range) => range.split(":").map((value) => new Date(`${value}T00:00:00Z`));
|
|
29
76
|
const [bStart, bEnd] = parseRange(before), [aStart, aEnd] = parseRange(after);
|
|
30
|
-
const collect = (start, end) => listJsonl(projectDir).flatMap((name) => {
|
|
77
|
+
const collect = (start, end) => projectDirs.flatMap((projectDir) => listJsonl(projectDir).flatMap((name) => {
|
|
31
78
|
const rows = parseJsonl(path.join(projectDir, name));
|
|
32
79
|
let cr = 0, first, last;
|
|
33
80
|
for (const row of rows) {
|
|
@@ -47,7 +94,7 @@ function comparison(projectDir, before, after, minHours, json, stdout) {
|
|
|
47
94
|
if (hours < minHours)
|
|
48
95
|
return [];
|
|
49
96
|
return [{ sid: name.replace(/\.jsonl$/, "").slice(0, 8), hours: Math.round(hours * 10) / 10, cr, cr_per_hour: Math.trunc(cr / hours) }];
|
|
50
|
-
});
|
|
97
|
+
}));
|
|
51
98
|
const bRows = collect(bStart, bEnd), aRows = collect(aStart, aEnd);
|
|
52
99
|
const median = (rows) => { if (!rows.length)
|
|
53
100
|
return 0; const values = rows.map((row) => row.cr_per_hour).sort((a, b) => a - b); const mid = Math.floor(values.length / 2); return Math.trunc(values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2); };
|
|
@@ -111,62 +158,60 @@ export function tokenUsageReport(args = [], options = {}) {
|
|
|
111
158
|
return 1;
|
|
112
159
|
}
|
|
113
160
|
}
|
|
114
|
-
|
|
115
|
-
// the `}` in `{your-name}` terminates `${CLAUDE_PROJECTS_DIR:-…}` early, so
|
|
116
|
-
// its literal `-Documents-HQ}` suffix remains even when the environment
|
|
117
|
-
// variable is set. `projectDir` is the explicit native test/integration seam.
|
|
118
|
-
const projectDir = options.projectDir ?? `${process.env.CLAUDE_PROJECTS_DIR ?? path.join(os.homedir(), ".claude/projects/-Users-{your-name")}-Documents-HQ}`;
|
|
161
|
+
const projectDirs = resolveProjectDirs(options).filter((dir) => fs.existsSync(dir) && fs.statSync(dir).isDirectory());
|
|
119
162
|
if (before && after) {
|
|
120
|
-
comparison(
|
|
163
|
+
comparison(projectDirs, before, after, minHours, json, stdout);
|
|
121
164
|
return 0;
|
|
122
165
|
}
|
|
123
|
-
if (!
|
|
124
|
-
line(stderr, `
|
|
166
|
+
if (!projectDirs.length) {
|
|
167
|
+
line(stderr, `Claude activity folders not found`);
|
|
125
168
|
return 1;
|
|
126
169
|
}
|
|
127
170
|
const now = options.now ?? new Date();
|
|
128
171
|
const cutoff = since || dayOf(new Date(now.getTime() - (lastDays - 1) * 86_400_000));
|
|
129
172
|
const days = new Map(), sessions = new Map();
|
|
130
|
-
for (const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (
|
|
145
|
-
firstTs
|
|
146
|
-
|
|
173
|
+
for (const projectDir of projectDirs)
|
|
174
|
+
for (const name of listJsonl(projectDir)) {
|
|
175
|
+
const file = path.join(projectDir, name);
|
|
176
|
+
const stat = fs.statSync(file);
|
|
177
|
+
const mtime = dayOf(stat.mtime);
|
|
178
|
+
if (mtime < cutoff)
|
|
179
|
+
continue;
|
|
180
|
+
let inp = 0, out = 0, cc = 0, cr = 0, firstTs = "", lastTs = "", first = "";
|
|
181
|
+
for (const record of parseJsonl(file)) {
|
|
182
|
+
const usage = record.message?.usage;
|
|
183
|
+
inp += readNumber(usage?.input_tokens);
|
|
184
|
+
out += readNumber(usage?.output_tokens);
|
|
185
|
+
cc += readNumber(usage?.cache_creation_input_tokens);
|
|
186
|
+
cr += readNumber(usage?.cache_read_input_tokens);
|
|
187
|
+
if (record.timestamp) {
|
|
188
|
+
if (!firstTs)
|
|
189
|
+
firstTs = record.timestamp;
|
|
190
|
+
lastTs = record.timestamp;
|
|
191
|
+
}
|
|
192
|
+
if (!first && record.type === "user")
|
|
193
|
+
first = firstUser(record);
|
|
147
194
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
195
|
+
const localSid = name.replace(/\.jsonl$/, "");
|
|
196
|
+
const sid = `${path.basename(path.dirname(projectDir))}/${localSid}`;
|
|
197
|
+
const subagents = (() => { try {
|
|
198
|
+
return fs.readdirSync(path.join(projectDir, localSid, "subagents")).filter((entry) => entry.endsWith(".jsonl")).length;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return 0;
|
|
202
|
+
} })();
|
|
203
|
+
const day = (firstTs || lastTs || "").slice(0, 10) || mtime;
|
|
204
|
+
if (day < cutoff)
|
|
205
|
+
continue;
|
|
206
|
+
const current = days.get(day) ?? { sessions: new Set(), inp: 0, out: 0, cc: 0, cr: 0 };
|
|
207
|
+
current.sessions.add(sid);
|
|
208
|
+
current.inp += inp;
|
|
209
|
+
current.out += out;
|
|
210
|
+
current.cc += cc;
|
|
211
|
+
current.cr += cr;
|
|
212
|
+
days.set(day, current);
|
|
213
|
+
sessions.set(sid, { day, inp, out, cc, cr, eff: Math.trunc(inp + 5 * out + 1.25 * cc + .1 * cr), subagents, first_user: first.replaceAll("\n", " ") });
|
|
154
214
|
}
|
|
155
|
-
catch {
|
|
156
|
-
return 0;
|
|
157
|
-
} })();
|
|
158
|
-
const day = (firstTs || lastTs || "").slice(0, 10) || mtime;
|
|
159
|
-
if (day < cutoff)
|
|
160
|
-
continue;
|
|
161
|
-
const current = days.get(day) ?? { sessions: new Set(), inp: 0, out: 0, cc: 0, cr: 0 };
|
|
162
|
-
current.sessions.add(sid);
|
|
163
|
-
current.inp += inp;
|
|
164
|
-
current.out += out;
|
|
165
|
-
current.cc += cc;
|
|
166
|
-
current.cr += cr;
|
|
167
|
-
days.set(day, current);
|
|
168
|
-
sessions.set(sid, { day, inp, out, cc, cr, eff: Math.trunc(inp + 5 * out + 1.25 * cc + .1 * cr), subagents, first_user: first.replaceAll("\n", " ") });
|
|
169
|
-
}
|
|
170
215
|
const sorted = [...days.keys()].sort();
|
|
171
216
|
const dayRows = sorted.map((day) => { const value = days.get(day); return { date: day, sessions: value.sessions.size, input: value.inp, output: value.out, cache_create: value.cc, cache_read: value.cr, effective: Math.trunc(value.inp + 5 * value.out + 1.25 * value.cc + .1 * value.cr) }; });
|
|
172
217
|
const recent = sorted.at(-1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.105.1",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
32
32
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
33
|
-
"@indigoai-us/hq-cloud": "~6.16.
|
|
33
|
+
"@indigoai-us/hq-cloud": "~6.16.1",
|
|
34
34
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
35
35
|
"@sentry/node": "^10.49.0",
|
|
36
36
|
"@tobilu/qmd": "2.5.3",
|