claude-multiacc 2.0.58 → 2.0.60

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.
Files changed (151) hide show
  1. package/README.md +125 -4
  2. package/bin/claude +389 -94
  3. package/bin/claude-accounts +276 -42
  4. package/bin/codex +69 -5
  5. package/bin/codex-accounts +33 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +28 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +81 -9
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +146 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/mcp_registry.py +230 -8
  59. package/lib/model_catalog.json +404 -0
  60. package/lib/model_catalog.py +201 -0
  61. package/lib/model_match.py +104 -0
  62. package/lib/report.py +8 -5
  63. package/lib/session_settings.py +123 -0
  64. package/package.json +2 -2
  65. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  122. package/tests/fake_codex_tui.py +25 -0
  123. package/tests/fake_security.sh +24 -2
  124. package/tests/fixtures/cross_provider_cli.py +109 -0
  125. package/tests/run-tests.sh +1171 -23
  126. package/tests/test_autoresume.py +215 -8
  127. package/tests/test_autoresume_catalog.py +226 -0
  128. package/tests/test_autoresume_catalog_cli.py +185 -0
  129. package/tests/test_autoresume_codex_records.py +16 -0
  130. package/tests/test_autoresume_codex_trust.py +247 -0
  131. package/tests/test_autoresume_cross_guards.py +28 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +14 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. package/tests/test_mcp_registry.py +234 -3
  151. package/tests/test_packaged_commands.py +41 -0
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """The model/effort catalog of both providers, from the freshest credential-free source (3.8+).
3
+
4
+ Sources, freshest wins per provider: the catalog shipped beside this file (``model_catalog.json``,
5
+ a snapshot of the lineup, the fallback), a pool's refreshed copy (``<root>/model-catalog.json``, written by
6
+ ``refresh`` on every install and self-update and kept current by the limits passes), and the
7
+ caches the CLIs themselves maintain while they run -- Codex's ``<acct>/models_cache.json``
8
+ (rewritten every few minutes by any app-server) and Claude Code's served catalog
9
+ ``<acct>/cache/model-catalog/*-cc.json`` (hourly). No request is ever sent: the CLIs already
10
+ fetch these with their own credentials and budgets.
11
+
12
+ Shape: ``{"schema": 1, "<provider>": {"fetched_at": iso, "source": str, "efforts": [ascending],
13
+ "families": {family: tier}, "models": {id: {"family", "version", "efforts", "default_effort",
14
+ "listed", "upgrade"}}}}``. ``efforts``/``families`` are structure and come from the shipped file;
15
+ ``models`` from the freshest source. ``lib/model_match.py`` does the matching.
16
+ """
17
+
18
+ import glob
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ import tempfile
24
+ import time
25
+
26
+ SHIPPED = os.path.join(os.path.dirname(os.path.abspath(__file__)), "model_catalog.json")
27
+ OVERLAY = "model-catalog.json"
28
+ PROVIDERS = ("claude", "codex")
29
+ CLAUDE_ID = re.compile(r"^claude-(?P<family>[a-z]+)(?:-(?P<version>\d+(?:-\d+)*?))?(?:-\d{8})?$")
30
+ CODEX_ID = re.compile(r"^gpt-(?P<version>\d+(?:\.\d+)*)(?:-(?P<family>[a-z]+))?$")
31
+
32
+
33
+ def parse_id(provider, model):
34
+ """(family, version tuple) of a model id; family None when the id is not understood."""
35
+ model = re.sub(r"\[\d+[km]\]$", "", model or "", flags=re.I).strip()
36
+ match = (CLAUDE_ID if provider == "claude" else CODEX_ID).match(model)
37
+ if not match:
38
+ return None, ()
39
+ version = match.group("version") or ""
40
+ return match.group("family"), tuple(int(part) for part in re.split(r"[.-]", version) if part)
41
+
42
+
43
+ def _read_json(path):
44
+ try:
45
+ with open(path, "rb") as fh:
46
+ return json.loads(fh.read().decode("utf-8"))
47
+ except (OSError, ValueError):
48
+ return None
49
+
50
+
51
+ def _iso(epoch):
52
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(epoch))
53
+
54
+
55
+ def _entry(provider, model, efforts, default, listed, upgrade=None):
56
+ family, version = parse_id(provider, model)
57
+ return {"family": family, "version": list(version), "efforts": [e for e in efforts if isinstance(e, str)],
58
+ "default_effort": default if isinstance(default, str) else None, "listed": bool(listed),
59
+ "upgrade": upgrade if isinstance(upgrade, str) else None}
60
+
61
+
62
+ def codex_cache(path):
63
+ """A ``models_cache.json`` as a provider section, or None (also for a half-written file)."""
64
+ doc = _read_json(path)
65
+ models = doc.get("models") if isinstance(doc, dict) else None
66
+ if not isinstance(models, list) or not isinstance(doc.get("fetched_at"), str):
67
+ return None
68
+ table = {}
69
+ for item in models:
70
+ if not isinstance(item, dict) or not isinstance(item.get("slug"), str):
71
+ continue
72
+ levels = item.get("supported_reasoning_levels") or []
73
+ efforts = [lvl.get("effort") for lvl in levels if isinstance(lvl, dict)]
74
+ upgrade = item.get("upgrade") if isinstance(item.get("upgrade"), dict) else {}
75
+ table[item["slug"]] = _entry("codex", item["slug"], efforts, item.get("default_reasoning_level"),
76
+ item.get("visibility") == "list", upgrade.get("model"))
77
+ return {"fetched_at": doc["fetched_at"][:19] + "Z", "source": path, "models": table} if table else None
78
+
79
+
80
+ def claude_cache(path):
81
+ """A served-catalog cache (``*-cc.json``) as a provider section, or None."""
82
+ doc = _read_json(path)
83
+ catalog = doc.get("catalog") if isinstance(doc, dict) else None
84
+ config = catalog.get("config") if isinstance(catalog, dict) else None
85
+ rows = config.get("models") if isinstance(config, dict) else None
86
+ if not isinstance(rows, list) or not isinstance(doc.get("fetchedAt"), (int, float)):
87
+ return None
88
+ state = catalog.get("state") if isinstance(catalog.get("state"), dict) else {}
89
+ defaults = {}
90
+ for item in state.get("thinking_by_model") or []:
91
+ thinking = item.get("thinking") if isinstance(item, dict) else None
92
+ if isinstance(thinking, dict) and isinstance(item.get("id"), str):
93
+ defaults[item["id"]] = thinking.get("effort")
94
+ table = {}
95
+ for row in rows:
96
+ if not isinstance(row, dict) or not isinstance(row.get("id"), str):
97
+ continue
98
+ thinking = row.get("thinking") if isinstance(row.get("thinking"), dict) else {}
99
+ options = [opt.get("id") for opt in thinking.get("effort_options") or [] if isinstance(opt, dict)]
100
+ table[row["id"]] = _entry("claude", row["id"], options, defaults.get(row["id"]),
101
+ row.get("section") == "main")
102
+ return {"fetched_at": _iso(doc["fetchedAt"] / 1000.0), "source": path, "models": table} if table else None
103
+
104
+
105
+ def sources(roots):
106
+ """Every candidate section per provider: shipped, overlays, then the CLIs' own caches."""
107
+ found = {provider: [] for provider in PROVIDERS}
108
+ shipped = _read_json(SHIPPED) or {}
109
+ for provider in PROVIDERS:
110
+ if isinstance(shipped.get(provider), dict) and isinstance(shipped[provider].get("models"), dict):
111
+ found[provider].append(dict(shipped[provider], source=SHIPPED))
112
+ for root in [r for r in (roots or {}).values() if r]:
113
+ overlay = _read_json(os.path.join(root, OVERLAY)) or {}
114
+ for provider in PROVIDERS:
115
+ if isinstance(overlay.get(provider), dict) and isinstance(overlay[provider].get("models"), dict):
116
+ found[provider].append(dict(overlay[provider], source=os.path.join(root, OVERLAY)))
117
+ codex_root, claude_root = (roots or {}).get("codex"), (roots or {}).get("claude")
118
+ for path in glob.glob(os.path.join(codex_root, "acct-*", "models_cache.json")) if codex_root else []:
119
+ section = codex_cache(path)
120
+ if section:
121
+ found["codex"].append(section)
122
+ for path in glob.glob(os.path.join(claude_root, "acct-*", "cache", "model-catalog", "*-cc.json")) \
123
+ if claude_root else []:
124
+ section = claude_cache(path)
125
+ if section:
126
+ found["claude"].append(section)
127
+ return found
128
+
129
+
130
+ def load(roots=None):
131
+ """The merged catalog: structure from the shipped file, models from the freshest source."""
132
+ shipped = _read_json(SHIPPED) or {}
133
+ found = sources(roots)
134
+ catalog = {"schema": 1}
135
+ for provider in PROVIDERS:
136
+ base = shipped.get(provider) if isinstance(shipped.get(provider), dict) else {}
137
+ best = max(found[provider], key=lambda s: str(s.get("fetched_at") or ""), default=None)
138
+ catalog[provider] = {"efforts": list(base.get("efforts") or []), "families": dict(base.get("families") or {}),
139
+ "fetched_at": best.get("fetched_at") if best else None,
140
+ "source": best.get("source") if best else None,
141
+ "models": dict(best.get("models") or {}) if best else {}}
142
+ return catalog
143
+
144
+
145
+ def refresh(roots, if_stale=False, max_age=3600):
146
+ """Write the merged catalog to every pool root given; ``if_stale`` skips roots whose copy
147
+ is younger than ``max_age`` seconds. Returns the paths written."""
148
+ written = []
149
+ for root in sorted(set(r for r in roots.values() if r)):
150
+ path = os.path.join(root, OVERLAY)
151
+ try:
152
+ if if_stale and time.time() - os.stat(path).st_mtime < max_age:
153
+ continue
154
+ except OSError:
155
+ pass
156
+ catalog = load(roots)
157
+ catalog["generated_at"] = _iso(time.time())
158
+ scratch = None
159
+ try:
160
+ handle, scratch = tempfile.mkstemp(prefix=".model-catalog.", dir=root)
161
+ with os.fdopen(handle, "w", encoding="utf-8") as fh:
162
+ os.fchmod(fh.fileno(), 0o644)
163
+ fh.write(json.dumps(catalog, indent=1, sort_keys=True) + "\n")
164
+ os.replace(scratch, path)
165
+ written.append(path)
166
+ except OSError:
167
+ if scratch:
168
+ try:
169
+ os.unlink(scratch)
170
+ except OSError:
171
+ pass
172
+ return written
173
+
174
+
175
+ def main(argv):
176
+ roots, verb, if_stale, i = {}, None, False, 0
177
+ while i < len(argv):
178
+ if argv[i] in ("--claude-root", "--codex-root") and i + 1 < len(argv):
179
+ roots[argv[i][2:-5]] = argv[i + 1]
180
+ i += 2
181
+ elif argv[i] == "--if-stale":
182
+ if_stale, i = True, i + 1
183
+ elif argv[i] in ("refresh", "show") and verb is None:
184
+ verb, i = argv[i], i + 1
185
+ else:
186
+ verb = None
187
+ break
188
+ if verb is None:
189
+ sys.stderr.write("usage: model_catalog.py <refresh|show> [--claude-root R] [--codex-root R] [--if-stale]\n")
190
+ return 2
191
+ if verb == "show":
192
+ print(json.dumps(load(roots), indent=1, sort_keys=True))
193
+ return 0
194
+ written = refresh(roots, if_stale)
195
+ for path in written:
196
+ print(path)
197
+ return 0 if written or if_stale or not roots else 1
198
+
199
+
200
+ if __name__ == "__main__":
201
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """Which model and effort a resumed session should run with (Python 3.8+).
3
+
4
+ Same provider: the session's own model and effort, as its history recorded them. Another
5
+ provider: the model of the same LEVEL -- every family has a level in the catalog (Claude: haiku 2,
6
+ sonnet 3, opus 4, fable 5; Codex: luna 1, terra 2, sol 3, astra 4, so astra <-> opus, sol <->
7
+ sonnet, terra -> haiku <- luna, haiku -> terra, fable -> astra), the source model's level picks
8
+ the destination family, and the newest listed model of that family is the match -- and the
9
+ effort of the same level by name (low, medium, high, xhigh, max), with Codex's ``ultra`` landing
10
+ on ``max`` and its ``minimal``/``none`` on ``low``, clamped to what the destination model
11
+ supports (the nearest level below, else the lowest). An unknown model or effort is simply
12
+ omitted, and the destination CLI applies its own default.
13
+
14
+ ``settings_flags`` renders the result as argv: Claude ``--model M --effort E``; Codex ``-m M -c
15
+ model_reasoning_effort=E`` -- always both, because any single model flag makes Codex drop the
16
+ thread's other saved value (the effort falls back to the model's catalog default).
17
+ """
18
+
19
+ import sys
20
+
21
+ sys.path.insert(0, __import__("os").path.dirname(__import__("os").path.abspath(__file__)))
22
+ from model_catalog import load, parse_id # noqa: E402
23
+
24
+ RENAME = {"codex": {"claude": {"ultra": "max", "minimal": "low", "none": "low"}},
25
+ "claude": {"codex": {}}}
26
+
27
+
28
+ def tier(catalog, provider, model, hops=4):
29
+ """The model's family tier; a retiring model counts as the one the catalog upgrades it to."""
30
+ section = catalog.get(provider) or {}
31
+ entry = (section.get("models") or {}).get(model) or {}
32
+ family = entry.get("family") or parse_id(provider, model)[0]
33
+ level = (section.get("families") or {}).get(family)
34
+ if level is None and entry.get("upgrade") and hops:
35
+ return tier(catalog, provider, entry["upgrade"], hops - 1)
36
+ return level
37
+
38
+
39
+ def current_models(catalog, provider):
40
+ """family -> the newest listed model of that family. Every listed family is a level of its
41
+ own, whatever its version: launches are staggered, and a tier must never borrow another
42
+ family's model while a listed one of its own exists."""
43
+ best = {}
44
+ for model, entry in ((catalog.get(provider) or {}).get("models") or {}).items():
45
+ family = entry.get("family") or parse_id(provider, model)[0]
46
+ if not family or not entry.get("listed"):
47
+ continue
48
+ version = tuple(entry.get("version") or parse_id(provider, model)[1])
49
+ if family not in best or version > best[family][0]:
50
+ best[family] = (version, model)
51
+ return {family: model for family, (_, model) in best.items()}
52
+
53
+
54
+ def match_model(catalog, source, model, target):
55
+ """The target provider's current model at the source model's tier (nearest above, then
56
+ below, when that exact tier has no current model); None when the source is unknown."""
57
+ if not model:
58
+ return None
59
+ if source == target:
60
+ return model
61
+ wanted = tier(catalog, source, model)
62
+ if wanted is None:
63
+ return None
64
+ current = current_models(catalog, target)
65
+ families = (catalog.get(target) or {}).get("families") or {}
66
+ ranked = sorted(((level, family) for family, level in families.items() if family in current),
67
+ key=lambda item: (abs(item[0] - wanted), -item[0]))
68
+ return current[ranked[0][1]] if ranked else None
69
+
70
+
71
+ def match_effort(catalog, source, effort, target, model):
72
+ """The same effort by name, renamed between vocabularies, clamped to the target model."""
73
+ if not effort:
74
+ return None
75
+ name = RENAME.get(source, {}).get(target, {}).get(effort, effort) if source != target else effort
76
+ section = catalog.get(target) or {}
77
+ scale = list(section.get("efforts") or [])
78
+ supported = ((section.get("models") or {}).get(model) or {}).get("efforts") or []
79
+ if name not in scale:
80
+ return name if not supported or name in supported else None
81
+ if not supported or name in supported:
82
+ return name
83
+ below = [level for level in supported if level in scale and scale.index(level) <= scale.index(name)]
84
+ return max(below, key=scale.index) if below else min(supported, key=lambda level: scale.index(level)
85
+ if level in scale else len(scale))
86
+
87
+
88
+ def resume_settings(source, settings, target, catalog=None):
89
+ """{"model", "effort"} for the target provider from a source session's settings."""
90
+ catalog = catalog or load()
91
+ model = match_model(catalog, source, (settings or {}).get("model"), target)
92
+ effort = match_effort(catalog, source, (settings or {}).get("effort"), target, model)
93
+ if model and not effort and target == "codex":
94
+ effort = (((catalog.get("codex") or {}).get("models") or {}).get(model) or {}).get("default_effort")
95
+ return {"model": model, "effort": effort}
96
+
97
+
98
+ def settings_flags(provider, model, effort):
99
+ """argv fragment that pins ``model``/``effort`` for ``provider``; empty when nothing is known."""
100
+ if provider == "claude":
101
+ return (["--model", model] if model else []) + (["--effort", effort] if effort else [])
102
+ if provider == "codex" and model and effort:
103
+ return ["-m", model, "-c", "model_reasoning_effort=" + effort]
104
+ return []
package/lib/report.py CHANGED
@@ -32,8 +32,9 @@ Account status vocabulary (the panel's contract):
32
32
  Credential classes (what may be COPIED between machines):
33
33
  portable claude setup-token (server.token) — safe to export/import anywhere
34
34
  machine-local claude .credentials.json OR a macOS Keychain item (credentials.oauth_store
35
- says which) / codex auth.json — rotating refresh grant; copying it makes
36
- two machines fight over one grant and breaks both
35
+ says which) carrying a claudeAiOauth login — one holding only MCP OAuth
36
+ state is no login — / codex auth.json — rotating refresh grant; copying it
37
+ makes two machines fight over one grant and breaks both
37
38
  none nothing on disk (or in the Keychain) here
38
39
  """
39
40
 
@@ -46,7 +47,7 @@ import time
46
47
 
47
48
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
48
49
  import keychain # noqa: E402
49
- from audit import creds_doc_state, token_verified # noqa: E402
50
+ from audit import creds_doc_state, file_has_login, token_verified # noqa: E402
50
51
  from claude_client_error import org_access_verified_at # noqa: E402
51
52
  from claude_reset import marker_superseded, record_from # noqa: E402
52
53
  from codex_credits import credit_view # noqa: E402
@@ -170,10 +171,12 @@ def _claude_credentials(d):
170
171
  macOS Keychain (`oauth_store` says which; `keychain` says whether THIS process could
171
172
  open it). A Keychain login this session cannot open is still a login on this Mac —
172
173
  reporting it as `none` is what made a whole pool of working accounts read as
173
- missing — so the class stays machine-local and the row's status says `locked`."""
174
+ missing — so the class stays machine-local and the row's status says `locked`.
175
+ A file or item holding only the client's MCP OAuth state is no login at all
176
+ (audit.file_has_login, keychain 'nologin'): `oauth` stays false for it."""
174
177
  cpath = os.path.join(d, '.credentials.json')
175
178
  tpath = os.path.join(d, 'server.token')
176
- has_oauth = _size(cpath) > 0
179
+ has_oauth = file_has_login(cpath)
177
180
  has_token = _size(tpath) > 0
178
181
  detail = {'oauth': has_oauth, 'token': has_token, 'oauth_store': None, 'keychain': None,
179
182
  'oauth_expires_at': None, 'oauth_refresh_expires_at': None,
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """The model and effort a session is running with, read from its own history (Python 3.8+).
3
+
4
+ claude: the last main-thread assistant record (not a sidechain, not meta, a real model): its
5
+ ``requestedModel``, else ``message.model``; and ``effort``, else ``perTurnEffort``. A natively
6
+ 1M model is recorded without ``[1m]``, which it does not need. A ``/model`` switch with no reply
7
+ after it is not visible: only assistant records carry the model.
8
+ codex: whichever comes last of a ``turn_context`` (``model``/``effort``) and a
9
+ ``thread_settings_applied`` event for the file's own thread (``thread_settings.model``/
10
+ ``reasoning_effort``) -- the rule Codex's own state db follows, so a mid-session ``/model`` or
11
+ effort change is honoured. An absent effort means the model's default.
12
+
13
+ Bounded: the file is read backwards in chunks (a transcript can be 100 MB) and the scan stops
14
+ at SCAN_LIMIT bytes; ``upto`` caps it at a byte boundary (a handoff's snapshot). The result is
15
+ ``{"model": str|None, "effort": str|None}``; a file that cannot be read gives both None.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+
22
+ CHUNK = 1 << 20
23
+ SCAN_LIMIT = 64 << 20
24
+ SYNTHETIC = "<synthetic>"
25
+
26
+
27
+ def _reverse_lines(path, upto=None):
28
+ """Complete lines of ``path`` from the last one backwards, at most SCAN_LIMIT bytes in."""
29
+ with open(path, "rb") as fh:
30
+ end = os.fstat(fh.fileno()).st_size if upto is None else min(upto, os.fstat(fh.fileno()).st_size)
31
+ rest, pos, scanned = b"", end, 0
32
+ while pos > 0 and scanned < SCAN_LIMIT:
33
+ step = min(CHUNK, pos)
34
+ pos -= step
35
+ fh.seek(pos)
36
+ block = fh.read(step) + rest
37
+ scanned += step
38
+ lines = block.split(b"\n")
39
+ rest = lines[0]
40
+ for line in reversed(lines[1:]):
41
+ if line.strip():
42
+ yield line
43
+ if pos == 0 and rest.strip():
44
+ yield rest
45
+
46
+
47
+ def _record(line):
48
+ try:
49
+ doc = json.loads(line.decode("utf-8", "replace"))
50
+ except ValueError:
51
+ return None
52
+ return doc if isinstance(doc, dict) else None
53
+
54
+
55
+ def _text(value):
56
+ return value if isinstance(value, str) and value.strip() else None
57
+
58
+
59
+ def _own_thread_id(path):
60
+ """The id in the file's first line (its own session_meta; a fork copies its parent's at
61
+ line two), so another thread's settings snapshots are not mistaken for this one's."""
62
+ try:
63
+ with open(path, "rb") as fh:
64
+ doc = _record(fh.readline(1 << 20))
65
+ except OSError:
66
+ return None
67
+ payload = doc.get("payload") if doc else None
68
+ return _text(payload.get("id")) if isinstance(payload, dict) else None
69
+
70
+
71
+ def codex_settings(path, upto=None):
72
+ own = _own_thread_id(path)
73
+ for line in _reverse_lines(path, upto):
74
+ doc = _record(line)
75
+ payload = doc.get("payload") if doc else None
76
+ if not isinstance(payload, dict):
77
+ continue
78
+ if doc.get("type") == "turn_context" and _text(payload.get("model")):
79
+ return {"model": _text(payload.get("model")), "effort": _text(payload.get("effort"))}
80
+ if doc.get("type") == "event_msg" and payload.get("type") == "thread_settings_applied":
81
+ settings = payload.get("thread_settings")
82
+ thread = _text(payload.get("thread_id"))
83
+ if isinstance(settings, dict) and _text(settings.get("model")) and (own is None or thread in (None, own)):
84
+ return {"model": _text(settings.get("model")), "effort": _text(settings.get("reasoning_effort"))}
85
+ return {"model": None, "effort": None}
86
+
87
+
88
+ def claude_settings(path, upto=None):
89
+ for line in _reverse_lines(path, upto):
90
+ doc = _record(line)
91
+ if not doc or doc.get("type") != "assistant" or doc.get("isSidechain") or doc.get("isMeta"):
92
+ continue
93
+ message = doc.get("message")
94
+ model = _text(message.get("model")) if isinstance(message, dict) else None
95
+ if model is None or model == SYNTHETIC:
96
+ continue
97
+ return {"model": _text(doc.get("requestedModel")) or model,
98
+ "effort": _text(doc.get("effort")) or _text(doc.get("perTurnEffort"))}
99
+ return {"model": None, "effort": None}
100
+
101
+
102
+ def session_settings(provider, path, upto=None):
103
+ """Fail-open: anything unreadable or unknown is ``{"model": None, "effort": None}``."""
104
+ reader = {"claude": claude_settings, "codex": codex_settings}.get(provider)
105
+ if reader is None or not path:
106
+ return {"model": None, "effort": None}
107
+ try:
108
+ return reader(path, upto)
109
+ except (OSError, ValueError):
110
+ return {"model": None, "effort": None}
111
+
112
+
113
+ def main(argv):
114
+ if len(argv) not in (2, 4) or argv[0] not in ("claude", "codex") or (len(argv) == 4 and argv[2] != "--upto"):
115
+ sys.stderr.write("usage: session_settings.py <claude|codex> <history.jsonl> [--upto <bytes>]\n")
116
+ return 2
117
+ upto = int(argv[3]) if len(argv) == 4 else None
118
+ print(json.dumps(session_settings(argv[0], argv[1], upto)))
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main(sys.argv[1:]))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.58",
3
+ "version": "2.0.60",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "test": "npm run test:core && npm run test:seeding && npm run test:commands",
59
59
  "test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
60
60
  "test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
61
- "test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 tests/test_mcp_registry.py && npm run test:codex-settings",
61
+ "test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 tests/test_mcp_registry.py && python3 tests/test_mcp_oauth.py && npm run test:codex-settings",
62
62
  "test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
63
63
  "test:commands": "python3 tests/test_packaged_commands.py"
64
64
  },