claude-multiacc 1.0.13 → 1.0.15

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/lib/report.py ADDED
@@ -0,0 +1,434 @@
1
+ """Machine-readable pool report — the ONE place that builds the JSON `list --json`,
2
+ `status --json` and `limits --json` emit, for BOTH providers, so a consumer (the
3
+ app-robot panel/runner daemon) parses exactly one shape no matter which verb or
4
+ provider produced it.
5
+
6
+ Run: python3 lib/report.py <acc-root> <claude|codex> <mac|linux> [list|status|limits]
7
+
8
+ The document is a single JSON object on stdout:
9
+
10
+ schema "claude-multiacc/pool.v1" — bumped only on a BREAKING change
11
+ provider claude | codex
12
+ kind which verb rendered it (list|status|limits)
13
+ pool root, manifest path, threshold, sync target/mode/role, peers
14
+ accounts[] id, email, home, home_dir, status, state, credential_class, usage, ...
15
+ summary counts a dashboard can render without walking the list
16
+ warnings[] duplicate emails, unreadable manifest bits — never fatal
17
+
18
+ Every field is best-effort: one corrupt account file degrades that account only,
19
+ never the document (a --json call that dies is worse than one that says 'unknown').
20
+
21
+ Account status vocabulary (the panel's contract):
22
+ active selectable right now
23
+ limited authenticates, but a >=threshold bucket is parked until limit_reset_at
24
+ expired has auth material that cannot authenticate — needs an interactive login
25
+ blocked authenticates, but the org/workspace disabled the CLI for it
26
+ missing no auth material on this machine (and this machine should own it)
27
+ remote no auth here on purpose — the manifest says another machine owns it
28
+
29
+ Credential classes (what may be COPIED between machines):
30
+ portable claude setup-token (server.token) — safe to export/import anywhere
31
+ machine-local claude .credentials.json / codex auth.json — rotating refresh grant;
32
+ copying it makes two machines fight over one grant and breaks both
33
+ none nothing on disk here
34
+ """
35
+
36
+ import json
37
+ import os
38
+ import re
39
+ import socket
40
+ import sys
41
+ import time
42
+
43
+ SCHEMA = 'claude-multiacc/pool.v1'
44
+
45
+ # Must match the SHIM's ranking window, or this document calls data "fresh" that
46
+ # selection has already stopped ranking on. The two providers differ on purpose:
47
+ # api.anthropic.com hands out Retry-After: 3600, so bin/claude trusts an hour;
48
+ # bin/codex still uses 900 against chatgpt.com, which has not been measured.
49
+ _STALE_DEFAULT = {'claude': 3600, 'codex': 900}
50
+
51
+
52
+ def _stale_after(provider):
53
+ env = {'claude': 'CLAUDE_MULTIACC_STALE_AFTER',
54
+ 'codex': 'CODEX_MULTIACC_STALE_AFTER'}.get(provider)
55
+ default = _STALE_DEFAULT.get(provider, 900)
56
+ try:
57
+ v = int(os.environ.get(env) or default) if env else default
58
+ except ValueError:
59
+ v = default
60
+ return v if v > 0 else default
61
+
62
+
63
+ VALID_ID = re.compile(r'acct-\d{2}')
64
+
65
+
66
+ def _iso(epoch):
67
+ try:
68
+ epoch = float(epoch)
69
+ except (TypeError, ValueError):
70
+ return None
71
+ if epoch <= 0:
72
+ return None
73
+ try:
74
+ return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(epoch))
75
+ except (ValueError, OSError, OverflowError): # absurd epoch in a corrupt file
76
+ return None
77
+
78
+
79
+ def _iso_ms(value):
80
+ """A milliseconds field (credentials carry them) -> ISO, tolerating whatever a
81
+ corrupt credential holds: a string, a list, null. One bad file must degrade its
82
+ own account, never the document."""
83
+ try:
84
+ return _iso(float(value or 0) / 1000.0)
85
+ except (TypeError, ValueError):
86
+ return None
87
+
88
+
89
+ def _size(path):
90
+ try:
91
+ return os.path.getsize(path)
92
+ except OSError:
93
+ return 0
94
+
95
+
96
+ def _mtime(path):
97
+ try:
98
+ return os.path.getmtime(path)
99
+ except OSError:
100
+ return 0.0
101
+
102
+
103
+ def _load_json(path):
104
+ try:
105
+ with open(path) as f:
106
+ doc = json.load(f)
107
+ return doc if isinstance(doc, dict) else None
108
+ except Exception:
109
+ return None
110
+
111
+
112
+ def _marker(path):
113
+ """(reset_epoch, detail) of a .limited/.expired marker; (0, '') when absent."""
114
+ try:
115
+ lines = open(path, errors='replace').read().splitlines()
116
+ except OSError:
117
+ return 0, ''
118
+ reset = int(lines[0]) if lines and lines[0].strip().isdigit() else 0
119
+ detail = lines[1].strip() if len(lines) > 1 else ''
120
+ return reset, detail
121
+
122
+
123
+ def _sync_block(root, manifest):
124
+ """Where this pool syncs to. The env values are what lib/common.sh RESOLVED
125
+ (env override > manifest), passed through so the report can never disagree with
126
+ what `sync` would actually do; without them the manifest is the authority."""
127
+ target = os.environ.get('MULTIACC_REPORT_SYNC_TARGET')
128
+ if target is None:
129
+ target = str(manifest.get('server') or '')
130
+ sroot = os.environ.get('MULTIACC_REPORT_SYNC_ROOT') or str(manifest.get('server_root') or '')
131
+ srepo = os.environ.get('MULTIACC_REPORT_SYNC_REPO') or str(manifest.get('server_repo') or '')
132
+ mode = os.environ.get('MULTIACC_REPORT_SYNC_MODE')
133
+ if not mode:
134
+ mode = 'local' if target.strip().lower() in ('', 'none', 'local', 'off', 'disabled') else 'server'
135
+ role = 'source'
136
+ try:
137
+ with open(os.path.join(root, 'sync-role'), errors='replace') as f:
138
+ if any(line.strip().lower() == 'replica' for line in f):
139
+ role = 'replica'
140
+ except OSError:
141
+ pass
142
+ peers = []
143
+ raw = manifest.get('peers')
144
+ if isinstance(raw, list):
145
+ for p in raw:
146
+ if isinstance(p, dict):
147
+ peers.append({'target': str(p.get('target') or ''),
148
+ 'root': str(p.get('root') or ''),
149
+ 'repo': str(p.get('repo') or '')})
150
+ return {'mode': mode, 'target': None if mode == 'local' else target,
151
+ 'root': sroot or None, 'repo': srepo or None, 'role': role, 'peers': peers}
152
+
153
+
154
+ def _claude_credentials(d):
155
+ """(credential_class, credentials-detail) for a claude account dir."""
156
+ cpath = os.path.join(d, '.credentials.json')
157
+ tpath = os.path.join(d, 'server.token')
158
+ has_oauth = _size(cpath) > 0
159
+ has_token = _size(tpath) > 0
160
+ detail = {'oauth': has_oauth, 'token': has_token,
161
+ 'oauth_expires_at': None, 'oauth_refresh_expires_at': None,
162
+ 'token_minted_at': None, 'token_age_days': None}
163
+ if has_oauth:
164
+ doc = _load_json(cpath) or {}
165
+ o = doc.get('claudeAiOauth')
166
+ if isinstance(o, dict):
167
+ detail['oauth_expires_at'] = _iso_ms(o.get('expiresAt'))
168
+ detail['oauth_refresh_expires_at'] = _iso_ms(o.get('refreshTokenExpiresAt'))
169
+ if has_token:
170
+ mt = _mtime(tpath)
171
+ detail['token_minted_at'] = _iso(mt)
172
+ if mt:
173
+ detail['token_age_days'] = int((time.time() - mt) / 86400)
174
+ # A setup-token is the only claude credential that survives being copied.
175
+ if has_token:
176
+ return 'portable', detail
177
+ if has_oauth:
178
+ return 'machine-local', detail
179
+ return 'none', detail
180
+
181
+
182
+ def _codex_credentials(d, jwt_claims):
183
+ """(credential_class, credentials-detail) for a codex account dir. Codex has NO
184
+ portable class: auth.json carries a rotating refresh token."""
185
+ cpath = os.path.join(d, 'auth.json')
186
+ has_auth = _size(cpath) > 0
187
+ detail = {'oauth': has_auth, 'token': False, 'auth_expires_at': None,
188
+ 'refresh_token': False, 'plan': None, 'auth_mode': None}
189
+ if not has_auth:
190
+ return 'none', detail
191
+ doc = _load_json(cpath) or {}
192
+ detail['auth_mode'] = str(doc.get('auth_mode') or '') or None
193
+ tokens = doc.get('tokens')
194
+ if isinstance(tokens, dict):
195
+ detail['refresh_token'] = bool(tokens.get('refresh_token'))
196
+ detail['auth_expires_at'] = _iso(jwt_claims(tokens.get('access_token')).get('exp'))
197
+ plan = (jwt_claims(tokens.get('id_token')).get('https://api.openai.com/auth') or {})
198
+ if isinstance(plan, dict):
199
+ detail['plan'] = plan.get('chatgpt_plan_type') or None
200
+ return 'machine-local', detail
201
+
202
+
203
+ def _usage(d, stale_after):
204
+ lim = _load_json(os.path.join(d, 'limits.json'))
205
+ if not lim:
206
+ return None
207
+ buckets = []
208
+ for b in (lim.get('buckets') or []):
209
+ if not isinstance(b, dict):
210
+ continue
211
+ buckets.append({'name': str(b.get('name') or ''), 'kind': str(b.get('kind') or ''),
212
+ 'group': str(b.get('group') or ''), 'percent': b.get('percent'),
213
+ 'resets_at': b.get('resets_at')})
214
+ fetched = lim.get('fetched_at') or 0
215
+ out = {'fetched_at': _iso(fetched), 'source': lim.get('source'),
216
+ 'max_percent': lim.get('max_percent'), 'weekly_percent': lim.get('weekly_percent'),
217
+ 'session_percent': lim.get('session_percent'), 'buckets': buckets}
218
+ try:
219
+ out['age_seconds'] = max(0, int(time.time() - float(fetched)))
220
+ except (TypeError, ValueError):
221
+ out['age_seconds'] = None
222
+ # Past this window the shim stops ranking on these numbers entirely (bin/claude
223
+ # STALE_AFTER). A panel that shows "weekly 2%" without it is reporting a number
224
+ # nothing is using — which is how an 11-day telemetry outage stayed invisible.
225
+ out['stale'] = out['age_seconds'] is None or out['age_seconds'] > stale_after
226
+ # The horizon a stale weekly reading stays true until (bin/claude stale_weekly).
227
+ # Absent on documents written before it existed, which is itself the signal that
228
+ # the reading cannot be used for degraded ranking.
229
+ if lim.get('weekly_resets_epoch'):
230
+ out['weekly_resets_epoch'] = lim['weekly_resets_epoch']
231
+ if lim.get('last_error'):
232
+ out['last_error'] = lim['last_error']
233
+ if lim.get('last_error_at'):
234
+ out['last_error_at'] = _iso(lim['last_error_at'])
235
+ if lim.get('plan'):
236
+ out['plan'] = lim['plan']
237
+ if lim.get('retry_after'):
238
+ out['retry_after'] = _iso(lim['retry_after'])
239
+ return out
240
+
241
+
242
+ def _last_pick(root, aid):
243
+ path = os.path.join(root, 'selection.log')
244
+ last = None
245
+ try:
246
+ with open(path, errors='replace') as f:
247
+ for line in f:
248
+ parts = line.split()
249
+ if len(parts) >= 2 and parts[1] == aid:
250
+ last = parts[0]
251
+ except OSError:
252
+ pass
253
+ return last
254
+
255
+
256
+
257
+ def telemetry_state(accounts, now=None):
258
+ """Three-state verdict on how the shim is CURRENTLY ranking this pool, over the
259
+ same candidate set bin/claude uses: the eligible accounts, or — when every one of
260
+ them is limit-marked — the valid ones it falls back to.
261
+
262
+ 'fresh' at least one candidate has telemetry inside the ranking window
263
+ 'degraded' none do, but EVERY candidate still has a weekly reading whose bucket
264
+ has not reset yet, so the shim ranks on those (all-or-nothing: one
265
+ unusable reading and the comparison is not apples-to-apples)
266
+ 'blind' nothing usable anywhere — every account scores the same and selection
267
+ is effectively a coin flip
268
+ 'none' no candidates at all; nothing to say
269
+
270
+ Kept here rather than beside either caller because `status` and `--json` disagreeing
271
+ with the shim is exactly how an eleven-day outage stayed invisible.
272
+ """
273
+ now = time.time() if now is None else now
274
+ usable = [a for a in accounts if a.get('status') in ('active', 'limited')]
275
+ cands = [a for a in usable if a.get('status') == 'active'] or usable
276
+ if not cands:
277
+ return 'none'
278
+ if any(a.get('usage') and not a['usage'].get('stale') for a in cands):
279
+ return 'fresh'
280
+ for a in cands:
281
+ u = a.get('usage') or {}
282
+ horizon = u.get('weekly_resets_epoch') or 0
283
+ pct = u.get('weekly_percent')
284
+ # bool is an int in Python but not a number to the shim's digit parser, so a
285
+ # document carrying `weekly_percent: true` must read as unusable HERE too —
286
+ # otherwise this says 'degraded' while the shim ranks blind.
287
+ if isinstance(horizon, bool) or isinstance(pct, bool) \
288
+ or not isinstance(horizon, (int, float)) or horizon <= now \
289
+ or not isinstance(pct, (int, float)):
290
+ return 'blind'
291
+ return 'degraded'
292
+
293
+
294
+ def _account_row(root, a, aid, d, provider, jwt_claims, audit_account, now, kind, machine):
295
+ """One account's row. Everything it reads is already tolerant; build() catches
296
+ anything unexpected on top of that and degrades this account alone."""
297
+ st = audit_account(root, a, now=now, machine=machine)
298
+ if provider == 'codex':
299
+ cclass, cdetail = _codex_credentials(d, jwt_claims)
300
+ else:
301
+ cclass, cdetail = _claude_credentials(d)
302
+ reset_epoch, marker_detail = _marker(os.path.join(d, '.limited'))
303
+ limited = reset_epoch > now or (reset_epoch == 0 and os.path.isfile(os.path.join(d, '.limited')))
304
+ status = st['state']
305
+ if status == 'ok':
306
+ status = 'limited' if limited else 'active'
307
+ row = {
308
+ 'id': aid,
309
+ 'email': a.get('email', ''),
310
+ 'home': a.get('home', ''),
311
+ 'added_at': a.get('added_at'),
312
+ 'home_dir': d,
313
+ 'exists': os.path.isdir(d),
314
+ 'adopted': os.path.islink(d),
315
+ 'status': status,
316
+ 'state': st['state'],
317
+ 'label': st['label'],
318
+ 'reason': st['reason'],
319
+ 'fix': st['fix'],
320
+ 'selectable': st['state'] == 'ok' and not limited,
321
+ 'needs_login': st['state'] in ('expired', 'blocked', 'missing'),
322
+ 'credential_class': cclass,
323
+ 'portable': cclass == 'portable',
324
+ 'credentials': cdetail,
325
+ 'limited': bool(limited),
326
+ 'limit_reset_at': _iso(reset_epoch) if limited and reset_epoch else None,
327
+ 'limit_reset_epoch': reset_epoch if limited and reset_epoch else None,
328
+ 'limit_detail': marker_detail if limited else None,
329
+ 'usage': _usage(d, _stale_after(provider)),
330
+ }
331
+ if kind == 'status':
332
+ row['last_picked'] = _last_pick(root, aid)
333
+ expired_reset, expired_detail = _marker(os.path.join(d, '.expired'))
334
+ row['expired_marker'] = expired_detail or None
335
+ return row
336
+
337
+
338
+ def build(root, provider, machine, kind='list'):
339
+ if provider == 'codex':
340
+ from codex_audit import audit_account, jwt_claims
341
+ else:
342
+ from audit import audit_account
343
+ jwt_claims = None
344
+ now = time.time()
345
+ warnings = []
346
+ manifest_path = os.path.join(root, 'accounts.json')
347
+ manifest = _load_json(manifest_path)
348
+ if manifest is None:
349
+ manifest = {}
350
+ warnings.append(f'manifest unreadable or missing: {manifest_path}')
351
+
352
+ raw_accounts = manifest.get('accounts')
353
+ if not isinstance(raw_accounts, list):
354
+ raw_accounts = []
355
+ if manifest:
356
+ warnings.append("manifest has no well-formed 'accounts' array")
357
+
358
+ accounts = []
359
+ seen_emails = {}
360
+ for a in raw_accounts:
361
+ if not isinstance(a, dict) or not VALID_ID.fullmatch(str(a.get('id', ''))):
362
+ warnings.append('skipped a manifest entry with a malformed id')
363
+ continue
364
+ aid = a['id']
365
+ d = os.path.join(root, aid)
366
+ try:
367
+ row = _account_row(root, a, aid, d, provider, jwt_claims, audit_account,
368
+ now, kind, machine)
369
+ except Exception as e: # one unreadable account must not empty the report
370
+ warnings.append(f'{aid}: could not be read ({str(e)[:120]})')
371
+ row = {'id': aid, 'email': a.get('email', ''), 'home': a.get('home', ''),
372
+ 'home_dir': d, 'status': 'unknown', 'state': 'unknown',
373
+ 'label': 'UNKNOWN', 'reason': f'unreadable: {str(e)[:120]}',
374
+ 'fix': '', 'selectable': False, 'needs_login': False,
375
+ 'credential_class': 'unknown', 'portable': False,
376
+ 'credentials': {}, 'limited': False, 'limit_reset_at': None,
377
+ 'usage': None}
378
+ accounts.append(row)
379
+ seen_emails.setdefault(str(a.get('email', '')).lower(), []).append(aid)
380
+
381
+ for email, ids in seen_emails.items():
382
+ if len(ids) > 1:
383
+ warnings.append(f"{email} is registered {len(ids)}x ({', '.join(ids)}) "
384
+ f"— run '{provider}-accounts dedupe'")
385
+
386
+ threshold = manifest.get('threshold', 90)
387
+ try:
388
+ threshold = int(threshold)
389
+ except (TypeError, ValueError):
390
+ threshold = 90
391
+
392
+ return {
393
+ 'schema': SCHEMA,
394
+ 'provider': provider,
395
+ 'kind': kind,
396
+ 'generated_at': _iso(now),
397
+ 'machine': machine,
398
+ 'host': socket.gethostname(),
399
+ 'pool': {
400
+ 'root': root,
401
+ 'manifest': manifest_path,
402
+ 'threshold': threshold,
403
+ 'sync': _sync_block(root, manifest),
404
+ },
405
+ 'accounts': accounts,
406
+ 'summary': {
407
+ 'total': len(accounts),
408
+ 'active': sum(1 for a in accounts if a['status'] == 'active'),
409
+ 'limited': sum(1 for a in accounts if a['status'] == 'limited'),
410
+ 'needs_login': sum(1 for a in accounts if a['needs_login']),
411
+ 'portable': sum(1 for a in accounts if a['portable']),
412
+ 'selectable': sum(1 for a in accounts if a['selectable']),
413
+ # The pool-wide verdict, so a dashboard can show the outage rather than a
414
+ # grid of reassuring percentages nothing is ranking on.
415
+ # fresh ranking on current data
416
+ # degraded ranking on old-but-still-true weekly readings
417
+ # blind every account scores the same; selection is a coin flip
418
+ 'telemetry': telemetry_state(accounts, now),
419
+ # Kept as its own boolean because a dashboard alerting on one field should
420
+ # not have to know the vocabulary. True ONLY for the coin-flip case.
421
+ 'ranking_blind': telemetry_state(accounts, now) == 'blind',
422
+ },
423
+ 'warnings': warnings,
424
+ }
425
+
426
+
427
+ if __name__ == '__main__':
428
+ root = sys.argv[1]
429
+ provider = sys.argv[2] if len(sys.argv) > 2 else 'claude'
430
+ machine = sys.argv[3] if len(sys.argv) > 3 else 'mac'
431
+ kind = sys.argv[4] if len(sys.argv) > 4 else 'list'
432
+ sys.path = [os.path.dirname(os.path.abspath(__file__))] + \
433
+ [p for p in sys.path if p not in ('', '.')]
434
+ print(json.dumps(build(root, provider, machine, kind), indent=2, sort_keys=False))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Multi-account addon for Claude Code and OpenAI Codex CLI: every claude / claude -p and every codex / codex exec runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
5
5
  "type": "module",
6
6
  "bin": {