log-llm-config 1.5.0 → 1.5.8

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.
@@ -6,7 +6,7 @@
6
6
  * When autofix returns restart_commands, this process does not spawn them — the shell hook pipes
7
7
  * the same JSON line to execute_trusted_restarts (TS allowlist + spawn).
8
8
  */
9
- import { applyAutofixViolations, confirmAppliedAutofixVerified, normalizeAgentToken, pruneSatisfiedOneTimeRemediations, reportPostRestartVerificationOutcomes, runLocalRemediationComplianceCheck, uploadSatisfiedManifestConfigs, } from './log_config_files/runtime/compliance_check.js';
9
+ import { applyAutofixViolations, confirmAppliedAutofixVerified, normalizeAgentToken, pruneSatisfiedOneTimeRemediations, reportCompliantRemediationVerifiedStatus, reportPostRestartVerificationOutcomes, runLocalRemediationComplianceCheck, uploadSatisfiedManifestConfigs, } from './log_config_files/runtime/compliance_check.js';
10
10
  import { isRemediationQuarantined } from './log_config_files/runtime/remediation_apply_tracking.js';
11
11
  import { existsSync, readFileSync, statSync } from 'node:fs';
12
12
  import { getRemediationInstructionsPath } from './log_config_files/runtime/management_storage.js';
@@ -16,8 +16,10 @@ import { finalizeAndUploadComplianceSessionLog, initComplianceSessionForGate, pa
16
16
  import { isThisCliModule } from './cli_invocation_match.js';
17
17
  import { ensureAuthentication } from './log_config_files/auth/auth_flow.js';
18
18
  import { sendConfigFile } from './log_config_files/sender/batch_sender.js';
19
+ import { parseJsonWithJsoncFallback } from './log_config_files/readers/file_readers.js';
20
+ import { PORTABLE_CURSOR_USER_SETTINGS, resolveRemediationConfigPath, resolveRemediationUploadFileType, } from './log_config_files/runtime/remediation_config_path.js';
19
21
  import { tryResolveHardwareUuid } from './log_config_files/runtime/hardware_uuid.js';
20
- import { syncRemediations } from './log_config_files/runtime/remediation_sync.js';
22
+ import { cursorAutofixDefersInlineVerify, syncRemediations, } from './log_config_files/runtime/remediation_sync.js';
21
23
  import { loadEndpointBase } from './log_config_files/sender/endpoint_config.js';
22
24
  import { formatRemediationChangePreviewForApplied } from './remediation_change_preview.js';
23
25
  const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -147,9 +149,9 @@ export function formatCopilotAutofixDialog(appliedViolations) {
147
149
  export function formatOpenCodeAutofixDialog(appliedViolations) {
148
150
  return formatPreventiveAutofixDialog(appliedViolations, 'OpenCode will now apply this policy to your environment.');
149
151
  }
150
- /** Cursor restart dialog after enforced/preventive remediation is applied locally. */
152
+ /** Cursor restart after local autofix (all JSON settings + vscdb paths use restart_commands). */
151
153
  export function formatCursorRestartAutofixDialog(appliedViolations) {
152
- return formatPreventiveAutofixDialog(appliedViolations, 'Cursor will now restart to apply this policy, and your context will be retained.');
154
+ return formatPreventiveAutofixDialog(appliedViolations, 'Cursor will restart so the running app reloads policy from disk. Your context will be retained.');
153
155
  }
154
156
  /**
155
157
  * Upload a secondary compliance file to the backend so the server can resolve the finding.
@@ -157,16 +159,23 @@ export function formatCursorRestartAutofixDialog(appliedViolations) {
157
159
  */
158
160
  async function _uploadSecondaryFile(entry) {
159
161
  const { uuid, config_file_path, file_type } = entry;
160
- if (!file_type) {
162
+ const uploadFileType = resolveRemediationUploadFileType(config_file_path, file_type ?? undefined);
163
+ if (!uploadFileType) {
161
164
  hookRunLog(`secondary_upload: skipping uuid=${uuid} — no file_type on secondary group`);
162
165
  return;
163
166
  }
167
+ const diskPath = resolveRemediationConfigPath(config_file_path);
164
168
  let rawContent;
165
169
  try {
166
- rawContent = JSON.parse(readFileSync(config_file_path, 'utf8'));
170
+ const parsed = parseJsonWithJsoncFallback(readFileSync(diskPath, 'utf8'));
171
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
172
+ hookRunLog(`secondary_upload: invalid JSON uuid=${uuid} path=${diskPath}`);
173
+ return;
174
+ }
175
+ rawContent = parsed;
167
176
  }
168
177
  catch {
169
- hookRunLog(`secondary_upload: could not read file uuid=${uuid} path=${config_file_path}`);
178
+ hookRunLog(`secondary_upload: could not read file uuid=${uuid} path=${diskPath}`);
170
179
  return;
171
180
  }
172
181
  const hw = tryResolveHardwareUuid();
@@ -174,13 +183,16 @@ async function _uploadSecondaryFile(entry) {
174
183
  hookRunLog(`secondary_upload: hardware UUID unavailable, skipping uuid=${uuid}`);
175
184
  return;
176
185
  }
186
+ const portablePath = config_file_path.includes('Cursor/User/settings.json')
187
+ ? PORTABLE_CURSOR_USER_SETTINGS
188
+ : config_file_path.trim() || diskPath;
177
189
  try {
178
190
  const authKey = await ensureAuthentication(hw);
179
- const sent = await sendConfigFile({ file_type, file_path: config_file_path, raw_content: rawContent }, hw, authKey);
180
- hookRunLog(`secondary_upload: uuid=${uuid} path=${config_file_path} sent=${sent}`);
191
+ const sent = await sendConfigFile({ file_type: uploadFileType, file_path: portablePath, raw_content: rawContent }, hw, authKey);
192
+ hookRunLog(`secondary_upload: uuid=${uuid} path=${portablePath} sent=${sent}`);
181
193
  }
182
194
  catch (err) {
183
- hookRunLog(`secondary_upload: upload failed uuid=${uuid} path=${config_file_path} err=${err instanceof Error ? err.message : String(err)}`);
195
+ hookRunLog(`secondary_upload: upload failed uuid=${uuid} path=${portablePath} err=${err instanceof Error ? err.message : String(err)}`);
184
196
  }
185
197
  }
186
198
  /**
@@ -218,8 +230,6 @@ export async function runCompliancePromptGate() {
218
230
  hookLogSessionBanner('compliance_prompt_gate (before submit)');
219
231
  }
220
232
  let status = runLocalRemediationComplianceCheck(agent);
221
- // Post-restart verify + server reports BEFORE sync so a manifest UUID swap cannot delay
222
- // verified / activity-log until the following prompt.
223
233
  const postRestartVerify = reportPostRestartVerificationOutcomes(status.violations);
224
234
  if (postRestartVerify.outcomes.length > 0) {
225
235
  await Promise.allSettled(postRestartVerify.reportPromises);
@@ -306,12 +316,14 @@ export async function runCompliancePromptGate() {
306
316
  // disk synchronously; a restart_command, if present, only reloads the already-compliant file
307
317
  // into the running app — it is not the mechanism that makes the fix take effect. So report
308
318
  // "verified" now instead of leaving the finding "pending" in the UI until the next prompt.
309
- // Cursor's restart can carry a deferred state.vscdb write that only lands post-restart, so
310
- // Cursor still verifies inline only when no restart is pending.
319
+ // Cursor JSON: disk recheck OK → verified before restart. Cursor deferred vscdb verify after restart.
311
320
  const immediateJsonAgent = ide === 'claude' || ide === 'copilot' || ide === 'opencode';
321
+ const diskProvenCompliant = recheckOk || claudeRecheckStaleAfterImmediateApply;
322
+ const cursorRestartVerifyPending = cursorAutofixDefersInlineVerify(ide, restartCommands.length, deferredSqlitePending === true, diskProvenCompliant);
312
323
  const immediateVerified = !deferredSqlitePending &&
313
- (recheckOk || claudeRecheckStaleAfterImmediateApply) &&
314
- (restartCommands.length === 0 || immediateJsonAgent);
324
+ !cursorRestartVerifyPending &&
325
+ diskProvenCompliant &&
326
+ (restartCommands.length === 0 || immediateJsonAgent || ide === 'cursor');
315
327
  if (immediateVerified) {
316
328
  confirmAppliedAutofixVerified(appliedViolations, reportPromises);
317
329
  await Promise.allSettled(reportPromises);
@@ -376,6 +388,7 @@ export async function runCompliancePromptGate() {
376
388
  await Promise.allSettled(pruned.reportPromises);
377
389
  }
378
390
  await Promise.allSettled(uploadSatisfiedManifestConfigs(agent));
391
+ await Promise.allSettled(reportCompliantRemediationVerifiedStatus(agent));
379
392
  printAllow(ide);
380
393
  }
381
394
  if (isRunAsCliModule()) {
@@ -0,0 +1,156 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ /**
5
+ * Token-usage / session / CLI-version aggregator for Claude Code.
6
+ *
7
+ * Claude Code writes one JSONL transcript per session under ~/.claude/projects/**. Each
8
+ * assistant line carries a `message.usage` block ({input,output,cache_*}_tokens), and every
9
+ * line carries the CLI `version` and `sessionId`. We aggregate the *numbers* (and version /
10
+ * model ids) on the endpoint and emit only those — never transcript content — so the backend
11
+ * gets token usage, session count, and a reliable CLI version without ingesting prompts.
12
+ *
13
+ * Bounded by mtime recency so a large transcript history (can be hundreds of MB) does not get
14
+ * fully re-parsed on every hook run; this yields "recent activity" usage and the current version.
15
+ */
16
+ const TOKEN_USAGE_RECENCY_DAYS = 30;
17
+ const TOKEN_USAGE_FILE_TYPE = 'claude_token_usage';
18
+ function emptyTotals() {
19
+ return {
20
+ input_tokens: 0,
21
+ output_tokens: 0,
22
+ cache_creation_input_tokens: 0,
23
+ cache_read_input_tokens: 0,
24
+ };
25
+ }
26
+ function addUsage(totals, usage) {
27
+ for (const key of Object.keys(totals)) {
28
+ const value = usage[key];
29
+ if (typeof value === 'number' && Number.isFinite(value))
30
+ totals[key] += value;
31
+ }
32
+ }
33
+ /** Sum the token fields of one usage block (used for per-model attribution). */
34
+ function usageSum(usage) {
35
+ let sum = 0;
36
+ for (const key of ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']) {
37
+ const value = usage[key];
38
+ if (typeof value === 'number' && Number.isFinite(value))
39
+ sum += value;
40
+ }
41
+ return sum;
42
+ }
43
+ /** Recursively collect *.jsonl files under a directory whose mtime is within the recency window. */
44
+ function recentJsonlFiles(dir, cutoffMs) {
45
+ const out = [];
46
+ let entries;
47
+ try {
48
+ entries = readdirSync(dir, { withFileTypes: true });
49
+ }
50
+ catch {
51
+ return out;
52
+ }
53
+ for (const entry of entries) {
54
+ const full = join(dir, entry.name);
55
+ if (entry.isDirectory()) {
56
+ out.push(...recentJsonlFiles(full, cutoffMs));
57
+ }
58
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
59
+ try {
60
+ if (statSync(full).mtimeMs >= cutoffMs)
61
+ out.push(full);
62
+ }
63
+ catch {
64
+ /* unreadable file — skip */
65
+ }
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * Aggregate token usage / session count / version across recent Claude session transcripts.
72
+ * Returns a single-element array (one per-machine aggregate) or [] when there is no recent data.
73
+ */
74
+ function collectClaudeTokenUsage(home = homedir()) {
75
+ const projectsDir = join(home, '.claude', 'projects');
76
+ if (!existsSync(projectsDir))
77
+ return [];
78
+ const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
79
+ const files = recentJsonlFiles(projectsDir, cutoffMs);
80
+ if (files.length === 0)
81
+ return [];
82
+ const totals = emptyTotals();
83
+ const sessions = new Set();
84
+ const models = new Set();
85
+ const modelTokenSplit = {};
86
+ let version = '';
87
+ let versionAt = '';
88
+ let sawData = false;
89
+ for (const file of files) {
90
+ let content;
91
+ try {
92
+ content = readFileSync(file, 'utf8');
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ for (const line of content.split('\n')) {
98
+ const trimmed = line.trim();
99
+ if (!trimmed)
100
+ continue;
101
+ let entry;
102
+ try {
103
+ entry = JSON.parse(trimmed);
104
+ }
105
+ catch {
106
+ continue;
107
+ }
108
+ sawData = true;
109
+ const sessionId = entry.sessionId;
110
+ if (typeof sessionId === 'string' && sessionId)
111
+ sessions.add(sessionId);
112
+ // Keep the version from the most recent line (timestamps are ISO-8601, sort lexically).
113
+ const lineVersion = entry.version;
114
+ const lineAt = typeof entry.timestamp === 'string' ? entry.timestamp : '';
115
+ if (typeof lineVersion === 'string' && lineVersion && lineAt >= versionAt) {
116
+ version = lineVersion;
117
+ versionAt = lineAt;
118
+ }
119
+ const message = entry.message;
120
+ if (message && typeof message === 'object') {
121
+ const msg = message;
122
+ // Skip Claude Code's internal placeholder model ids (e.g. "<synthetic>").
123
+ const model = typeof msg.model === 'string' && msg.model && !msg.model.startsWith('<') ? msg.model : '';
124
+ if (model)
125
+ models.add(model);
126
+ if (msg.usage && typeof msg.usage === 'object') {
127
+ const usage = msg.usage;
128
+ addUsage(totals, usage);
129
+ if (model) {
130
+ const sum = usageSum(usage);
131
+ if (sum > 0)
132
+ modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ if (!sawData)
139
+ return [];
140
+ return [
141
+ {
142
+ file_type: TOKEN_USAGE_FILE_TYPE,
143
+ // Stable synthetic path → one aggregate row per machine.
144
+ file_path: projectsDir,
145
+ raw_content: {
146
+ version,
147
+ session_count: sessions.size,
148
+ models: [...models].sort(),
149
+ model_token_split: modelTokenSplit,
150
+ window_days: TOKEN_USAGE_RECENCY_DAYS,
151
+ totals,
152
+ },
153
+ },
154
+ ];
155
+ }
156
+ export { collectClaudeTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
@@ -0,0 +1,245 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ /**
5
+ * Token-usage / session / version / identity aggregator for OpenAI Codex CLI.
6
+ *
7
+ * Codex writes one JSONL rollout per session under ~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl
8
+ * (and ~/.codex/archived_sessions/). A `session_meta` line carries `cli_version` + `model_provider`;
9
+ * `token_count` lines carry a *cumulative* `total_token_usage` ({input,cached_input,output,
10
+ * reasoning_output}_tokens) — so the session total is its LAST token_count, summed across sessions.
11
+ *
12
+ * Identity lives in ~/.codex/auth.json as a JWT (`auth_mode` + an `id_token` whose claims hold
13
+ * email / name / chatgpt_plan_type / account). We decode the id_token's claims locally and emit
14
+ * ONLY those (email, name, plan, account) — never the token, access_token, or API key. Configured
15
+ * model is read from ~/.codex/config.toml. Numbers + identity claims only — no transcript content.
16
+ *
17
+ * Bounded by rollout-file mtime recency so a long session history isn't fully re-parsed each run.
18
+ */
19
+ const TOKEN_USAGE_RECENCY_DAYS = 30;
20
+ const TOKEN_USAGE_FILE_TYPE = 'codex_token_usage';
21
+ function emptyTotals() {
22
+ return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
23
+ }
24
+ function num(value) {
25
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
26
+ }
27
+ function asObject(value) {
28
+ return value && typeof value === 'object' ? value : {};
29
+ }
30
+ /** Recursively collect rollout-*.jsonl files under a dir whose mtime is within the window. */
31
+ function recentRolloutFiles(dir, cutoffMs) {
32
+ const out = [];
33
+ let entries;
34
+ try {
35
+ entries = readdirSync(dir, { withFileTypes: true });
36
+ }
37
+ catch {
38
+ return out;
39
+ }
40
+ for (const entry of entries) {
41
+ const full = join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ out.push(...recentRolloutFiles(full, cutoffMs));
44
+ }
45
+ else if (entry.isFile() &&
46
+ entry.name.startsWith('rollout-') &&
47
+ entry.name.endsWith('.jsonl')) {
48
+ try {
49
+ if (statSync(full).mtimeMs >= cutoffMs)
50
+ out.push(full);
51
+ }
52
+ catch {
53
+ /* unreadable — skip */
54
+ }
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+ /** Decode a JWT's payload claims (no verification — we only read non-secret identity claims). */
60
+ function decodeJwtClaims(jwt) {
61
+ const parts = jwt.split('.');
62
+ if (parts.length !== 3)
63
+ return {};
64
+ try {
65
+ let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
66
+ b64 += '='.repeat((4 - (b64.length % 4)) % 4);
67
+ return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ }
73
+ /** Identity from ~/.codex/auth.json: auth_mode + id_token claims (email/name/plan/account). */
74
+ function readCodexIdentity(home) {
75
+ const out = {};
76
+ let auth;
77
+ try {
78
+ auth = JSON.parse(readFileSync(join(home, '.codex', 'auth.json'), 'utf8'));
79
+ }
80
+ catch {
81
+ return out;
82
+ }
83
+ if (typeof auth.auth_mode === 'string' && auth.auth_mode)
84
+ out.auth_mode = auth.auth_mode;
85
+ const idToken = asObject(auth.tokens).id_token;
86
+ if (typeof idToken !== 'string' || !idToken)
87
+ return out;
88
+ const claims = decodeJwtClaims(idToken);
89
+ if (typeof claims.email === 'string')
90
+ out.email = claims.email;
91
+ if (typeof claims.name === 'string')
92
+ out.name = claims.name;
93
+ const openaiAuth = asObject(claims['https://api.openai.com/auth']);
94
+ if (typeof openaiAuth.chatgpt_plan_type === 'string')
95
+ out.plan = openaiAuth.chatgpt_plan_type;
96
+ if (typeof openaiAuth.chatgpt_account_id === 'string') {
97
+ out.account_id = openaiAuth.chatgpt_account_id;
98
+ }
99
+ const orgs = openaiAuth.organizations;
100
+ if (Array.isArray(orgs) && orgs.length > 0) {
101
+ const org = asObject(orgs[0]);
102
+ if (typeof org.id === 'string')
103
+ out.account_id = out.account_id || org.id;
104
+ if (typeof org.title === 'string')
105
+ out.org_name = org.title;
106
+ }
107
+ return out;
108
+ }
109
+ /** Configured model from ~/.codex/config.toml (TOML — a simple `model = "..."` line). */
110
+ function readConfiguredModel(home) {
111
+ try {
112
+ const toml = readFileSync(join(home, '.codex', 'config.toml'), 'utf8');
113
+ const m = toml.match(/^\s*model\s*=\s*["']([^"']+)["']/m);
114
+ return m ? m[1] : '';
115
+ }
116
+ catch {
117
+ return '';
118
+ }
119
+ }
120
+ /**
121
+ * Aggregate Codex token usage / sessions / version across recent rollout transcripts + identity.
122
+ * Returns a single-element array (one per-machine aggregate) or [] when there is no recent data.
123
+ */
124
+ function collectCodexTokenUsage(home = homedir()) {
125
+ const codexDir = join(home, '.codex');
126
+ const sessionRoots = [join(codexDir, 'sessions'), join(codexDir, 'archived_sessions')];
127
+ if (!sessionRoots.some((d) => existsSync(d)) && !existsSync(join(codexDir, 'auth.json'))) {
128
+ return [];
129
+ }
130
+ const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
131
+ const files = sessionRoots.flatMap((d) => (existsSync(d) ? recentRolloutFiles(d, cutoffMs) : []));
132
+ const totals = emptyTotals();
133
+ const models = new Set();
134
+ const providers = new Set();
135
+ const modelTokenSplit = {};
136
+ const sessions = new Set();
137
+ // Per-session cumulative usage, keyed by session id (else file path). total_token_usage is
138
+ // cumulative within a session, so the same session appearing in multiple rollout files
139
+ // (sessions/ + archived_sessions/) must be deduped — keep the largest snapshot rather than
140
+ // summing the files, which would double-count.
141
+ const sessionTotals = new Map();
142
+ let version = '';
143
+ let versionAt = '';
144
+ for (const file of files) {
145
+ let content;
146
+ try {
147
+ content = readFileSync(file, 'utf8');
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ let sessionModel = '';
153
+ let sessionTotal = null; // last (cumulative) token usage
154
+ let sessionId = '';
155
+ for (const line of content.split('\n')) {
156
+ const trimmed = line.trim();
157
+ if (!trimmed)
158
+ continue;
159
+ let entry;
160
+ try {
161
+ entry = JSON.parse(trimmed);
162
+ }
163
+ catch {
164
+ continue;
165
+ }
166
+ const payload = asObject(entry.payload);
167
+ const ts = typeof entry.timestamp === 'string' ? entry.timestamp : '';
168
+ if (entry.type === 'session_meta') {
169
+ if (typeof payload.session_id === 'string')
170
+ sessionId = payload.session_id;
171
+ if (typeof payload.cli_version === 'string' && payload.cli_version && ts >= versionAt) {
172
+ version = payload.cli_version;
173
+ versionAt = ts;
174
+ }
175
+ if (typeof payload.model_provider === 'string' && payload.model_provider) {
176
+ providers.add(payload.model_provider);
177
+ }
178
+ }
179
+ // Model id can appear on turn/response lines as payload.model.
180
+ if (typeof payload.model === 'string' && payload.model) {
181
+ models.add(payload.model);
182
+ if (!sessionModel)
183
+ sessionModel = payload.model;
184
+ }
185
+ if (payload.type === 'token_count') {
186
+ const usage = asObject(asObject(payload.info).total_token_usage);
187
+ if (Object.keys(usage).length) {
188
+ // Cumulative within the session — keep the latest seen.
189
+ sessionTotal = {
190
+ input: num(usage.input_tokens),
191
+ cached: num(usage.cached_input_tokens),
192
+ output: num(usage.output_tokens) + num(usage.reasoning_output_tokens),
193
+ };
194
+ }
195
+ }
196
+ }
197
+ const sessionKey = sessionId || file;
198
+ sessions.add(sessionKey);
199
+ if (sessionTotal) {
200
+ const sum = sessionTotal.input + sessionTotal.cached + sessionTotal.output;
201
+ const prev = sessionTotals.get(sessionKey);
202
+ // Keep the largest cumulative snapshot for a session so a duplicate file doesn't double-count.
203
+ if (!prev || sum > prev.sum) {
204
+ sessionTotals.set(sessionKey, {
205
+ model: sessionModel,
206
+ input: sessionTotal.input,
207
+ cached: sessionTotal.cached,
208
+ output: sessionTotal.output,
209
+ sum,
210
+ });
211
+ }
212
+ }
213
+ }
214
+ for (const st of sessionTotals.values()) {
215
+ totals.input_tokens += st.input;
216
+ totals.cache_read_input_tokens += st.cached;
217
+ totals.output_tokens += st.output;
218
+ if (st.model && st.sum > 0) {
219
+ modelTokenSplit[st.model] = (modelTokenSplit[st.model] ?? 0) + st.sum;
220
+ }
221
+ }
222
+ const identity = readCodexIdentity(home);
223
+ const configuredModel = readConfiguredModel(home);
224
+ const sawUsage = files.length > 0;
225
+ if (!sawUsage && !identity.email)
226
+ return [];
227
+ return [
228
+ {
229
+ file_type: TOKEN_USAGE_FILE_TYPE,
230
+ file_path: codexDir, // stable synthetic path → one aggregate row per machine
231
+ raw_content: {
232
+ version,
233
+ session_count: sessions.size,
234
+ models: [...models].sort(),
235
+ providers: [...providers].sort(),
236
+ model_token_split: modelTokenSplit,
237
+ configured_model: configuredModel,
238
+ window_days: TOKEN_USAGE_RECENCY_DAYS,
239
+ totals,
240
+ ...identity, // auth_mode, email, name, plan, account_id, org_name (claims only)
241
+ },
242
+ },
243
+ ];
244
+ }
245
+ export { collectCodexTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
@@ -86,6 +86,12 @@ function readContentByFormat(path, format) {
86
86
  // json (default) — fall back to markdown for .md files from agents not yet annotated
87
87
  return readJSONFile(path) ?? readMCPConfig(path) ?? (path.endsWith('.md') ? readMarkdownFile(path) : null);
88
88
  }
89
+ function isPortableCursorUserSettingsTarget(t) {
90
+ const p = (t.logicalFilePath ?? t.path).replace(/\\/g, '/').toLowerCase();
91
+ return (p === 'cursor/user/settings.json' ||
92
+ p.endsWith('/cursor/user/settings.json') ||
93
+ p.includes('application support/cursor/user/settings.json'));
94
+ }
89
95
  function collectRegularFileEntry(t, enrichByFileType) {
90
96
  const format = t.content_format || 'json';
91
97
  let content = readContentByFormat(t.path, format);
@@ -120,6 +126,12 @@ function collectRegularFileEntry(t, enrichByFileType) {
120
126
  return null;
121
127
  raw = filtered;
122
128
  }
129
+ if (t.file_type === 'vscode_settings' &&
130
+ isPortableCursorUserSettingsTarget(t) &&
131
+ Object.keys(raw).length === 0) {
132
+ console.warn(`Skipping empty Cursor User/settings.json upload (path=${t.path}); global ignore would be invisible to the server.`);
133
+ return null;
134
+ }
123
135
  return { file_type: t.file_type, file_path: t.logicalFilePath ?? t.path, raw_content: raw };
124
136
  }
125
137
  function collectMetadataEntry(t) {
@@ -215,6 +227,14 @@ export { collectConfigFilesFromPatterns };
215
227
  export { collectMcpToolFiles } from './mcp_tool_collector.js';
216
228
  export { collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles } from './plugin_collector.js';
217
229
  export { collectMcpFromClaudeJsonProjects } from './claude_known_projects_collector.js';
230
+ export { collectClaudeTokenUsage } from './claude_token_usage_collector.js';
231
+ export { collectCopilotTokenUsage } from './copilot_token_usage_collector.js';
232
+ export { collectOpencodeTokenUsage } from './opencode_token_usage_collector.js';
233
+ export { collectCodexTokenUsage } from './codex_token_usage_collector.js';
234
+ export { collectCursorTokenUsage } from './cursor_token_usage_collector.js';
235
+ export { collectHermesTokenUsage } from './hermes_token_usage_collector.js';
236
+ export { collectOpenclawTokenUsage } from './openclaw_token_usage_collector.js';
237
+ export { collectPiTokenUsage } from './pi_token_usage_collector.js';
218
238
  export { collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, mergeClaudeDesktopExtensionEnableSettings, } from './claude_desktop_extensions_collector.js';
219
239
  export { collectCursorProjectWorkspaceMcpConfigs } from './cursor_project_mcp_collector.js';
220
240
  export { determineFileTypeFromPath } from './file_type_rules.js';