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.
@@ -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
+ }