ai-acct-autopilot 1.0.0
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/LICENSE +21 -0
- package/README.md +184 -0
- package/assets/screenshot.png +0 -0
- package/bin/ai-acct-autopilot.js +1205 -0
- package/bin/claude-acct +847 -0
- package/bin/usage-stats.js +336 -0
- package/docs/how-it-works.md +169 -0
- package/install.sh +19 -0
- package/package.json +46 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// usage-stats.js — account-independent cost/token stats from LOCAL session logs
|
|
2
|
+
// (codexbar-style): today cost, 30d cost, 30d tokens, latest tokens, top model,
|
|
3
|
+
// 30-day daily histogram. Sources:
|
|
4
|
+
// Claude: ~/.claude/projects/**/*.jsonl (assistant messages w/ usage)
|
|
5
|
+
// Codex: ~/.codex/sessions/YYYY/MM/DD/*.jsonl + ~/.codex/archived_sessions/
|
|
6
|
+
// (token_count events, summed per-event last_token_usage)
|
|
7
|
+
// Estimated at API rates — token traffic on this Mac regardless of account.
|
|
8
|
+
// Pricing mirrors CodexBar's CostUsagePricing table (steipete/CodexBar) so the
|
|
9
|
+
// two tools agree on rates. Known residual vs CodexBar: it additionally
|
|
10
|
+
// subtracts fork/resume-inherited token baselines across session files, so its
|
|
11
|
+
// codex total can read ~10-20% LOWER than ours on resume-heavy workloads.
|
|
12
|
+
//
|
|
13
|
+
// Incremental: per-file byte offsets + day aggregates cached in
|
|
14
|
+
// ~/.cache/ai-acct-autopilot/stats-cache.json; logs are append-only, so each scan
|
|
15
|
+
// reads only new bytes. Claude messages dedupe via a persisted seen-id set
|
|
16
|
+
// (resumed/compacted sessions re-write old messages into new files).
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const HOME = os.homedir();
|
|
24
|
+
const CACHE_DIR = path.join(HOME, '.cache', 'ai-acct-autopilot');
|
|
25
|
+
const CACHE_FILE = path.join(CACHE_DIR, 'stats-cache.json');
|
|
26
|
+
const WINDOW_DAYS = 31;
|
|
27
|
+
|
|
28
|
+
// USD per MTok — mirrors CodexBar's CostUsagePricing table (steipete/CodexBar,
|
|
29
|
+
// Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift) so the
|
|
30
|
+
// panel's numbers agree with codexbar's.
|
|
31
|
+
// Codex: [in, out, cacheRead, thresholdTokens, inAbove, outAbove, cacheAbove]
|
|
32
|
+
// (whole event billed at "above" rates when its input_tokens > threshold)
|
|
33
|
+
const CODEX_PRICES = {
|
|
34
|
+
'gpt-5.5': [5, 30, 0.5, 272_000, 10, 45, 1],
|
|
35
|
+
'gpt-5.5-pro': [30, 180, null],
|
|
36
|
+
'gpt-5.4': [2.5, 15, 0.25, 272_000, 5, 22.5, 0.5],
|
|
37
|
+
'gpt-5.4-mini': [0.75, 4.5, 0.075],
|
|
38
|
+
'gpt-5.4-nano': [0.2, 1.25, 0.02],
|
|
39
|
+
'gpt-5.4-pro': [30, 180, null],
|
|
40
|
+
'gpt-5.3-codex': [1.75, 14, 0.175],
|
|
41
|
+
'gpt-5.3-codex-spark': [0, 0, 0], // research preview — free
|
|
42
|
+
'gpt-5.2': [1.75, 14, 0.175],
|
|
43
|
+
'gpt-5.2-codex': [1.75, 14, 0.175],
|
|
44
|
+
'gpt-5.2-pro': [21, 168, null],
|
|
45
|
+
'gpt-5.1': [1.25, 10, 0.125],
|
|
46
|
+
'gpt-5.1-codex': [1.25, 10, 0.125],
|
|
47
|
+
'gpt-5.1-codex-max': [1.25, 10, 0.125],
|
|
48
|
+
'gpt-5.1-codex-mini': [0.25, 2, 0.025],
|
|
49
|
+
'gpt-5': [1.25, 10, 0.125],
|
|
50
|
+
'gpt-5-codex': [1.25, 10, 0.125],
|
|
51
|
+
'gpt-5-mini': [0.25, 2, 0.025],
|
|
52
|
+
'gpt-5-nano': [0.05, 0.4, 0.005],
|
|
53
|
+
'gpt-5-pro': [15, 120, null],
|
|
54
|
+
};
|
|
55
|
+
// Claude: [in, out, cacheWrite, cacheRead]
|
|
56
|
+
const CLAUDE_PRICES = {
|
|
57
|
+
'claude-fable-5': [10, 50, 12.5, 1],
|
|
58
|
+
'claude-opus-4-8': [5, 25, 6.25, 0.5],
|
|
59
|
+
'claude-opus-4-7': [5, 25, 6.25, 0.5],
|
|
60
|
+
'claude-opus-4-6': [5, 25, 6.25, 0.5],
|
|
61
|
+
'claude-opus-4-5': [5, 25, 6.25, 0.5],
|
|
62
|
+
'claude-opus-4-1': [15, 75, 18.75, 1.5],
|
|
63
|
+
'claude-opus-4': [15, 75, 18.75, 1.5],
|
|
64
|
+
'claude-sonnet-4-6': [3, 15, 3.75, 0.3],
|
|
65
|
+
'claude-sonnet-4-5': [3, 15, 3.75, 0.3],
|
|
66
|
+
'claude-sonnet-4': [3, 15, 3.75, 0.3],
|
|
67
|
+
'claude-haiku-4-5': [1, 5, 1.25, 0.1],
|
|
68
|
+
};
|
|
69
|
+
// normalize: strip provider prefixes + dated suffixes, then longest-prefix match
|
|
70
|
+
function lookupPrice(table, model) {
|
|
71
|
+
let m = String(model || '').trim().replace(/^(openai\/|anthropic\.)/, '');
|
|
72
|
+
m = m.replace(/-\d{4}-\d{2}-\d{2}$/, '').replace(/-\d{8}$/, '');
|
|
73
|
+
if (table[m]) return table[m];
|
|
74
|
+
let best = null;
|
|
75
|
+
for (const k of Object.keys(table)) {
|
|
76
|
+
if (m.startsWith(k) && (!best || k.length > best.length)) best = k;
|
|
77
|
+
}
|
|
78
|
+
return best ? table[best] : null;
|
|
79
|
+
}
|
|
80
|
+
function claudePriceFor(model) {
|
|
81
|
+
const p = lookupPrice(CLAUDE_PRICES, model);
|
|
82
|
+
if (p) return { i: p[0], o: p[1], cw: p[2], cr: p[3] };
|
|
83
|
+
if (/fable|mythos/i.test(model || '')) return { i: 10, o: 50, cw: 12.5, cr: 1 };
|
|
84
|
+
if (/opus/i.test(model || '')) return { i: 5, o: 25, cw: 6.25, cr: 0.5 };
|
|
85
|
+
if (/haiku/i.test(model || '')) return { i: 1, o: 5, cw: 1.25, cr: 0.1 };
|
|
86
|
+
return { i: 3, o: 15, cw: 3.75, cr: 0.30 }; // sonnet-class default
|
|
87
|
+
}
|
|
88
|
+
// Per-event codex cost in USD, codexbar-style (threshold reprices the event).
|
|
89
|
+
function codexCostUSD(model, inT, cached, out) {
|
|
90
|
+
const p = lookupPrice(CODEX_PRICES, model) || CODEX_PRICES['gpt-5.5'];
|
|
91
|
+
const [pin, pout, pcr, thr, pinA, poutA, pcrA] = p;
|
|
92
|
+
const cach = Math.min(Math.max(0, cached), Math.max(0, inT));
|
|
93
|
+
const nonCached = Math.max(0, inT - cach);
|
|
94
|
+
const above = thr && inT > thr;
|
|
95
|
+
const inRate = above ? (pinA ?? pin) : pin;
|
|
96
|
+
const outRate = above ? (poutA ?? pout) : pout;
|
|
97
|
+
const crBase = above ? (pcrA ?? pcr) : pcr;
|
|
98
|
+
const crRate = crBase == null ? inRate : crBase;
|
|
99
|
+
return (nonCached * inRate + cach * crRate + out * outRate) / 1e6;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const dayKey = (ts) => {
|
|
103
|
+
const d = new Date(ts);
|
|
104
|
+
if (!Number.isFinite(d.getTime())) return null;
|
|
105
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ---------- streaming line parser (byte-accurate offsets) ----------
|
|
109
|
+
function parseNewLines(file, fromOffset, onLine) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
let size;
|
|
112
|
+
try { size = fs.statSync(file).size; } catch { resolve(fromOffset); return; }
|
|
113
|
+
if (size <= fromOffset) { resolve(size < fromOffset ? 0 : fromOffset); return; } // shrunk → reset
|
|
114
|
+
const stream = fs.createReadStream(file, { start: fromOffset });
|
|
115
|
+
let tail = Buffer.alloc(0);
|
|
116
|
+
let consumed = fromOffset;
|
|
117
|
+
stream.on('data', (chunk) => {
|
|
118
|
+
const buf = tail.length ? Buffer.concat([tail, chunk]) : chunk;
|
|
119
|
+
let start = 0;
|
|
120
|
+
for (;;) {
|
|
121
|
+
const nl = buf.indexOf(10, start);
|
|
122
|
+
if (nl === -1) break;
|
|
123
|
+
const line = buf.subarray(start, nl).toString('utf8');
|
|
124
|
+
consumed += nl - start + 1;
|
|
125
|
+
if (line.trim()) { try { onLine(JSON.parse(line)); } catch {} }
|
|
126
|
+
start = nl + 1;
|
|
127
|
+
}
|
|
128
|
+
tail = Buffer.from(buf.subarray(start)); // copy — chunk buffer gets reused
|
|
129
|
+
stream.destroyed || null;
|
|
130
|
+
});
|
|
131
|
+
stream.on('end', () => resolve(consumed));
|
|
132
|
+
stream.on('error', () => resolve(consumed));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- per-provider line handlers ----------
|
|
137
|
+
function claudeHandler(rec, seen) {
|
|
138
|
+
return (l) => {
|
|
139
|
+
if (l.type !== 'assistant' || !l.message || !l.message.usage) return;
|
|
140
|
+
const u = l.message.usage;
|
|
141
|
+
const id = l.message.id ? `${l.message.id}:${l.requestId || ''}` : null;
|
|
142
|
+
if (id) { if (seen[id]) return; seen[id] = Date.parse(l.timestamp) || Date.now(); }
|
|
143
|
+
const day = dayKey(l.timestamp);
|
|
144
|
+
if (!day) return;
|
|
145
|
+
const model = l.message.model || 'claude';
|
|
146
|
+
if (/synthetic/.test(model)) return;
|
|
147
|
+
const p = claudePriceFor(model);
|
|
148
|
+
const inT = u.input_tokens || 0, out = u.output_tokens || 0;
|
|
149
|
+
const cw = u.cache_creation_input_tokens || 0, cr = u.cache_read_input_tokens || 0;
|
|
150
|
+
const cost = (inT * p.i + out * p.o + cw * p.cw + cr * p.cr) / 1e6;
|
|
151
|
+
const d = rec.days[day] || (rec.days[day] = { cost: 0, tokens: 0, models: {} });
|
|
152
|
+
d.cost += cost; d.tokens += inT + out + cw + cr;
|
|
153
|
+
d.models[model] = (d.models[model] || 0) + cost;
|
|
154
|
+
const ts = Date.parse(l.timestamp) || 0;
|
|
155
|
+
if (ts >= (rec.lastTs || 0)) { rec.lastTs = ts; rec.lastTokens = inT + cr + cw + out; }
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function codexHandler(rec, live) {
|
|
160
|
+
let model = rec.model || 'gpt-5';
|
|
161
|
+
return (l) => {
|
|
162
|
+
const pl = l.payload || {};
|
|
163
|
+
if (l.type === 'turn_context' && pl.model) { model = pl.model; rec.model = model; return; }
|
|
164
|
+
if (pl.type !== 'token_count' || !pl.info) return;
|
|
165
|
+
const u = pl.info.last_token_usage || {};
|
|
166
|
+
const day = dayKey(l.timestamp);
|
|
167
|
+
if (!day) return;
|
|
168
|
+
const inT = u.input_tokens || 0, cached = Math.min(u.cached_input_tokens || 0, inT), out = u.output_tokens || 0;
|
|
169
|
+
const cost = codexCostUSD(model, inT, cached, out);
|
|
170
|
+
const d = rec.days[day] || (rec.days[day] = { cost: 0, tokens: 0, models: {} });
|
|
171
|
+
d.cost += cost; d.tokens += inT + out;
|
|
172
|
+
d.models[model] = (d.models[model] || 0) + cost;
|
|
173
|
+
const ts = Date.parse(l.timestamp) || 0;
|
|
174
|
+
if (ts >= (rec.lastTs || 0)) {
|
|
175
|
+
rec.lastTs = ts;
|
|
176
|
+
rec.lastTokens = (pl.info.total_token_usage || {}).total_tokens || 0;
|
|
177
|
+
}
|
|
178
|
+
if (pl.rate_limits && ts >= (live.ts || 0)) {
|
|
179
|
+
live.ts = ts; live.rateLimits = pl.rate_limits; live.model = model;
|
|
180
|
+
live.contextWindow = pl.info.model_context_window || null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------- file discovery ----------
|
|
186
|
+
function claudeFiles() {
|
|
187
|
+
const root = path.join(HOME, '.claude', 'projects');
|
|
188
|
+
const cutoff = Date.now() - WINDOW_DAYS * 86_400_000;
|
|
189
|
+
const out = [];
|
|
190
|
+
const walk = (dir, depth) => {
|
|
191
|
+
let ents = [];
|
|
192
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
193
|
+
for (const e of ents) {
|
|
194
|
+
const p = path.join(dir, e.name);
|
|
195
|
+
if (e.isDirectory()) { if (depth < 3) walk(p, depth + 1); continue; }
|
|
196
|
+
if (!e.name.endsWith('.jsonl')) continue;
|
|
197
|
+
try { const st = fs.statSync(p); if (st.mtimeMs >= cutoff) out.push({ path: p, mtime: st.mtimeMs, size: st.size }); } catch {}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
walk(root, 0);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function codexFiles() {
|
|
205
|
+
const root = path.join(HOME, '.codex', 'sessions');
|
|
206
|
+
const out = [];
|
|
207
|
+
for (let i = 0; i < WINDOW_DAYS; i++) {
|
|
208
|
+
const d = new Date(Date.now() - i * 86_400_000);
|
|
209
|
+
const dir = path.join(root, String(d.getFullYear()),
|
|
210
|
+
String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0'));
|
|
211
|
+
let ents = [];
|
|
212
|
+
try { ents = fs.readdirSync(dir); } catch { continue; }
|
|
213
|
+
for (const name of ents) {
|
|
214
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
215
|
+
const p = path.join(dir, name);
|
|
216
|
+
try { const st = fs.statSync(p); out.push({ path: p, mtime: st.mtimeMs, size: st.size }); } catch {}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// archived sessions (flat dir; mv preserves mtime) — codexbar scans these too
|
|
220
|
+
const archived = path.join(HOME, '.codex', 'archived_sessions');
|
|
221
|
+
const cutoff = Date.now() - WINDOW_DAYS * 86_400_000;
|
|
222
|
+
let ents = [];
|
|
223
|
+
try { ents = fs.readdirSync(archived); } catch {}
|
|
224
|
+
for (const name of ents) {
|
|
225
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
226
|
+
const p = path.join(archived, name);
|
|
227
|
+
try { const st = fs.statSync(p); if (st.mtimeMs >= cutoff) out.push({ path: p, mtime: st.mtimeMs, size: st.size }); } catch {}
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------- cache ----------
|
|
233
|
+
function loadCache() {
|
|
234
|
+
// version bump invalidates cached day aggregates when pricing changes
|
|
235
|
+
try { const c = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); if (c.version === 2) return c; } catch {}
|
|
236
|
+
return { version: 2, files: {}, claudeSeen: {}, codexLive: {} };
|
|
237
|
+
}
|
|
238
|
+
function saveCache(cache) {
|
|
239
|
+
try {
|
|
240
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
241
|
+
const tmp = `${CACHE_FILE}.tmp-${process.pid}`;
|
|
242
|
+
fs.writeFileSync(tmp, JSON.stringify(cache));
|
|
243
|
+
fs.renameSync(tmp, CACHE_FILE);
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------- aggregation ----------
|
|
248
|
+
function aggregate(cache, provider) {
|
|
249
|
+
const days = {};
|
|
250
|
+
let lastTs = 0, lastTokens = null, model = null;
|
|
251
|
+
for (const [p, rec] of Object.entries(cache.files)) {
|
|
252
|
+
if (rec.provider !== provider) continue;
|
|
253
|
+
for (const [day, d] of Object.entries(rec.days || {})) {
|
|
254
|
+
const t = days[day] || (days[day] = { cost: 0, tokens: 0, models: {} });
|
|
255
|
+
t.cost += d.cost; t.tokens += d.tokens;
|
|
256
|
+
for (const [m, c] of Object.entries(d.models || {})) t.models[m] = (t.models[m] || 0) + c;
|
|
257
|
+
}
|
|
258
|
+
if ((rec.lastTs || 0) > lastTs) { lastTs = rec.lastTs; lastTokens = rec.lastTokens; model = rec.model || null; }
|
|
259
|
+
}
|
|
260
|
+
const today = dayKey(Date.now());
|
|
261
|
+
const keys = [];
|
|
262
|
+
for (let i = 29; i >= 0; i--) keys.push(dayKey(Date.now() - i * 86_400_000));
|
|
263
|
+
const hist = keys.map((k) => (days[k] ? days[k].cost : 0));
|
|
264
|
+
let cost30 = 0, tok30 = 0;
|
|
265
|
+
const modelCost = {};
|
|
266
|
+
for (const k of keys) {
|
|
267
|
+
const d = days[k]; if (!d) continue;
|
|
268
|
+
cost30 += d.cost; tok30 += d.tokens;
|
|
269
|
+
for (const [m, c] of Object.entries(d.models)) modelCost[m] = (modelCost[m] || 0) + c;
|
|
270
|
+
}
|
|
271
|
+
const top = Object.entries(modelCost).sort((a, b) => b[1] - a[1])[0];
|
|
272
|
+
return {
|
|
273
|
+
todayCost: days[today] ? days[today].cost : 0,
|
|
274
|
+
cost30, tokens30: tok30, lastTokens, lastTs,
|
|
275
|
+
topModel: top ? top[0] : null, hist,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------- public API ----------
|
|
280
|
+
let running = false;
|
|
281
|
+
async function collect(onProgress) {
|
|
282
|
+
if (running) return null;
|
|
283
|
+
running = true;
|
|
284
|
+
try {
|
|
285
|
+
const cache = loadCache();
|
|
286
|
+
const live = cache.codexLive || {};
|
|
287
|
+
const all = [
|
|
288
|
+
...claudeFiles().map((f) => ({ ...f, provider: 'claude' })),
|
|
289
|
+
...codexFiles().map((f) => ({ ...f, provider: 'codex' })),
|
|
290
|
+
];
|
|
291
|
+
// prune cache entries that left the window
|
|
292
|
+
const keep = new Set(all.map((f) => f.path));
|
|
293
|
+
for (const p of Object.keys(cache.files)) if (!keep.has(p)) delete cache.files[p];
|
|
294
|
+
const dirty = all.filter((f) => {
|
|
295
|
+
const rec = cache.files[f.path];
|
|
296
|
+
return !rec || rec.size !== f.size || rec.mtime !== f.mtime;
|
|
297
|
+
});
|
|
298
|
+
let done = 0;
|
|
299
|
+
for (const f of dirty) {
|
|
300
|
+
const rec = cache.files[f.path] || { provider: f.provider, offset: 0, days: {}, lastTs: 0, lastTokens: null };
|
|
301
|
+
if (f.size < (rec.offset || 0)) { rec.offset = 0; rec.days = {}; } // rewritten file → reparse
|
|
302
|
+
const handler = f.provider === 'claude' ? claudeHandler(rec, cache.claudeSeen) : codexHandler(rec, live);
|
|
303
|
+
rec.offset = await parseNewLines(f.path, rec.offset || 0, handler);
|
|
304
|
+
rec.size = f.size; rec.mtime = f.mtime; rec.provider = f.provider;
|
|
305
|
+
cache.files[f.path] = rec;
|
|
306
|
+
done++;
|
|
307
|
+
if (onProgress && (done % 25 === 0 || done === dirty.length)) onProgress(done, dirty.length);
|
|
308
|
+
}
|
|
309
|
+
// prune seen ids older than the window
|
|
310
|
+
const cut = Date.now() - (WINDOW_DAYS + 4) * 86_400_000;
|
|
311
|
+
for (const [id, ts] of Object.entries(cache.claudeSeen)) if (ts < cut) delete cache.claudeSeen[id];
|
|
312
|
+
cache.codexLive = live;
|
|
313
|
+
saveCache(cache);
|
|
314
|
+
return {
|
|
315
|
+
claude: aggregate(cache, 'claude'),
|
|
316
|
+
codex: aggregate(cache, 'codex'),
|
|
317
|
+
codexLive: live,
|
|
318
|
+
scannedFiles: dirty.length,
|
|
319
|
+
generatedAt: Date.now(),
|
|
320
|
+
};
|
|
321
|
+
} finally { running = false; }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function cachedStats() {
|
|
325
|
+
const cache = loadCache();
|
|
326
|
+
if (!Object.keys(cache.files).length) return null;
|
|
327
|
+
return {
|
|
328
|
+
claude: aggregate(cache, 'claude'),
|
|
329
|
+
codex: aggregate(cache, 'codex'),
|
|
330
|
+
codexLive: cache.codexLive || {},
|
|
331
|
+
scannedFiles: 0,
|
|
332
|
+
generatedAt: Date.now(),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = { collect, cachedStats };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# How it works
|
|
2
|
+
|
|
3
|
+
This document is the architecture deep-dive: what runs when, where every
|
|
4
|
+
credential lives, which endpoints are touched, and why the Codex side needs a
|
|
5
|
+
supervisor while the Claude side doesn't.
|
|
6
|
+
|
|
7
|
+
## The tick (every 60s)
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
tick
|
|
11
|
+
├─ 1. self-heal Claude tokens
|
|
12
|
+
│ refresh stale NON-active account blobs via OAuth refresh
|
|
13
|
+
│ (atomic tmp+rename, .bak kept, originals never deleted on failure);
|
|
14
|
+
│ re-snapshot the ACTIVE account from the live keychain when its
|
|
15
|
+
│ saved copy goes stale (the live CLI rotates tokens)
|
|
16
|
+
├─ 2. poll usage
|
|
17
|
+
│ Claude: claude-acct usage --json (per-account OAuth usage endpoint,
|
|
18
|
+
│ also appends trend history)
|
|
19
|
+
│ Codex: GET chatgpt.com/backend-api/wham/usage per account token
|
|
20
|
+
│ (works for benched accounts; rollout logs = offline fallback)
|
|
21
|
+
│ also: mirror the active codex account's rotated tokens to its blob
|
|
22
|
+
├─ 3. autopilot (per provider)
|
|
23
|
+
│ trigger: active account's worst(5h, weekly) ≥ 100 - threshold
|
|
24
|
+
│ target: healthiest account that PASSED a usage probe this tick
|
|
25
|
+
│ act: claude-acct use <email> | swap ~/.codex/auth.json
|
|
26
|
+
│ then: (codex) restart supervised sessions so they resume
|
|
27
|
+
│ guards: cooldown, all-hot hold + single notification, journal
|
|
28
|
+
└─ 4. render
|
|
29
|
+
per-account bars (% left + reset countdowns), trends, cost panel,
|
|
30
|
+
journal tail, per-second countdown footer
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Account storage
|
|
34
|
+
|
|
35
|
+
| Provider | Active credential | Saved accounts |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| Claude | macOS keychain item `Claude Code-credentials` | `~/.claude/accounts/<email>.json` (+ `.meta`, `.oauthAccount.json`) |
|
|
38
|
+
| Codex | `~/.codex/auth.json` | `~/.codex/accounts/<email>.json` |
|
|
39
|
+
|
|
40
|
+
Accounts are **named by their email** — unique, profile-verified, and immune
|
|
41
|
+
to the stale-nickname drift that plagues hand-named account files.
|
|
42
|
+
|
|
43
|
+
## Claude token lifecycle
|
|
44
|
+
|
|
45
|
+
- The **active** account is owned by Claude Code itself: it refreshes tokens
|
|
46
|
+
and writes them to the keychain. The watcher mirrors those rotations into
|
|
47
|
+
the saved blob (`claude-acct save`) so switching away never strands them.
|
|
48
|
+
- **Benched** accounts' access tokens expire within hours. The watcher
|
|
49
|
+
refreshes them via Anthropic's OAuth token endpoint using each blob's
|
|
50
|
+
refresh token (the same public client id Claude Code uses). Writes are
|
|
51
|
+
atomic with `.bak`; a refresh that fails with `invalid_grant` marks the
|
|
52
|
+
account "re-auth needed" and never touches the file.
|
|
53
|
+
- **Switching** (`claude-acct use`) snapshots the outgoing live keychain blob
|
|
54
|
+
first, then writes the target blob into the keychain and verifies it.
|
|
55
|
+
- **Why running Claude sessions survive**: Claude Code re-reads the keychain
|
|
56
|
+
credential (observed ≈30s; also documented by other switchers). Non-pinned
|
|
57
|
+
running sessions just start billing the new account. Worktree pins
|
|
58
|
+
(`CLAUDE_CODE_OAUTH_TOKEN` in `.claude/settings.local.json`) are read at
|
|
59
|
+
process start and never follow the keychain — by design.
|
|
60
|
+
- **Never `/logout`**: Claude's logout revokes the session server-side and
|
|
61
|
+
bricks the saved blob. All account capture is overwrite-login.
|
|
62
|
+
|
|
63
|
+
## Codex: the single-session discovery
|
|
64
|
+
|
|
65
|
+
Two facts shape the entire Codex design:
|
|
66
|
+
|
|
67
|
+
1. **A running codex process never re-reads `auth.json`**
|
|
68
|
+
([openai/codex#17041](https://github.com/openai/codex/issues/17041)).
|
|
69
|
+
Swapping the file only affects *new* processes.
|
|
70
|
+
2. **`codex login` in a shared `CODEX_HOME` revokes the session it replaces.**
|
|
71
|
+
Empirically verified: after logging into accounts B, C, D in sequence, only
|
|
72
|
+
D's token still answered the usage endpoint; A–C returned
|
|
73
|
+
`token_revoked`. Crucially, a login in an **isolated** `CODEX_HOME` leaves
|
|
74
|
+
other sessions alive — revocation is login-flow hygiene inside one home,
|
|
75
|
+
not a server-side single-session rule.
|
|
76
|
+
|
|
77
|
+
Hence:
|
|
78
|
+
|
|
79
|
+
- **`codex-add <email>`** runs `codex login` in a throwaway isolated
|
|
80
|
+
`CODEX_HOME`, verifies the identity from the id-token JWT, probes its usage,
|
|
81
|
+
and imports `auth.json` into the bench. Existing sessions stay alive.
|
|
82
|
+
- **Usage probing** (`GET chatgpt.com/backend-api/wham/usage`, Bearer = the
|
|
83
|
+
account's access token) works for benched accounts with zero sessions and
|
|
84
|
+
zero token burn. Windows nest under `rate_limit`
|
|
85
|
+
(`primary_window` = 5h, `secondary_window` = weekly, `used_percent`,
|
|
86
|
+
`reset_at`); `additional_rate_limits[]` carries model-family buckets
|
|
87
|
+
(e.g. the GPT-5.3-Codex-Spark research preview) which are deliberately
|
|
88
|
+
ignored — only the regular account limit drives display and switching.
|
|
89
|
+
|
|
90
|
+
## The codex supervisor shim
|
|
91
|
+
|
|
92
|
+
`codex-shim install` replaces the codex entry point (an npm symlink, e.g.
|
|
93
|
+
`/opt/homebrew/bin/codex`) with:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
#!/bin/sh
|
|
97
|
+
# ai-acct-autopilot codex shim v3 (node supervisor)
|
|
98
|
+
exec node /path/to/ai-acct-autopilot.js codex-supervise -- "$@"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`codex-supervise` runs a pre-launch account check (`codex-ensure`: if the
|
|
102
|
+
active account has <threshold% left on fresh data and a better bench account
|
|
103
|
+
exists, swap `auth.json` *before* codex starts), then spawns the real codex
|
|
104
|
+
and waits.
|
|
105
|
+
|
|
106
|
+
On an account switch, the watcher:
|
|
107
|
+
|
|
108
|
+
1. finds running codex **launcher** processes (`node …/codex.js`, never the
|
|
109
|
+
Codex.app app-server),
|
|
110
|
+
2. reads each session's id from the rollout file its native binary holds open
|
|
111
|
+
(`lsof` on the launcher's descendants),
|
|
112
|
+
3. writes the id to `~/.codex/watch-restarts/<pid>` and sends SIGTERM (the
|
|
113
|
+
launcher forwards it to the native binary and mirrors its exit).
|
|
114
|
+
|
|
115
|
+
The supervisor sees its child die **with a marker** and relaunches:
|
|
116
|
+
|
|
117
|
+
- original args contained `exec` → `<args up to exec> exec resume <sid>`
|
|
118
|
+
- TUI → `<flag args preserved> resume <sid>` (positional prompt dropped — the
|
|
119
|
+
thread already contains it)
|
|
120
|
+
|
|
121
|
+
Launch flags injected by wrappers (e.g. Superset's `--enable hooks -c
|
|
122
|
+
notify=[…]`) survive the resume. No marker → the supervisor mirrors the exit
|
|
123
|
+
code/signal exactly like stock codex. TUI exec-mode caveat: exec processes
|
|
124
|
+
don't hold their rollout open, so sid capture can come back empty — the
|
|
125
|
+
supervisor then relaunches with the original args (the task re-runs).
|
|
126
|
+
|
|
127
|
+
Fail-safe in both directions: any supervisor/ensure failure still launches
|
|
128
|
+
codex, and an npm upgrade of `@openai/codex` simply restores the stock binary
|
|
129
|
+
(re-run `codex-shim install` after upgrades).
|
|
130
|
+
|
|
131
|
+
## Autopilot policy
|
|
132
|
+
|
|
133
|
+
- **Trigger**: worst of (5h, weekly) utilization ≥ `100 - threshold`
|
|
134
|
+
(default: <5% left). Opus/sonnet sub-windows are display-only.
|
|
135
|
+
- **Target ranking**: probe-passing accounts only, lowest worst-window
|
|
136
|
+
utilization wins, soonest 5h reset breaks ties.
|
|
137
|
+
- **Guards**: per-provider cooldown (default 10 min); never switch into an
|
|
138
|
+
account that is itself ≥ the threshold; codex never acts on usage data
|
|
139
|
+
older than 30 minutes; "all hot" → hold + one notification with the
|
|
140
|
+
earliest reset.
|
|
141
|
+
- **Manual switches are adopted**, never fought: the active account is
|
|
142
|
+
re-detected each tick.
|
|
143
|
+
|
|
144
|
+
## The cost panel
|
|
145
|
+
|
|
146
|
+
`bin/usage-stats.js` streams local session logs —
|
|
147
|
+
`~/.claude/projects/**/*.jsonl` (per-message token usage, deduped by
|
|
148
|
+
message+request id) and `~/.codex/sessions/**/*.jsonl` (`token_count`
|
|
149
|
+
events) — and prices them at public API rates with per-model tables mirrored
|
|
150
|
+
from CodexBar (long-context tiers included; the Spark preview is $0). Because
|
|
151
|
+
it reads logs rather than account APIs, it aggregates everything the machine
|
|
152
|
+
did across every account. Incremental: per-file byte offsets cached in
|
|
153
|
+
`~/.cache/ai-acct-autopilot/` (first scan over multi-GB logs takes ~30s,
|
|
154
|
+
afterwards ~0.2s). The numbers are estimates at API rates — not your bill.
|
|
155
|
+
|
|
156
|
+
## Files written
|
|
157
|
+
|
|
158
|
+
| Path | What |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `~/.claude/accounts/*.json` (+`.bak`) | Claude account blobs |
|
|
161
|
+
| `~/.claude/accounts/switch-journal.jsonl` | append-only event journal (both providers) |
|
|
162
|
+
| `~/.claude/accounts/usage-history.json` | Claude usage trend history |
|
|
163
|
+
| `~/.codex/accounts/*.json` | Codex account blobs |
|
|
164
|
+
| `~/.codex/watch-shim.json` | shim install state (real binary path) |
|
|
165
|
+
| `~/.codex/watch-restarts/<pid>` | transient restart markers (sid inside) |
|
|
166
|
+
| `~/.cache/ai-acct-autopilot/` | cost-panel incremental scan cache |
|
|
167
|
+
|
|
168
|
+
All credential-bearing files are written `0600`, atomically, with `.bak`
|
|
169
|
+
backups. Nothing is ever sent anywhere except the providers' own endpoints.
|
package/install.sh
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# ai-acct-autopilot installer (no npm needed): symlinks the commands into
|
|
3
|
+
# ~/.local/bin. Run from a clone of the repo.
|
|
4
|
+
set -e
|
|
5
|
+
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
6
|
+
mkdir -p "$HOME/.local/bin"
|
|
7
|
+
ln -sf "$DIR/bin/ai-acct-autopilot.js" "$HOME/.local/bin/ai-acct-autopilot"
|
|
8
|
+
ln -sf "$DIR/bin/claude-acct" "$HOME/.local/bin/claude-acct"
|
|
9
|
+
chmod +x "$DIR/bin/ai-acct-autopilot.js" "$DIR/bin/claude-acct"
|
|
10
|
+
echo "installed: ai-acct-autopilot, claude-acct -> ~/.local/bin"
|
|
11
|
+
case ":$PATH:" in
|
|
12
|
+
*":$HOME/.local/bin:"*) ;;
|
|
13
|
+
*) echo "NOTE: add ~/.local/bin to your PATH." ;;
|
|
14
|
+
esac
|
|
15
|
+
echo "next steps:"
|
|
16
|
+
echo " claude-acct save <your-email> # capture your current Claude account"
|
|
17
|
+
echo " ai-acct-autopilot codex-save # capture your current Codex account"
|
|
18
|
+
echo " ai-acct-autopilot codex-shim install # enable codex session auto-resume"
|
|
19
|
+
echo " ai-acct-autopilot # run the dashboard"
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-acct-autopilot",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Terminal dashboard + autopilot for multiple Claude Code and Codex accounts: live usage bars, auto-switch before rate limits, sessions resumed on the fresh account.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ai-acct-autopilot": "bin/ai-acct-autopilot.js",
|
|
7
|
+
"claude-acct": "bin/claude-acct"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"docs",
|
|
12
|
+
"assets",
|
|
13
|
+
"install.sh"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"os": [
|
|
19
|
+
"darwin"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node bin/ai-acct-autopilot.js --test-decision"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"claude",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"codex",
|
|
28
|
+
"rate-limit",
|
|
29
|
+
"multi-account",
|
|
30
|
+
"account-switcher",
|
|
31
|
+
"usage",
|
|
32
|
+
"autopilot",
|
|
33
|
+
"cli",
|
|
34
|
+
"dashboard"
|
|
35
|
+
],
|
|
36
|
+
"author": "Alnim (https://github.com/alnimra)",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/alnimra/ai-acct-autopilot.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/alnimra/ai-acct-autopilot/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/alnimra/ai-acct-autopilot#readme"
|
|
46
|
+
}
|