-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathio.js
More file actions
299 lines (288 loc) · 14.2 KB
/
Copy pathio.js
File metadata and controls
299 lines (288 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { atomicWrite, logError, C, safeSlice, redact } = require('./utils');
// ─── Settings (mtime-cached) ──────────────────────────────
const _settingsPath = path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'settings.json',
);
let _settingsCache = null;
let _settingsMtime = 0;
// statusline-local config (sibling to this file) — kept separate from CC settings.json
// because CC's schema rejects unknown top-level keys. aggWindowDays lives here.
const _localCfgPath = path.join(__dirname, 'config.json');
let _localCfgCache = null;
let _localCfgMtime = 0;
function _readLocalCfg() {
try {
const st = fs.statSync(_localCfgPath);
if (_localCfgCache && st.mtimeMs === _localCfgMtime) return _localCfgCache;
const raw = JSON.parse(fs.readFileSync(_localCfgPath, 'utf8'));
_localCfgCache = raw && typeof raw === 'object' ? raw : {};
_localCfgMtime = st.mtimeMs;
return _localCfgCache;
} catch { return _localCfgCache || {}; }
}
// aggWindowDays: integer days for cost / cache cleanup. 0 = all-time, no filter.
// Read from statusline-local config.json so it lives next to the code that uses it
// (CC's settings.json schema rejects unknown top-level keys, so we can't put it there).
// Overlaid on every readSettings() call — has its own mtime cache, so it's cheap.
function _aggWindowDaysFromCfg() {
const aw = _readLocalCfg().aggWindowDays;
return Number.isFinite(aw) && aw >= 0 ? Math.floor(aw) : 30;
}
function readSettings() {
const defaults = {
effort: '', fastMode: false, aggWindowDays: 30,
thresholds: { costSession: [15, 30], costMonthly: [300, 800], rateLimit: [50, 80], push: [3, 10] },
};
try {
const st = fs.statSync(_settingsPath);
if (_settingsCache && st.mtimeMs === _settingsMtime) {
return { ..._settingsCache, aggWindowDays: _aggWindowDaysFromCfg() };
}
const s = JSON.parse(fs.readFileSync(_settingsPath, 'utf8'));
const out = { ...defaults };
out.effort = s.effortLevel || '';
out.fastMode = s.fastMode === true;
const th = s.statusline?.thresholds;
if (th && typeof th === 'object') {
for (const k of ['costSession', 'costMonthly', 'rateLimit', 'push']) {
const v = th[k];
if (Array.isArray(v) && v.length === 2 && Number.isFinite(v[0]) && Number.isFinite(v[1]) && v[0] <= v[1]) {
out.thresholds[k] = v;
} else if (v !== undefined) {
logError('settings-threshold', new Error(`invalid ${k}`));
}
}
}
_settingsCache = out;
_settingsMtime = st.mtimeMs;
return { ...out, aggWindowDays: _aggWindowDaysFromCfg() };
} catch (e) {
if (e.code !== 'ENOENT') logError('settings', e);
const base = _settingsCache || defaults;
return { ...base, aggWindowDays: _aggWindowDaysFromCfg() };
}
}
// ─── Rolling cost (configurable window, race-safe, delta-tracked) ─────
// Delta tracking: CC sometimes resets `cost.total_cost_usd` mid-session
// (compaction, auto-recovery). We split each session entry into
// { baseCost, currentCost } — when the payload value drops, we move the
// previous current into base. Total = baseCost + currentCost; never regresses.
// Legacy entries `{ cost }` are migrated transparently on first touch.
function _entryTotal(s) {
if (!s) return 0;
if (typeof s.cost === 'number') return s.cost; // legacy
return (s.baseCost || 0) + (s.currentCost || 0);
}
function trackMonthlyCost(cost, sessionId, claudeDir, aggWindowDays = 30) {
const costCachePath = path.join(claudeDir, 'cache', 'cost-monthly.json');
const readCache = () => { try { return JSON.parse(fs.readFileSync(costCachePath, 'utf8')); } catch { return {}; } };
try {
let cache = readCache();
if (!cache.sessions) cache.sessions = {};
const cutoff = aggWindowDays > 0 ? Date.now() - aggWindowDays * 86400000 : 0;
let needsWrite = false;
if (sessionId && cost != null) {
const prev = cache.sessions[sessionId];
if (!prev) needsWrite = true;
else if (typeof prev.cost === 'number') needsWrite = true; // legacy → migrate
else if ((prev.currentCost || 0) !== cost) needsWrite = true;
}
if (cutoff > 0) {
for (const s of Object.values(cache.sessions)) {
if (s.date < cutoff) { needsWrite = true; break; }
}
}
if (needsWrite) {
// Re-read right before write to minimize races with concurrent sessions.
// Residual TOCTOU remains: another process can write between this re-read
// and atomicWrite, dropping that update. Tolerável — cada sessão regrava o
// próprio valor na tick seguinte, então o cache é eventualmente consistente.
cache = readCache();
if (!cache.sessions) cache.sessions = {};
if (sessionId && cost != null) {
const prev = cache.sessions[sessionId];
let baseCost = 0, currentCost = cost;
if (prev && typeof prev.cost === 'number') {
// Legacy migration: treat the old absolute value as currentCost only
// if the new payload is >= old (no regression yet); otherwise promote
// it to baseCost so we don't lose history when CC reset mid-session.
if (cost >= prev.cost) { baseCost = 0; currentCost = cost; }
else { baseCost = prev.cost; currentCost = cost; }
} else if (prev) {
baseCost = prev.baseCost || 0;
const prevCur = prev.currentCost || 0;
if (cost < prevCur) { baseCost += prevCur; currentCost = cost; }
else { currentCost = cost; }
}
cache.sessions[sessionId] = { baseCost, currentCost, date: Date.now() };
}
if (cutoff > 0) {
for (const [id, s] of Object.entries(cache.sessions)) {
if (s.date < cutoff) delete cache.sessions[id];
}
}
atomicWrite(costCachePath, JSON.stringify(cache));
}
// ponytail: dedup de custo herdado. Sessões resumidas/forkadas podem
// reaparecer com o total_cost_usd de outra sessão (o CC expõe o custo do
// "pai" antes da nova firmar o seu) → mesmo gasto contado 2x. Sinal: custo
// float idêntico a 8 casas E escrito dentro de DEDUP_WINDOW_MS de uma
// entrada já contada (o fork é observado quase junto com o pai). A janela
// evita colapsar sessões triviais distintas que coincidam no custo mas
// rodaram em horas diferentes.
// Tetos conhecidos: (a) se o pai CONTINUA após o fork, o fantasma congela
// num valor menor e diverge — vira entrada espúria não detectável aqui;
// (b) duas sessões triviais idênticas dentro da janela colapsam. Upgrade:
// lineage real se o CC expor parent session id no payload.
const DEDUP_WINDOW_MS = 5 * 60 * 1000;
let total = 0;
const seenByCost = new Map(); // costKey -> [datas já contadas]
for (const s of Object.values(cache.sessions)) {
const v = _entryTotal(s);
if (v === 0) continue;
const k = v.toFixed(8);
const when = s.date || 0;
const dates = seenByCost.get(k);
if (dates && dates.some(d => Math.abs(d - when) <= DEDUP_WINDOW_MS)) continue;
total += v;
if (dates) dates.push(when); else seenByCost.set(k, [when]);
}
return total;
} catch (e) { logError('cost-cache', e); return 0; }
}
// ─── Cross-session rate-limit aggregation ─────────────────
// CC payload reflects only this session's last API observation. With multiple
// parallel sessions, each terminal sees its own % and they diverge. We snapshot
// every session's reading and aggregate MAX(used_percentage) across the snapshots
// whose resets_at matches the most-recent observation — converging all terminals
// onto the same number that actually matches the account-wide quota.
function trackRateLimitSnapshot(rateLimits, sessionId, claudeDir) {
if (!rateLimits || !sessionId) return null;
const file = path.join(claudeDir, 'cache', 'rate-limit-snapshots.json');
const read = () => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; } };
try {
let cache = read();
if (!cache.sessions) cache.sessions = {};
const now = Date.now();
const ttl = 24 * 86400000; // drop snapshots older than 24h
const mine = {
observed_at: now,
five_hour: rateLimits.five_hour
? { used_percentage: rateLimits.five_hour.used_percentage, resets_at: rateLimits.five_hour.resets_at }
: null,
seven_day: rateLimits.seven_day
? { used_percentage: rateLimits.seven_day.used_percentage, resets_at: rateLimits.seven_day.resets_at }
: null,
};
const prev = cache.sessions[sessionId];
const same = prev && JSON.stringify({ ...prev, observed_at: 0 }) === JSON.stringify({ ...mine, observed_at: 0 });
if (!same) {
cache = read(); // re-read antes do write; janela TOCTOU residual auto-corrige (cada sessão regrava)
if (!cache.sessions) cache.sessions = {};
cache.sessions[sessionId] = mine;
for (const [id, s] of Object.entries(cache.sessions)) {
if (!s.observed_at || now - s.observed_at > ttl) delete cache.sessions[id];
}
atomicWrite(file, JSON.stringify(cache));
}
// Aggregate: pick the live window (max future resets_at — isolates week-turnover,
// old-window snapshots carry a different resets_at and never leak in), then take
// MAX used_percentage among snapshots observed in the last 2h. The 2h window drops
// pre-reset peaks after an admin balance reset (pct drops, resets_at stays) so they
// stop poisoning the number via MAX. When nothing is recent, fall back to the freshest
// snapshot — avoids under-reporting from an idle session still carrying a stale-low pct.
const RECENT_MS = 2 * 3600000; // ponytail: convergence window; admin reset auto-corrects in <=2h instead of the 24h TTL
const agg = (window) => {
const nowSec = Math.floor(now / 1000);
let bestReset = 0;
for (const s of Object.values(cache.sessions)) {
const w = s[window];
if (!w || w.used_percentage == null || !w.resets_at) continue;
if (w.resets_at >= nowSec && w.resets_at > bestReset) bestReset = w.resets_at;
}
if (!bestReset) return null;
let recentPct = null, freshPct = null, freshAt = 0;
for (const s of Object.values(cache.sessions)) {
const w = s[window];
if (!w || w.used_percentage == null || w.resets_at !== bestReset) continue;
const obs = s.observed_at || 0;
if (now - obs <= RECENT_MS && (recentPct == null || w.used_percentage > recentPct)) recentPct = w.used_percentage;
if (obs > freshAt) { freshAt = obs; freshPct = w.used_percentage; }
}
const pct = recentPct != null ? recentPct : freshPct;
return pct != null ? { used_percentage: pct, resets_at: bestReset } : null;
};
return { five_hour: agg('five_hour'), seven_day: agg('seven_day') };
} catch (e) { logError('rl-snapshot', e); return null; }
}
// ─── Active session time (hook-driven) ────────────────────
// Reads the file maintained by hooks/active-time-tracker.js. Returns ms or null.
function readActiveTime(transcriptPath, sessionId, claudeDir) {
const key = transcriptPath ? path.basename(transcriptPath, '.jsonl') : sessionId;
if (!key) return null;
const file = path.join(claudeDir, 'cache', `active-time-${key}.json`);
try {
const s = JSON.parse(fs.readFileSync(file, 'utf8'));
let total = s.totalMs || 0;
if (s.turnStart && Date.now() > s.turnStart) {
// Mesmo cap anti-crash do hook (active-time-tracker.js): um turnStart órfão
// (Stop perdido) não deve inflar o tempo ativo exibido sem limite.
const delta = Date.now() - s.turnStart;
if (delta <= 6 * 3600000) total += delta;
}
return total;
} catch { return null; }
}
// ─── Compact counter (hook-driven) ────────────────────────
// Reads the file maintained by hooks/compact-monitor.js (PreCompact event).
// Caller MAXes this with the JSONL-derived counter so a schema drift on either
// side never silently zeros the number.
function readCompactCount(transcriptPath, sessionId, claudeDir) {
const key = transcriptPath ? path.basename(transcriptPath, '.jsonl') : sessionId;
if (!key) return 0;
const file = path.join(claudeDir, 'cache', `compact-${key}.json`);
try { return JSON.parse(fs.readFileSync(file, 'utf8')).count || 0; } catch { return 0; }
}
function writeBridgeFile(sessionId, pct, pctEstimated, remainingPct) {
if (!sessionId) return;
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
try {
try {
const prev = JSON.parse(fs.readFileSync(bridgePath, 'utf8'));
if (prev.used_pct === pct && prev.remaining_percentage === (remainingPct ?? null) && prev.estimated === pctEstimated) return;
} catch {}
atomicWrite(bridgePath, JSON.stringify({
session_id: sessionId, remaining_percentage: remainingPct ?? null,
used_pct: pct, estimated: pctEstimated, timestamp: Math.floor(Date.now() / 1000),
}));
} catch (e) { logError('bridge-write', e); }
}
function lookupTask(sessionId, claudeDir) {
const todosDir = path.join(claudeDir, 'todos');
if (!sessionId || !fs.existsSync(todosDir)) return '';
try {
const all = fs.readdirSync(todosDir)
.filter(f => f.startsWith(sessionId) && f.endsWith('.json'))
.map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime, agent: f.includes('-agent-') }))
.sort((a, b) => b.mtime - a.mtime);
// Prefer root task (no -agent- in filename). Subagent todos are fallback only.
const ordered = [...all.filter(f => !f.agent), ...all.filter(f => f.agent)];
for (const file of ordered) {
try {
const todos = JSON.parse(fs.readFileSync(path.join(todosDir, file.name), 'utf8'));
const ip = todos.find(t => t.status === 'in_progress');
if (ip?.activeForm) return ` ${C.gray}| ${C.bold}${safeSlice(redact(ip.activeForm), 40)}${C.reset}`;
} catch {}
}
} catch (e) { logError('todos', e); }
return '';
}
module.exports = {
readSettings, trackMonthlyCost, trackRateLimitSnapshot,
readActiveTime, readCompactCount,
writeBridgeFile, lookupTask,
};