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,1205 @@
1
+ #!/usr/bin/env node
2
+ // ai-acct-autopilot — terminal dashboard + autopilot for multiple AI CLI accounts.
3
+ //
4
+ // Providers:
5
+ // • Claude (claude-acct accounts, macOS keychain) — auto-switch moves
6
+ // RUNNING non-pinned sessions too (they re-read the keychain in ~30s).
7
+ // • Codex (ChatGPT, ~/.codex/auth.json) — auto-switch applies to NEW or
8
+ // restarted codex sessions only: a live codex process holds auth in
9
+ // memory and never re-reads auth.json (openai/codex#17041).
10
+ //
11
+ // Every tick (default 60s), per provider:
12
+ // 1. self-heal tokens (Claude: OAuth-refresh stale non-active blobs,
13
+ // atomic + .bak; re-snapshot the active account from the keychain).
14
+ // 2. poll usage (Claude: claude-acct usage --json; Codex: latest
15
+ // rate_limits event in ~/.codex/sessions rollout logs — passive, no
16
+ // API calls; primary=5h, secondary=weekly).
17
+ // 3. render codexbar-style bars: "N% left", reset countdowns, trends.
18
+ // 4. autopilot: active account < threshold % left on 5h or weekly →
19
+ // switch to the healthiest saved account.
20
+ //
21
+ // ai-acct-autopilot [--interval 60] [--threshold 5] [--cooldown 10]
22
+ // [--once] [--no-switch] [--plain]
23
+ // ai-acct-autopilot codex-save snapshot current codex account
24
+ // ai-acct-autopilot codex-use <email> switch codex account (new sessions)
25
+ // ai-acct-autopilot codex-list list saved codex accounts
26
+ //
27
+ // Accounts are named by their email (unique, profile-verified).
28
+ // Zero dependencies. NEVER logs token material.
29
+
30
+ 'use strict';
31
+ const { execFile } = require('node:child_process');
32
+ const fs = require('node:fs');
33
+ const https = require('node:https');
34
+ const os = require('node:os');
35
+ const path = require('node:path');
36
+
37
+ const HOME = os.homedir();
38
+ const DIR = path.join(HOME, '.claude', 'accounts');
39
+ const JOURNAL = path.join(DIR, 'switch-journal.jsonl');
40
+ const HISTORY = path.join(DIR, 'usage-history.json');
41
+ const CLAUDE_JSON = path.join(HOME, '.claude.json');
42
+ const CODEX_AUTH = path.join(HOME, '.codex', 'auth.json');
43
+ const CODEX_DIR = path.join(HOME, '.codex', 'accounts');
44
+ const CODEX_SESSIONS = path.join(HOME, '.codex', 'sessions');
45
+ const usageStats = require('./usage-stats'); // local-log cost/token stats (account-independent)
46
+ // Claude Code CLI's public OAuth client — same client the CLI itself uses.
47
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
48
+ const OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
49
+ const CODEX_USAGE_MAX_AGE_MS = 30 * 60_000; // stale usage never drives a switch
50
+ // shim constants live up here: subcommand blocks run at module top-level and
51
+ // call into shim helpers before later const declarations would initialize (TDZ)
52
+ const SHIM_MARK = '# ai-acct-autopilot codex shim';
53
+ const SHIM_MARK_LEGACY = '# ai-cli-watch codex shim'; // pre-rename installs upgrade in place
54
+ const SHIM_STATE = path.join(HOME, '.codex', 'watch-shim.json');
55
+ const RESTART_DIR = path.join(HOME, '.codex', 'watch-restarts');
56
+
57
+ const acctBin = fs.existsSync(path.join(HOME, '.local', 'bin', 'claude-acct'))
58
+ ? path.join(HOME, '.local', 'bin', 'claude-acct') : 'claude-acct';
59
+
60
+ // ---------- args ----------
61
+ const argv = process.argv.slice(2);
62
+ const flag = (name) => argv.includes(name);
63
+ const opt = (name, dflt) => {
64
+ const i = argv.indexOf(name);
65
+ if (i === -1 || i + 1 >= argv.length) return dflt;
66
+ const n = Number(argv[i + 1]);
67
+ return Number.isFinite(n) ? n : dflt;
68
+ };
69
+ if (flag('--help') || flag('-h')) {
70
+ console.log(`ai-acct-autopilot — usage dashboard + auto-switch for Claude & Codex accounts
71
+
72
+ --interval N seconds between checks (default 60)
73
+ --threshold N auto-switch when active account has < N% left (default 5)
74
+ --cooldown N minutes between auto-switches per provider (default 10)
75
+ --once one tick, then exit
76
+ --no-switch monitor only — never switch accounts
77
+ --plain no screen clearing / colors (logging mode)
78
+
79
+ codex-add <email?> log a NEW codex account into the bench (isolated login —
80
+ the current session stays alive)
81
+ codex-save snapshot the current codex account (~/.codex/accounts)
82
+ codex-use <email> switch codex account — applies to NEW sessions only
83
+ codex-list list saved codex accounts
84
+
85
+ Claude accounts are managed with claude-acct (add/save/use by email).`);
86
+ process.exit(0);
87
+ }
88
+ const INTERVAL = Math.max(15, opt('--interval', 60));
89
+ const THRESHOLD = Math.min(50, Math.max(1, opt('--threshold', 5)));
90
+ const COOLDOWN_MS = Math.max(1, opt('--cooldown', 10)) * 60_000;
91
+ const ONCE = flag('--once');
92
+ const NO_SWITCH = flag('--no-switch');
93
+ const PLAIN = flag('--plain') || !process.stdout.isTTY;
94
+
95
+ // ---------- ansi ----------
96
+ const ansi = !PLAIN;
97
+ const esc = (s) => (ansi ? s : '');
98
+ const rgb = (r, g, b) => esc(`\x1b[38;2;${r};${g};${b}m`);
99
+ const C = {
100
+ reset: esc('\x1b[0m'), bold: esc('\x1b[1m'), dim: esc('\x1b[2m'),
101
+ tan: rgb(232, 160, 76), // codexbar warm bar
102
+ blue: rgb(96, 165, 250), // codex accent (codexbar's codex view)
103
+ orange: rgb(249, 117, 78), // parker orange
104
+ amber: rgb(249, 191, 1),
105
+ red: rgb(233, 59, 35),
106
+ green: rgb(82, 178, 138),
107
+ grey: rgb(151, 150, 146),
108
+ grey2: rgb(99, 98, 94),
109
+ white: rgb(251, 251, 247),
110
+ };
111
+ const stripLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
112
+
113
+ // ---------- small utils ----------
114
+ const run = (cmd, args, timeout = 90_000) => new Promise((resolve) => {
115
+ execFile(cmd, args, { encoding: 'utf8', timeout, maxBuffer: 8 * 1024 * 1024 },
116
+ (err, stdout, stderr) => resolve({ ok: !err, stdout: stdout || '', stderr: stderr || '' }));
117
+ });
118
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
119
+ const pct = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : null);
120
+ const now = () => Date.now();
121
+
122
+ function atomicWrite(file, content) {
123
+ if (fs.existsSync(file)) { try { fs.copyFileSync(file, `${file}.bak`); } catch {} }
124
+ const tmp = `${file}.tmp-${process.pid}`;
125
+ fs.writeFileSync(tmp, content, { mode: 0o600 });
126
+ fs.renameSync(tmp, file);
127
+ }
128
+
129
+ function rel(iso) {
130
+ if (!iso) return null;
131
+ const t = new Date(iso).getTime();
132
+ if (!Number.isFinite(t)) return null;
133
+ let s = Math.round((t - now()) / 1000);
134
+ const past = s < 0; s = Math.abs(s);
135
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
136
+ const txt = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m`;
137
+ return past ? `${txt} ago` : `in ${txt}`;
138
+ }
139
+
140
+ function notify(title, body) {
141
+ if (process.platform !== 'darwin') return;
142
+ const q = (s) => String(s).replace(/[\\"]/g, '');
143
+ execFile('osascript', ['-e', `display notification "${q(body)}" with title "${q(title)}" sound name "Glass"`], () => {});
144
+ }
145
+
146
+ function journalAppend(evt) {
147
+ try { fs.appendFileSync(JOURNAL, JSON.stringify({ ts: new Date().toISOString(), ...evt }) + '\n', { mode: 0o600 }); } catch {}
148
+ }
149
+ function journalTail(n, filter) {
150
+ try {
151
+ const lines = fs.readFileSync(JOURNAL, 'utf8').trim().split('\n');
152
+ const evts = lines.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
153
+ return (filter ? evts.filter(filter) : evts).slice(-n);
154
+ } catch { return []; }
155
+ }
156
+ function lastSwitchTs(provider) {
157
+ const evts = journalTail(100, (e) => e.event === 'switch' && (e.provider || 'claude') === provider);
158
+ return evts.length ? new Date(evts[evts.length - 1].ts).getTime() : 0;
159
+ }
160
+
161
+ // ════════════════════════ CLAUDE provider ════════════════════════
162
+ function accountNames() {
163
+ let files = [];
164
+ try { files = fs.readdirSync(DIR); } catch {}
165
+ return files.filter((f) => f.endsWith('.json') && !f.startsWith('.')
166
+ && f !== 'usage-history.json' && !f.endsWith('.oauthAccount.json'))
167
+ .map((f) => f.slice(0, -5));
168
+ }
169
+ const isRecovery = (name) => name.startsWith('unsaved-live-');
170
+
171
+ function liveEmail() {
172
+ const cfg = readJson(CLAUDE_JSON);
173
+ return cfg && cfg.oauthAccount && cfg.oauthAccount.emailAddress || null;
174
+ }
175
+ function emailOf(name) {
176
+ const meta = path.join(DIR, `${name}.meta`);
177
+ try {
178
+ const m = fs.readFileSync(meta, 'utf8').match(/^email=(.+)$/m);
179
+ if (m) return m[1];
180
+ } catch {}
181
+ const oauth = readJson(path.join(DIR, `${name}.oauthAccount.json`));
182
+ return oauth && oauth.emailAddress || null;
183
+ }
184
+
185
+ function oauthRefresh(refreshToken) {
186
+ return new Promise((resolve) => {
187
+ const body = JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: OAUTH_CLIENT_ID });
188
+ const req = https.request(OAUTH_TOKEN_URL, {
189
+ method: 'POST', timeout: 20_000,
190
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
191
+ }, (res) => {
192
+ let b = '';
193
+ res.on('data', (c) => { b += c; });
194
+ res.on('end', () => {
195
+ let data = null; try { data = JSON.parse(b); } catch {}
196
+ resolve({ ok: res.statusCode === 200 && data && data.access_token, status: res.statusCode, data });
197
+ });
198
+ });
199
+ req.on('error', () => resolve({ ok: false, status: 0 }));
200
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, status: 0 }); });
201
+ req.end(body);
202
+ });
203
+ }
204
+
205
+ // Refresh a saved NON-active account blob in place. Atomic: tmp+rename, .bak of
206
+ // the previous valid blob. On any failure the original file is untouched.
207
+ async function refreshBlob(name, state) {
208
+ const file = path.join(DIR, `${name}.json`);
209
+ const blob = readJson(file);
210
+ const oauth = blob && blob.claudeAiOauth;
211
+ if (!oauth || !oauth.refreshToken) { state.reauth.add(name); return false; }
212
+ const r = await oauthRefresh(oauth.refreshToken);
213
+ if (!r.ok) {
214
+ if (r.status === 400 || r.status === 401 || r.status === 403) state.reauth.add(name);
215
+ return false; // keep old blob — refresh may be transient (network)
216
+ }
217
+ const next = { ...blob, claudeAiOauth: { ...oauth,
218
+ accessToken: r.data.access_token,
219
+ refreshToken: r.data.refresh_token || oauth.refreshToken,
220
+ expiresAt: now() + (Number(r.data.expires_in) || 3600) * 1000,
221
+ } };
222
+ try {
223
+ atomicWrite(file, JSON.stringify(next));
224
+ state.reauth.delete(name);
225
+ return true;
226
+ } catch { return false; }
227
+ }
228
+
229
+ function tokenStale(name) {
230
+ const blob = readJson(path.join(DIR, `${name}.json`));
231
+ const oauth = blob && blob.claudeAiOauth;
232
+ if (!oauth || !oauth.accessToken) return true;
233
+ return !oauth.expiresAt || now() > oauth.expiresAt - 120_000;
234
+ }
235
+
236
+ async function fetchUsage() {
237
+ const r = await run(acctBin, ['usage', '--json'], 120_000);
238
+ if (!r.ok) return null;
239
+ try { return JSON.parse(r.stdout); } catch { return null; }
240
+ }
241
+
242
+ const WINDOWS = [
243
+ ['5h', 'five_hour'], ['weekly', 'seven_day'],
244
+ ['opus', 'seven_day_opus'], ['sonnet', 'seven_day_sonnet'],
245
+ ];
246
+
247
+ function rowsFor(result) {
248
+ if (!result.usage || !result.usage.ok) return null;
249
+ const d = result.usage.data || {};
250
+ const rows = [];
251
+ for (const [label, key] of WINDOWS) {
252
+ const w = d[key];
253
+ if (!w) continue;
254
+ const used = pct(w.utilization);
255
+ const optional = key === 'seven_day_opus' || key === 'seven_day_sonnet';
256
+ if (optional && !w.resets_at && (used === null || used === 0)) continue;
257
+ rows.push({ label, key, used, resetsAt: w.resets_at || null });
258
+ }
259
+ return rows;
260
+ }
261
+
262
+ function trendFor(keyName) {
263
+ const hist = readJson(HISTORY);
264
+ const entries = hist && hist.accounts && hist.accounts[keyName] && hist.accounts[keyName].five_hour || [];
265
+ return sparkline(entries.slice(-28).map((e) => pct(e.usedPercent) ?? 0));
266
+ }
267
+ function sparkline(values) {
268
+ const blocks = '▁▂▃▄▅▆▇█';
269
+ return values.map((u) => blocks[Math.max(0, Math.min(7, Math.round((u / 100) * 7)))]).join('');
270
+ }
271
+
272
+ function worstUsed(rows) { // decision windows only: 5h + weekly
273
+ let worst = null;
274
+ for (const r of rows || []) {
275
+ if (r.key !== 'five_hour' && r.key !== 'seven_day') continue;
276
+ if (r.used === null) continue;
277
+ if (worst === null || r.used > worst) worst = r.used;
278
+ }
279
+ return worst;
280
+ }
281
+
282
+ function pickTarget(report, activeName) {
283
+ const candidates = [];
284
+ for (const res of report.results) {
285
+ if (res.account === activeName || isRecovery(res.account)) continue;
286
+ const rows = rowsFor(res);
287
+ if (!rows) continue; // unknown usage → never a target
288
+ const worst = worstUsed(rows);
289
+ if (worst === null || worst >= 100 - THRESHOLD) continue; // also nearly dead
290
+ const fiveReset = (rows.find((r) => r.key === 'five_hour') || {}).resetsAt || '9999';
291
+ candidates.push({ name: res.account, worst, fiveReset });
292
+ }
293
+ candidates.sort((a, b) => a.worst - b.worst || String(a.fiveReset).localeCompare(String(b.fiveReset)));
294
+ return candidates[0] || null;
295
+ }
296
+
297
+ async function claudeAutopilot(report, state) {
298
+ const activeName = report.active;
299
+ if (!activeName) return;
300
+ const active = report.results.find((r) => r.account === activeName);
301
+ const rows = active && rowsFor(active);
302
+ if (!rows) return; // active usage unknown → handled by self-heal
303
+ const worst = worstUsed(rows);
304
+ if (worst === null || worst < 100 - THRESHOLD) { state.allHotNotified = false; state.holdReason = null; return; }
305
+
306
+ const target = pickTarget(report, activeName);
307
+ if (!target) {
308
+ state.holdReason = 'claude: all accounts hot or unavailable — holding';
309
+ if (!state.allHotNotified) {
310
+ notify('ai-acct-autopilot', 'All Claude accounts near their limits — nothing to switch to.');
311
+ journalAppend({ provider: 'claude', event: 'all-hot', active: activeName, worst });
312
+ state.allHotNotified = true;
313
+ }
314
+ return;
315
+ }
316
+ if (NO_SWITCH) {
317
+ state.holdReason = `claude: monitor-only — WOULD switch ${activeName} → ${target.name} (${Math.round(100 - worst)}% left)`;
318
+ return;
319
+ }
320
+ if (now() - lastSwitchTs('claude') < COOLDOWN_MS) {
321
+ state.holdReason = `claude: cooldown — would switch to ${target.name}`;
322
+ return;
323
+ }
324
+ state.holdReason = null;
325
+ const r = await run(acctBin, ['use', target.name], 60_000);
326
+ if (r.ok) {
327
+ journalAppend({ provider: 'claude', event: 'switch', from: activeName, to: target.name, reason: `${activeName} ${Math.round(100 - worst)}% left`, targetWorst: target.worst });
328
+ notify('ai-acct-autopilot: Claude switched', `${activeName} → ${target.name} (${activeName} had ${Math.round(100 - worst)}% left)`);
329
+ state.justSwitched = `claude ${activeName} → ${target.name}`;
330
+ } else {
331
+ journalAppend({ provider: 'claude', event: 'switch-failed', from: activeName, to: target.name });
332
+ notify('ai-acct-autopilot: Claude switch FAILED', `${activeName} → ${target.name} — check terminal`);
333
+ }
334
+ }
335
+
336
+ // Self-heal the ACTIVE claude account: its saved blob goes stale because the
337
+ // live keychain (Claude CLI) rotates tokens. Re-snapshot via claude-acct save.
338
+ async function healActive(report, state) {
339
+ const activeName = report.active;
340
+ if (!activeName) return false;
341
+ const active = report.results.find((r) => r.account === activeName);
342
+ if (!active || (active.usage && active.usage.ok)) return false;
343
+ if (state.lastHealTry && now() - state.lastHealTry < 5 * 60_000) return false;
344
+ state.lastHealTry = now();
345
+ const email = liveEmail();
346
+ // Save under the matching saved account, else under the live email itself —
347
+ // emails are the canonical account names, so no more unsaved-live-* blobs.
348
+ const match = accountNames().find((n) => n === email || (emailOf(n) && emailOf(n) === email))
349
+ || email || activeName;
350
+ const r = await run(acctBin, ['save', match], 30_000);
351
+ if (r.ok) journalAppend({ provider: 'claude', event: 'snapshot', account: match });
352
+ return r.ok;
353
+ }
354
+
355
+ // Persist profile-verified emails so future refresh targeting and active
356
+ // matching work for accounts saved before claude-acct wrote .meta files.
357
+ function persistEmails(report) {
358
+ for (const res of report.results || []) {
359
+ if (!res.email || emailOf(res.account)) continue;
360
+ try { fs.writeFileSync(path.join(DIR, `${res.account}.meta`), `email=${res.email}\n`, { mode: 0o600 }); } catch {}
361
+ }
362
+ }
363
+
364
+ // ════════════════════════ CODEX provider ════════════════════════
365
+ function codexJwtClaims(blob) {
366
+ try {
367
+ const idt = blob && blob.tokens && blob.tokens.id_token;
368
+ if (!idt) return null;
369
+ return JSON.parse(Buffer.from(idt.split('.')[1], 'base64url').toString());
370
+ } catch { return null; }
371
+ }
372
+ function codexIdentity(blob) {
373
+ const claims = codexJwtClaims(blob);
374
+ if (!claims) return null;
375
+ const auth = claims['https://api.openai.com/auth'] || {};
376
+ return { email: claims.email || null, plan: auth.chatgpt_plan_type || null };
377
+ }
378
+ function codexSavedAccounts() {
379
+ let files = [];
380
+ try { files = fs.readdirSync(CODEX_DIR); } catch {}
381
+ return files.filter((f) => f.endsWith('.json') && !f.startsWith('.')).map((f) => f.slice(0, -5));
382
+ }
383
+
384
+ // Direct per-account usage probe (CodexBar's OAuth path): GET wham/usage with
385
+ // the account's access token. Works for BENCHED accounts too — no session, no
386
+ // token burn. 401 token_revoked = that account needs a fresh `codex login`
387
+ // (codex sessions are single-active: each login revokes the previous one).
388
+ function codexProbeUsage(token) {
389
+ return new Promise((resolve) => {
390
+ if (!token) { resolve({ ok: false, status: 0 }); return; }
391
+ https.get('https://chatgpt.com/backend-api/wham/usage', {
392
+ headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, timeout: 15_000,
393
+ }, (res) => {
394
+ let b = '';
395
+ res.on('data', (c) => { b += c; });
396
+ res.on('end', () => {
397
+ let body = null; try { body = JSON.parse(b); } catch {}
398
+ if (res.statusCode !== 200 || !body) { resolve({ ok: false, status: res.statusCode, code: body && body.error && body.error.code }); return; }
399
+ resolve({ ok: true, status: 200, ...mapWham(body) });
400
+ });
401
+ }).on('error', () => resolve({ ok: false, status: 0 }))
402
+ .on('timeout', function () { this.destroy(); });
403
+ });
404
+ }
405
+ function mapWham(body) {
406
+ const rl = body.rate_limit || body; // windows nest under rate_limit; Spark etc. live in additional_rate_limits (ignored)
407
+ const toIso = (u) => (u ? new Date(u * 1000).toISOString() : null);
408
+ const win = (w, label, key) => (w ? { label, key, used: pct(w.used_percent), resetsAt: toIso(w.reset_at) } : null);
409
+ const rows = [
410
+ win(rl.primary_window, '5h', 'five_hour'),
411
+ win(rl.secondary_window, 'weekly', 'seven_day'),
412
+ ].filter(Boolean);
413
+ return { rows, worst: worstUsed(rows), email: body.email || null, plan: body.plan_type || null };
414
+ }
415
+
416
+ // Probe active + all saved codex accounts once per tick: email -> probe result.
417
+ async function codexProbeAll() {
418
+ const probes = new Map();
419
+ const activeBlob = readJson(CODEX_AUTH);
420
+ const activeId = codexIdentity(activeBlob);
421
+ if (activeId && activeId.email) {
422
+ probes.set(activeId.email, await codexProbeUsage(activeBlob.tokens && activeBlob.tokens.access_token));
423
+ }
424
+ for (const email of codexSavedAccounts()) {
425
+ if (probes.has(email)) continue;
426
+ const blob = readJson(path.join(CODEX_DIR, `${email}.json`));
427
+ probes.set(email, await codexProbeUsage(blob && blob.tokens && blob.tokens.access_token));
428
+ }
429
+ return probes;
430
+ }
431
+
432
+ function codexSnapshotActive() {
433
+ const blob = readJson(CODEX_AUTH);
434
+ const id = codexIdentity(blob);
435
+ if (!blob || !id || !id.email) return null;
436
+ fs.mkdirSync(CODEX_DIR, { recursive: true, mode: 0o700 });
437
+ // record last-known usage at bench time so target ranking has a hint
438
+ const usage = codexUsage();
439
+ const meta = usage && usage.fresh ? { worst: usage.worst, ts: new Date().toISOString() } : (blob._watchMeta || null);
440
+ atomicWrite(path.join(CODEX_DIR, `${id.email}.json`), JSON.stringify({ ...blob, _watchMeta: meta }));
441
+ return id.email;
442
+ }
443
+
444
+ function codexUse(email) {
445
+ const file = path.join(CODEX_DIR, `${email}.json`);
446
+ const target = readJson(file);
447
+ if (!target || !target.tokens || !target.tokens.refresh_token) return { ok: false, error: `no usable saved codex account "${email}"` };
448
+ codexSnapshotActive();
449
+ const { _watchMeta, ...clean } = target;
450
+ try { atomicWrite(CODEX_AUTH, JSON.stringify(clean, null, 2)); } catch (e) { return { ok: false, error: String(e.message || e) }; }
451
+ return { ok: true };
452
+ }
453
+
454
+ // Latest rate_limits event from codex rollout logs (passive; no API calls).
455
+ // primary = 5h window, secondary = weekly. Newest few files, tail-read only.
456
+ function codexRolloutFiles(maxAgeDays = 3) {
457
+ const out = [];
458
+ const cutoff = now() - maxAgeDays * 86400_000;
459
+ const walk = (dir, depth) => {
460
+ let entries = [];
461
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
462
+ for (const e of entries) {
463
+ const p = path.join(dir, e.name);
464
+ if (e.isDirectory() && depth < 4) walk(p, depth + 1);
465
+ else if (e.isFile() && e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) {
466
+ let st; try { st = fs.statSync(p); } catch { continue; }
467
+ if (st.mtimeMs >= cutoff) out.push({ p, mtime: st.mtimeMs });
468
+ }
469
+ }
470
+ };
471
+ walk(CODEX_SESSIONS, 0);
472
+ return out.sort((a, b) => b.mtime - a.mtime).slice(0, 4);
473
+ }
474
+
475
+ function tailLines(file, bytes = 512 * 1024) {
476
+ try {
477
+ const size = fs.statSync(file).size;
478
+ const fd = fs.openSync(file, 'r');
479
+ const len = Math.min(bytes, size);
480
+ const buf = Buffer.alloc(len);
481
+ fs.readSync(fd, buf, 0, len, size - len);
482
+ fs.closeSync(fd);
483
+ const lines = buf.toString('utf8').split('\n');
484
+ if (size > len) lines.shift(); // drop partial first line
485
+ return lines;
486
+ } catch { return []; }
487
+ }
488
+
489
+ // Codex reports SEPARATE limit buckets per model family (e.g. limit_id
490
+ // "codex" = the regular account limit, "codex_bengalfox" = GPT-5.3-Codex-Spark).
491
+ // Only the REGULAR bucket drives display + switching: prefer limit_id "codex",
492
+ // else the bucket without a special limit_name, else the freshest.
493
+ function chooseBucket(buckets) {
494
+ const keys = Object.keys(buckets);
495
+ if (!keys.length) return null;
496
+ if (buckets.codex) return buckets.codex;
497
+ const unnamed = keys.filter((k) => !(buckets[k].rl && buckets[k].rl.limit_name));
498
+ const pool = unnamed.length ? unnamed : keys;
499
+ return pool.map((k) => buckets[k]).sort((a, b) => b.ts - a.ts)[0];
500
+ }
501
+
502
+ let codexUsageCache = null; // per-tick cache
503
+ function codexUsage() {
504
+ if (codexUsageCache && now() - codexUsageCache.at < 30_000) return codexUsageCache.value;
505
+ const buckets = {}; // limit_id -> { ts, rl, samples: [{ts, used}] }
506
+ for (const f of codexRolloutFiles()) {
507
+ for (const line of tailLines(f.p)) {
508
+ let j; try { j = JSON.parse(line); } catch { continue; }
509
+ const rl = (j.payload && j.payload.rate_limits)
510
+ || (j.payload && j.payload.info && j.payload.info.rate_limits) || j.rate_limits;
511
+ if (!rl || !rl.primary) continue;
512
+ const ts = new Date(j.timestamp || 0).getTime();
513
+ const id = rl.limit_id || '(none)';
514
+ buckets[id] ||= { ts: 0, rl: null, samples: [] };
515
+ buckets[id].samples.push({ ts, used: pct(rl.primary.used_percent) ?? 0 });
516
+ if (ts > buckets[id].ts) { buckets[id].ts = ts; buckets[id].rl = rl; }
517
+ }
518
+ }
519
+ const chosen = chooseBucket(buckets);
520
+ let value = null;
521
+ if (chosen) {
522
+ const toIso = (u) => (u ? new Date(u * 1000).toISOString() : null);
523
+ const rows = [
524
+ { label: '5h', key: 'five_hour', used: pct(chosen.rl.primary.used_percent), resetsAt: toIso(chosen.rl.primary.resets_at) },
525
+ chosen.rl.secondary ? { label: 'weekly', key: 'seven_day', used: pct(chosen.rl.secondary.used_percent), resetsAt: toIso(chosen.rl.secondary.resets_at) } : null,
526
+ ].filter(Boolean);
527
+ chosen.samples.sort((a, b) => a.ts - b.ts);
528
+ value = {
529
+ rows, ts: chosen.ts,
530
+ fresh: now() - chosen.ts < CODEX_USAGE_MAX_AGE_MS,
531
+ worst: worstUsed(rows),
532
+ limitName: chosen.rl.limit_name || null, // null for the regular bucket
533
+ plan: chosen.rl.plan_type || null,
534
+ trend: sparkline(dedupeAdjacent(chosen.samples.map((s) => s.used)).slice(-28)),
535
+ };
536
+ }
537
+ codexUsageCache = { at: now(), value };
538
+ return value;
539
+ }
540
+ function dedupeAdjacent(arr) { return arr.filter((v, i) => i === 0 || v !== arr[i - 1]); }
541
+
542
+ function codexPickTarget(activeEmail, probes) {
543
+ const candidates = [];
544
+ for (const email of codexSavedAccounts()) {
545
+ if (email === activeEmail) continue;
546
+ const p = probes && probes.get(email);
547
+ if (!p || !p.ok || p.worst === null) continue; // unknown usage → never a target
548
+ if (p.worst >= 100 - THRESHOLD) continue; // nearly dead too
549
+ candidates.push({ name: email, worst: p.worst });
550
+ }
551
+ candidates.sort((a, b) => a.worst - b.worst);
552
+ return candidates[0] || null;
553
+ }
554
+
555
+ async function codexAutopilot(state) {
556
+ const blob = readJson(CODEX_AUTH);
557
+ const id = codexIdentity(blob);
558
+ if (!id || !id.email) return;
559
+ const probes = state.codexProbes || new Map();
560
+ const active = probes.get(id.email);
561
+ // prefer the live probe (always fresh); fall back to rollout logs offline
562
+ let worst = active && active.ok ? active.worst : null;
563
+ if (worst === null) {
564
+ const usage = codexUsage();
565
+ if (!usage || !usage.fresh || usage.ts < lastSwitchTs('codex')) return;
566
+ worst = usage.worst;
567
+ }
568
+ if (worst === null || worst < 100 - THRESHOLD) { state.codexAllHot = false; state.codexHold = null; return; }
569
+ const usage = { worst };
570
+
571
+ const target = codexPickTarget(id.email, probes);
572
+ if (!target) {
573
+ state.codexHold = codexSavedAccounts().filter((e) => e !== id.email).length
574
+ ? 'codex: all saved accounts look hot — holding'
575
+ : 'codex: no other saved account — run codex-save on a second account to enable switching';
576
+ if (!state.codexAllHot && codexSavedAccounts().length > 1) {
577
+ notify('ai-acct-autopilot', 'Codex account near its limits — no usable fallback saved.');
578
+ journalAppend({ provider: 'codex', event: 'all-hot', active: id.email, worst: usage.worst });
579
+ state.codexAllHot = true;
580
+ }
581
+ return;
582
+ }
583
+ if (NO_SWITCH) {
584
+ state.codexHold = `codex: monitor-only — WOULD switch ${id.email} → ${target.name} (${Math.round(100 - usage.worst)}% left)`;
585
+ return;
586
+ }
587
+ if (now() - lastSwitchTs('codex') < COOLDOWN_MS) {
588
+ state.codexHold = `codex: cooldown — would switch to ${target.name}`;
589
+ return;
590
+ }
591
+ state.codexHold = null;
592
+ const r = codexUse(target.name);
593
+ if (r.ok) {
594
+ journalAppend({ provider: 'codex', event: 'switch', from: id.email, to: target.name, reason: `${id.email} ${Math.round(100 - usage.worst)}% left` });
595
+ state.justSwitched = `codex ${id.email} → ${target.name}`;
596
+ // restart supervised running sessions so they resume threads on the new account
597
+ const { restarted, unsupervised } = await codexRestartSessions(state);
598
+ notify('ai-acct-autopilot: Codex switched', `${id.email} → ${target.name}${restarted ? ` — resuming ${restarted} running session(s)` : ''}${unsupervised ? `; ${unsupervised} pre-shim session(s) need manual restart` : ''}`);
599
+ } else {
600
+ journalAppend({ provider: 'codex', event: 'switch-failed', from: id.email, to: target.name, error: r.error });
601
+ notify('ai-acct-autopilot: Codex switch FAILED', r.error || 'check terminal');
602
+ }
603
+ }
604
+
605
+ // ---------- codex subcommands ----------
606
+ // codex-add: log a NEW codex account into the bench WITHOUT revoking the
607
+ // current session. `codex login` inside a shared home revokes whatever token
608
+ // it replaces, so the login runs in a throwaway isolated CODEX_HOME and the
609
+ // resulting auth.json is imported as a saved account (verified 2026-06-12:
610
+ // isolated-home logins leave other sessions alive).
611
+ if (argv[0] === 'codex-add') {
612
+ (async () => {
613
+ const { spawnSync } = require('node:child_process');
614
+ const state = readJson(SHIM_STATE) || {};
615
+ const real = state.realTarget;
616
+ if (!real) { console.error('codex shim state missing — run: ai-acct-autopilot codex-shim install'); process.exit(1); }
617
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-add-'));
618
+ console.log('Opening codex login in an isolated home (current session stays alive).');
619
+ console.log('Sign into the NEW account in the browser window.');
620
+ const r = spawnSync(real, ['login'], { stdio: 'inherit', env: { ...process.env, CODEX_HOME: tmpHome } });
621
+ const blob = readJson(path.join(tmpHome, 'auth.json'));
622
+ const id = codexIdentity(blob);
623
+ if (r.status !== 0 || !blob || !id || !id.email) {
624
+ console.error('Login did not produce a usable auth.json — nothing imported.');
625
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
626
+ process.exit(1);
627
+ }
628
+ const probe = await codexProbeUsage(blob.tokens && blob.tokens.access_token);
629
+ fs.mkdirSync(CODEX_DIR, { recursive: true, mode: 0o700 });
630
+ atomicWrite(path.join(CODEX_DIR, `${id.email}.json`), JSON.stringify(blob));
631
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
632
+ console.log(`Added '${id.email}' (${id.plan || '?'}) to the codex bench${probe.ok ? ` — live, ${Math.round(100 - (probe.worst ?? 0))}% left` : ''}.`);
633
+ console.log('It joins the dashboard and autopilot targeting immediately.');
634
+ process.exit(0);
635
+ })();
636
+ }
637
+ if (argv[0] === 'codex-save') {
638
+ const email = codexSnapshotActive();
639
+ if (email) { console.log(`Saved current codex account as '${email}'.`); process.exit(0); }
640
+ console.error('No usable ~/.codex/auth.json (chatgpt login) found.'); process.exit(1);
641
+ }
642
+ if (argv[0] === 'codex-use') {
643
+ const email = argv[1];
644
+ if (!email) { console.error('usage: ai-acct-autopilot codex-use <email>'); process.exit(1); }
645
+ const cur = codexIdentity(readJson(CODEX_AUTH));
646
+ if (cur && cur.email === email) { console.log(`'${email}' is already the active codex account.`); process.exit(0); }
647
+ const r = codexUse(email);
648
+ if (!r.ok) { console.error(r.error); process.exit(1); }
649
+ journalAppend({ provider: 'codex', event: 'switch', from: 'manual', to: email, reason: 'manual codex-use' });
650
+ console.log(`Switched codex to '${email}'. New codex sessions use it now.`);
651
+ (async () => {
652
+ const { restarted, unsupervised } = await codexRestartSessions(null);
653
+ if (restarted) console.log(`Restarting ${restarted} supervised running session(s) — they resume their threads on the new account.`);
654
+ if (unsupervised) console.log(`${unsupervised} pre-shim session(s) keep the old account until restarted (openai/codex#17041).`);
655
+ process.exit(0);
656
+ })();
657
+ }
658
+ if (argv[0] === 'codex-list') {
659
+ const blob = readJson(CODEX_AUTH);
660
+ const id = codexIdentity(blob) || {};
661
+ const saved = codexSavedAccounts();
662
+ if (!saved.length && !id.email) { console.log('No codex accounts found.'); process.exit(0); }
663
+ for (const e of new Set([...(id.email ? [id.email] : []), ...saved])) {
664
+ const marks = [e === id.email ? 'live' : null, saved.includes(e) ? 'saved' : 'not saved — run codex-save'].filter(Boolean);
665
+ console.log(`${e === id.email ? '*' : ' '} ${e} (${marks.join(', ')})`);
666
+ }
667
+ process.exit(0);
668
+ }
669
+
670
+ // codex-ensure: fast pre-launch check used by the codex shim. If the active
671
+ // codex account has < threshold % left (on FRESH data) and a better saved
672
+ // account exists, swap auth.json BEFORE the new codex process starts.
673
+ // Fail-open and quiet: any problem → exit 0 so codex always launches.
674
+ if (argv[0] === 'codex-ensure') {
675
+ try {
676
+ const usage = codexUsage();
677
+ const id = codexIdentity(readJson(CODEX_AUTH));
678
+ if (!usage || !usage.fresh || !id || !id.email) process.exit(0);
679
+ if (usage.worst === null || usage.worst < 100 - THRESHOLD) process.exit(0);
680
+ if (now() - lastSwitchTs('codex') < COOLDOWN_MS) process.exit(0);
681
+ const target = codexPickTarget(id.email);
682
+ if (!target) process.exit(0);
683
+ const r = codexUse(target.name);
684
+ if (r.ok) {
685
+ journalAppend({ provider: 'codex', event: 'switch', from: id.email, to: target.name, reason: `launch ensure: ${id.email} ${Math.round(100 - usage.worst)}% left` });
686
+ notify('ai-acct-autopilot: Codex switched at launch', `${id.email} → ${target.name}`);
687
+ if (!flag('--quiet')) console.error(`[ai-acct-autopilot] codex account switched: ${id.email} → ${target.name}`);
688
+ }
689
+ } catch {}
690
+ process.exit(0);
691
+ }
692
+
693
+ // codex-shim: wrap the real codex binary as a SUPERVISOR. Every launch runs
694
+ // codex-ensure first (account decision at process start — running sessions
695
+ // never re-read auth.json). And when the watcher switches accounts, it
696
+ // terminates running codex processes after dropping a restart marker with the
697
+ // session id; the supervisor sees marker+death and relaunches
698
+ // `codex resume <session-id>` — same thread, fresh auth, no human.
699
+ function shimScript() {
700
+ return `#!/bin/sh
701
+ ${SHIM_MARK} v3 (node supervisor)
702
+ exec node "${__filename}" codex-supervise -- "$@"
703
+ `;
704
+ }
705
+
706
+ // Build the relaunch argv that resumes session `sid` while preserving the
707
+ // original launch flags (Superset prepends --enable hooks -c notify=[...]).
708
+ // exec mode: <args up to and incl. "exec"> resume <sid>
709
+ // tui mode: <flag args (values preserved)> resume <sid> (positional prompt dropped)
710
+ const VALUE_FLAGS = new Set(['-c', '--config', '-m', '--model', '-p', '--profile',
711
+ '--enable', '--disable', '-C', '--cd', '-s', '--sandbox', '-a', '--ask-for-approval',
712
+ '-i', '--image', '--output-schema', '--output-last-message', '--color']);
713
+ function buildResumeArgs(args, sid) {
714
+ const execIdx = (() => {
715
+ for (let i = 0; i < args.length; i++) {
716
+ const a = args[i];
717
+ if (a === 'exec') return i;
718
+ if (a.startsWith('-')) { if (VALUE_FLAGS.has(a)) i++; continue; }
719
+ return -1; // first positional isn't "exec" → tui with prompt
720
+ }
721
+ return -1;
722
+ })();
723
+ if (execIdx >= 0) return [...args.slice(0, execIdx + 1), 'resume', sid];
724
+ const flags = [];
725
+ for (let i = 0; i < args.length; i++) {
726
+ const a = args[i];
727
+ if (a.startsWith('-')) {
728
+ flags.push(a);
729
+ if (VALUE_FLAGS.has(a) && i + 1 < args.length) flags.push(args[++i]);
730
+ } // positional (initial prompt) dropped — the resumed thread already has it
731
+ }
732
+ return [...flags, 'resume', sid];
733
+ }
734
+
735
+ // The supervisor: launch the real codex, and when the watcher kills it after
736
+ // an account switch (restart marker present), relaunch `codex … resume <sid>`
737
+ // so the SAME thread continues on the new account. Without a marker, behave
738
+ // exactly like stock codex (mirror exit code/signal).
739
+ async function codexSupervise() {
740
+ const { spawn } = require('node:child_process');
741
+ const sep = argv.indexOf('--');
742
+ const args = sep >= 0 ? argv.slice(sep + 1) : [];
743
+ const state = readJson(SHIM_STATE) || {};
744
+ const real = process.env.AI_CLI_WATCH_REAL || state.realTarget;
745
+ if (!real) { console.error('[ai-acct-autopilot] shim state missing realTarget'); process.exit(127); }
746
+ process.on('SIGINT', () => {}); // ctrl-c belongs to codex (turn interrupt)
747
+ try { await new Promise((res) => execFile(process.execPath, [__filename, 'codex-ensure', '--quiet'], () => res())); } catch {}
748
+ let launchArgs = args;
749
+ for (;;) {
750
+ const child = spawn(real, launchArgs, { stdio: 'inherit' });
751
+ const { code, signal } = await new Promise((res) => child.on('exit', (c, s) => res({ code: c, signal: s })));
752
+ const marker = path.join(RESTART_DIR, String(child.pid));
753
+ let sid = null;
754
+ try { sid = fs.readFileSync(marker, 'utf8').trim() || null; fs.rmSync(marker); } catch {
755
+ // no marker → normal exit / user quit: mirror it
756
+ if (signal) { try { process.kill(process.pid, signal); } catch {} process.exit(1); }
757
+ process.exit(code == null ? 1 : code);
758
+ }
759
+ try { await new Promise((res) => execFile(process.execPath, [__filename, 'codex-ensure', '--quiet'], () => res())); } catch {}
760
+ if (sid) {
761
+ launchArgs = buildResumeArgs(args, sid);
762
+ console.error(`[ai-acct-autopilot] account switched — resuming codex session ${sid} on the new account`);
763
+ } else {
764
+ launchArgs = args;
765
+ console.error('[ai-acct-autopilot] account switched — relaunching codex on the new account');
766
+ }
767
+ }
768
+ }
769
+ if (argv[0] === 'codex-supervise') { codexSupervise(); }
770
+
771
+ // Running codex LAUNCHER processes (node codex.js): [{pid, supervised,
772
+ // descendants}]. The launcher is the right kill target — it forwards SIGTERM
773
+ // to the native codex binary and mirrors its exit. The native binary (a
774
+ // descendant) is what holds the rollout file open.
775
+ async function codexRunningProcs() {
776
+ const state = readJson(SHIM_STATE) || {};
777
+ const r = await run('/bin/ps', ['-axo', 'pid=,ppid=,command=']);
778
+ const byPid = new Map();
779
+ for (const line of r.stdout.split('\n')) {
780
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
781
+ if (m) byPid.set(Number(m[1]), { ppid: Number(m[2]), cmd: m[3] });
782
+ }
783
+ const childrenOf = (pid) => [...byPid.entries()].filter(([, v]) => v.ppid === pid).map(([p]) => p);
784
+ const procs = [];
785
+ for (const [pid, { ppid, cmd }] of byPid) {
786
+ const isLauncher = (state.realTarget && cmd.includes(state.realTarget))
787
+ || /@openai\/codex\/bin\/codex\.js/.test(cmd);
788
+ if (!isLauncher || cmd.includes('app-server')) continue; // never touch the Codex.app server
789
+ const parent = byPid.get(ppid);
790
+ const supervised = !!(parent && parent.cmd.includes('codex-supervise'));
791
+ const descendants = childrenOf(pid).flatMap((c) => [c, ...childrenOf(c)]);
792
+ procs.push({ pid, supervised, descendants });
793
+ }
794
+ return procs;
795
+ }
796
+
797
+ // Session id of a running codex session = the uuid of the rollout file held
798
+ // open by the native binary (or the launcher itself, version-dependent).
799
+ async function codexSidOf(proc) {
800
+ for (const pid of [proc.pid, ...proc.descendants]) {
801
+ const r = await run('/usr/sbin/lsof', ['-p', String(pid), '-Fn'], 10_000);
802
+ const m = r.stdout.match(/rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f-]{36})\.jsonl/);
803
+ if (m) return m[1];
804
+ }
805
+ return null;
806
+ }
807
+
808
+ // After a codex account switch: restart supervised running sessions so they
809
+ // resume their threads on the new account. Unsupervised ones (launched before
810
+ // the shim) can only be notified about.
811
+ async function codexRestartSessions(state) {
812
+ const procs = await codexRunningProcs();
813
+ if (!procs.length) return { restarted: 0, unsupervised: 0 };
814
+ fs.mkdirSync(RESTART_DIR, { recursive: true, mode: 0o700 });
815
+ let restarted = 0, unsupervised = 0;
816
+ for (const p of procs) {
817
+ if (!p.supervised) { unsupervised++; continue; }
818
+ const sid = await codexSidOf(p);
819
+ try {
820
+ fs.writeFileSync(path.join(RESTART_DIR, String(p.pid)), sid || '', { mode: 0o600 });
821
+ process.kill(p.pid, 'SIGTERM');
822
+ restarted++;
823
+ journalAppend({ provider: 'codex', event: 'session-restart', pid: p.pid, sid: sid || null });
824
+ } catch { try { fs.rmSync(path.join(RESTART_DIR, String(p.pid))); } catch {} }
825
+ }
826
+ if (restarted || unsupervised) {
827
+ const parts = [];
828
+ if (restarted) parts.push(`${restarted} session(s) auto-resuming on the new account`);
829
+ if (unsupervised) parts.push(`${unsupervised} pre-shim session(s) need a manual restart`);
830
+ notify('ai-acct-autopilot: Codex sessions', parts.join('; '));
831
+ if (state) state.codexHold = unsupervised ? `codex: ${unsupervised} pre-shim session(s) still on the old account — restart them manually` : state.codexHold;
832
+ }
833
+ return { restarted, unsupervised };
834
+ }
835
+ function findRealCodex() {
836
+ const candidates = [
837
+ '/opt/homebrew/bin/codex',
838
+ path.join(HOME, '.bun', 'bin', 'codex'),
839
+ '/usr/local/bin/codex',
840
+ ];
841
+ for (const p of candidates) {
842
+ try {
843
+ if (!fs.existsSync(p)) continue;
844
+ const text = fs.lstatSync(p).isSymbolicLink() ? '' : fs.readFileSync(p, 'utf8');
845
+ const content = !text || !(text.includes(SHIM_MARK) || text.includes(SHIM_MARK_LEGACY));
846
+ return { binPath: p, isShim: !content };
847
+ } catch {}
848
+ }
849
+ return null;
850
+ }
851
+ if (argv[0] === 'codex-shim') {
852
+ const sub = argv[1] || 'status';
853
+ const found = findRealCodex();
854
+ if (!found) { console.error('codex binary not found in known locations.'); process.exit(1); }
855
+ const state = readJson(SHIM_STATE);
856
+ if (sub === 'status') {
857
+ console.log(found.isShim ? `shim INSTALLED at ${found.binPath} (real: ${state && state.realTarget})`
858
+ : `shim not installed — ${found.binPath} is the stock codex`);
859
+ process.exit(0);
860
+ }
861
+ if (sub === 'install') {
862
+ let realTarget;
863
+ if (found.isShim) {
864
+ const cur = fs.readFileSync(found.binPath, 'utf8');
865
+ if (cur.includes(`${SHIM_MARK} v3`)) { console.log('shim v3 already installed.'); process.exit(0); }
866
+ realTarget = (state && state.realTarget) || null; // upgrade older shim
867
+ if (!realTarget) { console.error('shim state missing; run codex-shim uninstall first.'); process.exit(1); }
868
+ } else {
869
+ realTarget = fs.realpathSync(found.binPath);
870
+ }
871
+ fs.writeFileSync(SHIM_STATE, JSON.stringify({ binPath: found.binPath, realTarget, installedAt: new Date().toISOString(), version: 3 }, null, 2));
872
+ fs.rmSync(found.binPath);
873
+ fs.writeFileSync(found.binPath, shimScript(), { mode: 0o755 });
874
+ console.log(`shim v3 (node supervisor) installed: ${found.binPath} → ensure + supervise → ${realTarget}`);
875
+ console.log('On watcher-initiated account switches, supervised codex sessions auto-resume their threads.');
876
+ console.log('NOTE: an npm upgrade of @openai/codex restores the stock binary (fail-safe); re-run codex-shim install after upgrades.');
877
+ process.exit(0);
878
+ }
879
+ if (sub === 'uninstall') {
880
+ if (!found.isShim || !state || !state.realTarget) { console.log('shim not installed.'); process.exit(0); }
881
+ fs.rmSync(found.binPath);
882
+ fs.symlinkSync(state.realTarget, found.binPath);
883
+ console.log(`shim removed: ${found.binPath} → ${state.realTarget}`);
884
+ process.exit(0);
885
+ }
886
+ console.error('usage: ai-acct-autopilot codex-shim [status|install|uninstall]');
887
+ process.exit(1);
888
+ }
889
+
890
+ // ════════════════════════ render ════════════════════════
891
+ function bar(used, width) {
892
+ const u = used === null ? 0 : used;
893
+ const filled = Math.round((u / 100) * width);
894
+ const color = used === null ? C.grey2 : u >= 95 ? C.red : u >= 85 ? C.amber : C.tan;
895
+ return color + '█'.repeat(filled) + C.grey2 + '░'.repeat(Math.max(0, width - filled)) + C.reset;
896
+ }
897
+ function fmtLeft(used) {
898
+ if (used === null) return `${C.grey}–${C.reset}`;
899
+ const left = Math.round(100 - used);
900
+ const col = left <= THRESHOLD ? C.red : left <= 15 ? C.amber : C.white;
901
+ return `${col}${left}% left${C.reset}`;
902
+ }
903
+
904
+ function render(report, state) {
905
+ const cols = Math.max(72, process.stdout.columns || 100);
906
+ const barW = Math.max(20, Math.min(44, cols - 52));
907
+ const L = [];
908
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - stripLen(s)));
909
+ const right = (left, rightTxt) => pad(left, cols - stripLen(rightTxt) - 1) + rightTxt;
910
+ const divider = (label) => ` ${C.grey}${C.bold}${label}${C.reset} ${C.grey2}${'─'.repeat(Math.max(4, cols - label.length - 8))}${C.reset}`;
911
+
912
+ // header
913
+ const mode = NO_SWITCH ? `${C.grey}MONITOR ONLY${C.reset}` : `${C.green}AUTO-SWITCH${C.reset}${C.grey} at <${THRESHOLD}% left${C.reset}`;
914
+ const time = new Date().toLocaleTimeString();
915
+ L.push('');
916
+ L.push(right(` ${C.bold}${C.white}◉ AI CLI Accounts${C.reset} ${mode}`, `${C.grey}updated ${time}${C.reset} `));
917
+ L.push('');
918
+
919
+ // ---- claude section ----
920
+ L.push(divider('CLAUDE'));
921
+ if (!report) {
922
+ L.push(` ${C.red}claude-acct usage failed — retrying next tick${C.reset}`);
923
+ } else {
924
+ for (const res of report.results) {
925
+ const isActive = res.account === report.active;
926
+ const email = res.email && res.email !== res.account ? `${C.grey}${res.email}${C.reset}` : '';
927
+ const sub = res.subscriptionType ? `${C.grey2} · ${res.subscriptionType}${C.reset}` : '';
928
+ const activeTag = isActive ? `${C.green}● ACTIVE${C.reset}` : (state.reauth.has(res.account) ? `${C.amber}re-auth needed${C.reset}` : '');
929
+ const nameCol = isActive ? C.bold + C.orange : C.bold + C.white;
930
+ L.push('');
931
+ L.push(right(` ${nameCol}${res.account}${C.reset}${sub} ${email}`, `${activeTag} `));
932
+ if (isRecovery(res.account)) L.push(` ${C.amber}recovered keychain snapshot — adopt with: claude-acct save <email>${C.reset}`);
933
+
934
+ const rows = rowsFor(res);
935
+ if (!rows) {
936
+ const why = res.usage && (res.usage.status === 401 || res.usage.status === 403)
937
+ ? (isActive ? 'token stale — re-snapshotting from keychain' : 'token expired — auto-refresh next tick or re-auth')
938
+ : 'usage unavailable';
939
+ L.push(` ${C.grey2}${'·'.repeat(barW)}${C.reset} ${C.grey}${why}${C.reset}`);
940
+ continue;
941
+ }
942
+ for (const row of rows) {
943
+ const reset = row.resetsAt ? `${C.grey}resets ${rel(row.resetsAt)}${C.reset}` : `${C.grey2}no active window${C.reset}`;
944
+ const lbl = `${C.grey}${row.label.padEnd(7)}${C.reset}`;
945
+ L.push(right(` ${lbl}${bar(row.used, barW)} ${fmtLeft(row.used)}`, reset + ' '));
946
+ }
947
+ const tr = trendFor(res.email || res.account);
948
+ if (tr) L.push(` ${C.grey}trend ${C.reset}${C.tan}${C.dim}${tr}${C.reset} ${C.grey2}5h window${C.reset}`);
949
+ }
950
+ }
951
+
952
+ // ---- codex section ----
953
+ L.push('');
954
+ L.push(divider('CODEX'));
955
+ const codexBlob = readJson(CODEX_AUTH);
956
+ const codexId = codexIdentity(codexBlob);
957
+ const usage = codexUsage();
958
+ if (!codexId || !codexId.email) {
959
+ L.push(` ${C.grey}no codex chatgpt login found (~/.codex/auth.json)${C.reset}`);
960
+ } else {
961
+ const probes = state.codexProbes || new Map();
962
+ const plan = (usage && usage.plan) || codexId.plan;
963
+ const emails = [codexId.email, ...codexSavedAccounts().filter((e) => e !== codexId.email)];
964
+ for (const email of emails) {
965
+ const isActive = email === codexId.email;
966
+ const p = probes.get(email);
967
+ const dead = p && !p.ok && (p.status === 401 || p.status === 403);
968
+ const tag = isActive ? `${C.green}● ACTIVE${C.reset}` : (dead ? `${C.amber}re-login needed${C.reset}` : '');
969
+ const nameCol = isActive ? C.bold + C.orange : C.bold + C.white;
970
+ L.push('');
971
+ L.push(right(` ${nameCol}${email}${C.reset}${isActive && plan ? `${C.grey2} · ${plan}${C.reset}` : ''}`, `${tag} `));
972
+ if (isActive && !codexSavedAccounts().includes(email)) L.push(` ${C.grey}not snapshotted yet — run: ai-acct-autopilot codex-save${C.reset}`);
973
+
974
+ const rows = p && p.ok ? p.rows : (isActive && usage ? usage.rows : null);
975
+ if (!rows) {
976
+ const why = dead
977
+ ? `session revoked — revive with: ai-acct-autopilot codex-add ${email}`
978
+ : (p && p.status === 0 ? 'probe failed — network?' : 'usage unknown');
979
+ L.push(` ${C.grey2}${'·'.repeat(barW)}${C.reset} ${C.grey}${why}${C.reset}`);
980
+ continue;
981
+ }
982
+ for (const row of rows) {
983
+ const reset = row.resetsAt ? `${C.grey}resets ${rel(row.resetsAt)}${C.reset}` : `${C.grey2}no active window${C.reset}`;
984
+ L.push(right(` ${C.grey}${row.label.padEnd(7)}${C.reset}${bar(row.used, barW)} ${fmtLeft(row.used)}`, reset + ' '));
985
+ }
986
+ if (isActive && usage && usage.trend) {
987
+ L.push(` ${C.grey}trend ${C.reset}${C.tan}${C.dim}${usage.trend}${C.reset} ${C.grey2}5h window (local sessions)${C.reset}`);
988
+ }
989
+ }
990
+ }
991
+
992
+ // ---- local-log stats panel (codexbar-style; account-independent) ----
993
+ L.push('');
994
+ L.push(divider('LOCAL USAGE · estimated at API rates · all accounts combined'));
995
+ if (!state.stats) {
996
+ L.push(` ${C.grey}${state.statsProgress || 'scanning local session logs (first run takes a minute)…'}${C.reset}`);
997
+ } else {
998
+ const panel = (label, s, color) => {
999
+ const money = (x, dash) => (x < 0.005 && dash ? '—' : `$${x.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
1000
+ const tok = (n) => n == null ? '—'
1001
+ : n >= 1e9 ? `${(n / 1e9).toFixed(1).replace(/\.0$/, '')}B`
1002
+ : n >= 1e6 ? `${(n / 1e6).toFixed(1).replace(/\.0$/, '')}M`
1003
+ : n >= 1e3 ? `${(n / 1e3).toFixed(0)}K` : String(n);
1004
+ const max = Math.max(...s.hist, 0);
1005
+ const blocks = '▁▂▃▄▅▆▇█';
1006
+ const hist = s.hist.map((v) => max > 0 && v > 0
1007
+ ? color + blocks[Math.max(0, Math.min(7, Math.round((v / max) * 7)))] + C.reset
1008
+ : C.grey2 + '▁' + C.reset).join('');
1009
+ return [
1010
+ `${C.bold}${color}${label}${C.reset}`,
1011
+ `${C.grey}today${C.reset} ${C.white}${money(s.todayCost, true)}${C.reset} ${C.grey}30d cost${C.reset} ${C.white}${money(s.cost30)}${C.reset}`,
1012
+ `${C.grey}30d tokens${C.reset} ${C.white}${tok(s.tokens30)}${C.reset} ${C.grey}latest${C.reset} ${C.white}${tok(s.lastTokens)}${C.reset}`,
1013
+ hist + ` ${C.grey2}30d${C.reset}`,
1014
+ `${C.grey2}top model: ${s.topModel || '—'}${C.reset}`,
1015
+ ];
1016
+ };
1017
+ const cl = panel('CLAUDE', state.stats.claude, C.tan);
1018
+ const cx = panel('CODEX', state.stats.codex, C.blue);
1019
+ const colW = Math.floor((cols - 6) / 2);
1020
+ if (cols >= 104) {
1021
+ for (let i = 0; i < Math.max(cl.length, cx.length); i++) {
1022
+ L.push(` ${pad(cl[i] || '', colW)}${cx[i] || ''}`);
1023
+ }
1024
+ } else {
1025
+ for (const l of cl) L.push(` ${l}`);
1026
+ L.push('');
1027
+ for (const l of cx) L.push(` ${l}`);
1028
+ }
1029
+ if (state.statsProgress) L.push(` ${C.grey2}${state.statsProgress}${C.reset}`);
1030
+ }
1031
+
1032
+ // events + footer
1033
+ L.push('');
1034
+ L.push(` ${C.grey2}${'─'.repeat(cols - 4)}${C.reset}`);
1035
+ if (state.justSwitched) L.push(` ${C.green}⇄ switched ${state.justSwitched}${C.reset}`);
1036
+ if (state.holdReason) L.push(` ${C.amber}▲ ${state.holdReason}${C.reset}`);
1037
+ if (state.codexHold) L.push(` ${C.amber}▲ ${state.codexHold}${C.reset}`);
1038
+ for (const e of journalTail(3)) {
1039
+ const t = new Date(e.ts).toLocaleString(undefined, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
1040
+ const prov = e.provider && e.provider !== 'claude' ? `${e.provider} ` : '';
1041
+ const what = e.event === 'switch' ? `${prov}${e.from} → ${e.to}${e.reason ? ` (${e.reason})` : ''}`
1042
+ : e.event === 'all-hot' ? `${prov}all accounts hot — held`
1043
+ : e.event === 'snapshot' ? `re-snapshotted ${e.account} from keychain`
1044
+ : e.event === 'switch-failed' ? `${prov}switch FAILED ${e.from} → ${e.to}` : `${prov}${e.event}`;
1045
+ L.push(` ${C.grey2}${t}${C.reset} ${C.grey}${what}${C.reset}`);
1046
+ }
1047
+ state.footerRow = L.length + 1;
1048
+ L.push(` ${C.grey2}next check in ${INTERVAL}s · codex switches apply to new sessions · ctrl-c to quit${C.reset}`);
1049
+
1050
+ if (PLAIN) { console.log(L.map((l) => l.replace(/\x1b\[[0-9;]*m/g, '')).join('\n')); return; }
1051
+ const out = L.map((l) => l + '\x1b[K').join('\n') + '\n\x1b[J';
1052
+ process.stdout.write('\x1b[H' + out);
1053
+ }
1054
+
1055
+ // ════════════════════════ main loop ════════════════════════
1056
+ async function tick(state) {
1057
+ codexUsageCache = null;
1058
+ // 1. self-heal: refresh stale non-active claude blobs
1059
+ const live = liveEmail();
1060
+ for (const name of accountNames()) {
1061
+ if (isRecovery(name)) continue;
1062
+ const mail = emailOf(name);
1063
+ if (live && (name === live || (mail && mail === live))) continue; // keychain owns the active account
1064
+ if (state.lastReport && name === state.lastReport.active) continue;
1065
+ if (tokenStale(name)) await refreshBlob(name, state);
1066
+ }
1067
+ // 2. poll usage (claude via claude-acct; codex via wham probes per account)
1068
+ const report = await fetchUsage();
1069
+ if (report) { state.lastReport = report; persistEmails(report); }
1070
+ try { state.codexProbes = await codexProbeAll(); } catch {}
1071
+ // keep the active codex account's saved blob fresh (the live CLI rotates
1072
+ // tokens in auth.json; mirror them so switching away never strands it)
1073
+ try {
1074
+ const live = readJson(CODEX_AUTH);
1075
+ const id = codexIdentity(live);
1076
+ if (id && id.email) {
1077
+ const saved = readJson(path.join(CODEX_DIR, `${id.email}.json`));
1078
+ if (!saved || (saved.tokens && live.tokens && saved.tokens.access_token !== live.tokens.access_token)) codexSnapshotActive();
1079
+ }
1080
+ } catch {}
1081
+ // 3. self-heal active (re-snapshot from keychain on 401), then autopilots
1082
+ if (report) {
1083
+ const healed = await healActive(report, state);
1084
+ if (!healed) await claudeAutopilot(report, state);
1085
+ }
1086
+ await codexAutopilot(state);
1087
+ // 4. render
1088
+ render(report || state.lastReport, state);
1089
+ state.justSwitched = null;
1090
+ }
1091
+
1092
+ // Synthetic decision-logic self-test (no network, no account mutation).
1093
+ function testDecision() {
1094
+ const W = (fh, wk) => ({ ok: true, data: {
1095
+ five_hour: { utilization: fh, resets_at: '2099-01-01T00:00:00Z' },
1096
+ seven_day: { utilization: wk, resets_at: '2099-01-02T00:00:00Z' },
1097
+ } });
1098
+ const mk = (account, usage) => ({ account, usage });
1099
+ let pass = 0, fail = 0;
1100
+ const check = (name, cond) => { cond ? pass++ : fail++; console.log(`${cond ? 'PASS' : 'FAIL'} ${name}`); };
1101
+
1102
+ let rep = { active: 'main', results: [mk('main', W(96, 50)), mk('alt2', W(12, 31)), mk('alt3', W(64, 40))] };
1103
+ check('claude: 5h trigger picks lowest-worst target', (pickTarget(rep, 'main') || {}).name === 'alt2');
1104
+ check('claude: worstUsed takes max of 5h/weekly', worstUsed(rowsFor(rep.results[0])) === 96);
1105
+
1106
+ rep = { active: 'main', results: [mk('main', W(10, 97)), mk('alt2', W(12, 31))] };
1107
+ check('claude: weekly window also triggers (worst=97)', worstUsed(rowsFor(rep.results[0])) === 97);
1108
+
1109
+ rep = { active: 'main', results: [mk('main', W(96, 50)), mk('alt2', W(99, 10)), mk('alt3', { ok: false, status: 401 })] };
1110
+ check('claude: all-hot — near-dead + unknown-usage excluded', pickTarget(rep, 'main') === null);
1111
+
1112
+ rep = { active: 'main', results: [mk('main', W(96, 50)), mk('unsaved-live-x', W(5, 5)), mk('alt2', W(40, 20))] };
1113
+ check('claude: recovery blobs never targeted', (pickTarget(rep, 'main') || {}).name === 'alt2');
1114
+
1115
+ rep = { active: 'main', results: [mk('main', W(50, 50)), mk('alt2', W(5, 5))] };
1116
+ check('claude: no trigger below threshold (worst=50)', worstUsed(rowsFor(rep.results[0])) < 100 - THRESHOLD);
1117
+
1118
+ // codex: primary/secondary map to the same decision shape
1119
+ const codexRows = [
1120
+ { label: '5h', key: 'five_hour', used: 97, resetsAt: '2099-01-01T00:00:00Z' },
1121
+ { label: 'weekly', key: 'seven_day', used: 12, resetsAt: '2099-01-02T00:00:00Z' },
1122
+ ];
1123
+ check('codex: primary window drives worst (97)', worstUsed(codexRows) === 97);
1124
+ check('codex: sparkline dedupes flat samples', sparkline(dedupeAdjacent([0, 0, 50, 50, 100])) === '▁▅█');
1125
+
1126
+ // wham/usage response mapping (probed shape verified live 2026-06-12:
1127
+ // windows nest under rate_limit; email/plan_type at top level)
1128
+ const wham = mapWham({ email: 'a@x.com', plan_type: 'pro', rate_limit: { allowed: true, limit_reached: false,
1129
+ primary_window: { used_percent: 35, limit_window_seconds: 18000, reset_at: 1781225541 },
1130
+ secondary_window: { used_percent: 56, limit_window_seconds: 604800, reset_at: 1781812341 } },
1131
+ additional_rate_limits: [{ limit_name: 'GPT-5.3-Codex-Spark' }] });
1132
+ check('codex: wham maps primary/secondary to 5h/weekly rows',
1133
+ wham.rows.length === 2 && wham.rows[0].key === 'five_hour' && wham.rows[1].key === 'seven_day');
1134
+ check('codex: wham worst = max window (56)', wham.worst === 56);
1135
+
1136
+ // resume-arg construction preserves launch flags (Superset prepends them)
1137
+ const SS = ['--enable', 'hooks', '-c', 'notify=["bash","/x/notify.sh"]'];
1138
+ check('shim: exec resume keeps superset flags',
1139
+ JSON.stringify(buildResumeArgs([...SS, 'exec', 'Write a story'], 'SID')) === JSON.stringify([...SS, 'exec', 'resume', 'SID']));
1140
+ check('shim: tui resume keeps flags, drops prompt',
1141
+ JSON.stringify(buildResumeArgs([...SS, 'fix the bug'], 'SID')) === JSON.stringify([...SS, 'resume', 'SID']));
1142
+ check('shim: bare tui resume', JSON.stringify(buildResumeArgs([], 'SID')) === JSON.stringify(['resume', 'SID']));
1143
+
1144
+ // bucket selection: regular limit beats special model-family buckets
1145
+ let buckets = {
1146
+ codex_bengalfox: { ts: 2000, rl: { limit_name: 'GPT-5.3-Codex-Spark', primary: {} } },
1147
+ codex: { ts: 1000, rl: { limit_name: null, primary: {} } },
1148
+ };
1149
+ check('codex: "codex" bucket wins over newer Spark bucket', chooseBucket(buckets) === buckets.codex);
1150
+ buckets = {
1151
+ a_named: { ts: 2000, rl: { limit_name: 'Special', primary: {} } },
1152
+ b_unnamed: { ts: 1000, rl: { limit_name: null, primary: {} } },
1153
+ };
1154
+ check('codex: unnamed bucket preferred when no "codex" id', chooseBucket(buckets) === buckets.b_unnamed);
1155
+
1156
+ console.log(`${pass} passed, ${fail} failed`);
1157
+ process.exit(fail ? 1 : 0);
1158
+ }
1159
+ if (flag('--test-decision')) testDecision();
1160
+
1161
+ async function main() {
1162
+ const state = { reauth: new Set(), holdReason: null, codexHold: null, justSwitched: null, lastHealTry: 0, allHotNotified: false, codexAllHot: false, lastReport: null, footerRow: 0, stats: null, statsProgress: null };
1163
+ if (!PLAIN) {
1164
+ process.stdout.write('\x1b[2J\x1b[H\x1b[?25l');
1165
+ const cleanup = () => { process.stdout.write('\x1b[?25h\x1b[0m\n'); process.exit(0); };
1166
+ process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup);
1167
+ }
1168
+ state.stats = usageStats.cachedStats(); // instant render from last scan
1169
+
1170
+ const runStats = async () => {
1171
+ try {
1172
+ const s = await usageStats.collect((done, total) => {
1173
+ state.statsProgress = `scanning local logs… ${done}/${total} files`;
1174
+ });
1175
+ if (s) state.stats = s;
1176
+ state.statsProgress = null;
1177
+ if (!ONCE) render(state.lastReport, state);
1178
+ } catch { state.statsProgress = null; }
1179
+ };
1180
+
1181
+ if (ONCE) { await runStats(); await tick(state); if (!PLAIN) process.stdout.write('\x1b[?25h'); return; }
1182
+
1183
+ runStats(); // background; rerenders when done
1184
+ setInterval(runStats, 5 * 60_000); // refresh stats every 5 min
1185
+ await tick(state);
1186
+
1187
+ let nextAt = now() + INTERVAL * 1000;
1188
+ setInterval(() => { // per-second countdown on the footer line
1189
+ if (PLAIN || !state.footerRow) return;
1190
+ const s = Math.max(0, Math.ceil((nextAt - now()) / 1000));
1191
+ process.stdout.write(`\x1b[${state.footerRow};1H ${C.grey2}next check in ${s}s · codex switches apply to new sessions · ctrl-c to quit${C.reset}\x1b[K`);
1192
+ }, 1000);
1193
+ // serial tick loop (never overlapping)
1194
+ (async function loop() {
1195
+ for (;;) {
1196
+ await new Promise((r) => setTimeout(r, Math.max(0, nextAt - now())));
1197
+ nextAt = now() + INTERVAL * 1000;
1198
+ try { await tick(state); } catch {}
1199
+ }
1200
+ })();
1201
+ }
1202
+
1203
+ // Subcommands exit on their own (codex-use asynchronously); only the
1204
+ // dashboard path runs the main loop.
1205
+ if (!['codex-save', 'codex-use', 'codex-list', 'codex-shim', 'codex-ensure', 'codex-supervise', 'codex-add'].includes(argv[0])) main();