claude-multiacc 2.0.64 → 2.0.66

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 (233) hide show
  1. package/bin/claude-accounts +15 -201
  2. package/bin/codex +25 -7
  3. package/bin/codex-accounts +16 -3
  4. package/docs/AUTORESUME.md +10 -1
  5. package/docs/CODEX.md +39 -0
  6. package/lib/__pycache__/audit.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/autoresume_cross.cpython-314.pyc +0 -0
  9. package/lib/__pycache__/autoresume_cross_dispatch.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/autoresume_cross_receipt.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/autoresume_handoff.cpython-314.pyc +0 -0
  12. package/lib/__pycache__/autoresume_probe.cpython-314.pyc +0 -0
  13. package/lib/__pycache__/autoresume_receipt.cpython-314.pyc +0 -0
  14. package/lib/__pycache__/claude_client_error.cpython-314.pyc +0 -0
  15. package/lib/__pycache__/claude_reset.cpython-314.pyc +0 -0
  16. package/lib/__pycache__/claude_token_identity.cpython-314.pyc +0 -0
  17. package/lib/__pycache__/codex_autoresume_mode.cpython-314.pyc +0 -0
  18. package/lib/__pycache__/codex_config_edit.cpython-314.pyc +0 -0
  19. package/lib/__pycache__/codex_credit_controls.cpython-314.pyc +0 -0
  20. package/lib/__pycache__/codex_credit_local_scope.cpython-314.pyc +0 -0
  21. package/lib/__pycache__/codex_credit_selection.cpython-314.pyc +0 -0
  22. package/lib/__pycache__/codex_credits.cpython-314.pyc +0 -0
  23. package/lib/__pycache__/codex_limits_lock.cpython-314.pyc +0 -0
  24. package/lib/__pycache__/codex_mcp_oauth.cpython-314.pyc +0 -0
  25. package/lib/__pycache__/codex_python.cpython-314.pyc +0 -0
  26. package/lib/__pycache__/codex_resume_cwd.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/codex_speed.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/codex_trust.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/codex_usage_fetch.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/keychain.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/mcp_mirror.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/mcp_oauth.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/mcp_registry.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/model_catalog.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/model_match.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/report.cpython-314.pyc +0 -0
  37. package/lib/__pycache__/selector_credit_scope.cpython-314.pyc +0 -0
  38. package/lib/__pycache__/selector_credits.cpython-314.pyc +0 -0
  39. package/lib/__pycache__/selector_funding.cpython-314.pyc +0 -0
  40. package/lib/__pycache__/selector_policy.cpython-314.pyc +0 -0
  41. package/lib/__pycache__/selector_primitives.cpython-314.pyc +0 -0
  42. package/lib/__pycache__/session_settings.cpython-314.pyc +0 -0
  43. package/lib/__pycache__/shim_path.cpython-314.pyc +0 -0
  44. package/lib/autoresume.py +28 -8
  45. package/lib/claude_oauth_lock.py +172 -0
  46. package/lib/claude_oauth_refresh.py +225 -0
  47. package/lib/codex_speed.py +351 -0
  48. package/lib/codex_speed.sh +39 -0
  49. package/lib/common.sh +13 -3
  50. package/lib/model_match.py +4 -1
  51. package/lib/session_settings.py +25 -6
  52. package/lib/shim_path.py +6 -0
  53. package/package.json +4 -3
  54. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-314.pyc +0 -0
  55. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-314.pyc +0 -0
  56. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-314.pyc +0 -0
  57. package/tests/__pycache__/autoresume_cross_unit_support.cpython-314.pyc +0 -0
  58. package/tests/__pycache__/autoresume_tmux_support.cpython-314.pyc +0 -0
  59. package/tests/__pycache__/packaged_command_support.cpython-314.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume.cpython-314.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_catalog.cpython-314.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-314.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_codex_records.cpython-314.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_codex_trust.cpython-314.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-314.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_cross_guards.cpython-314.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_cross_holders.cpython-314.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_cross_origin.cpython-314.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_cross_policy.cpython-314.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-314.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-314.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-314.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-314.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_cursor.cpython-314.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cwd.cpython-314.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-314.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-314.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-314.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_fallback.cpython-314.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-314.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_handoff_history.cpython-314.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-314.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_memory.cpython-314.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-314.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_mode.cpython-314.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_probe.cpython-314.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_receipt.cpython-314.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-314.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_settings.cpython-314.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_sidechain.cpython-314.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-314.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_tmux.cpython-314.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-314.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-314.pyc +0 -0
  95. package/tests/__pycache__/test_claude_oauth_lock.cpython-314.pyc +0 -0
  96. package/tests/__pycache__/test_claude_reset.cpython-314.pyc +0 -0
  97. package/tests/__pycache__/test_claude_token_identity.cpython-314.pyc +0 -0
  98. package/tests/__pycache__/test_codex_exec_stdin.cpython-314.pyc +0 -0
  99. package/tests/__pycache__/test_codex_mcp_oauth.cpython-314.pyc +0 -0
  100. package/tests/__pycache__/test_codex_reset.cpython-314.pyc +0 -0
  101. package/tests/__pycache__/test_codex_reset_credits.cpython-314.pyc +0 -0
  102. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-314.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset_credits_report.cpython-314.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_fetch.cpython-314.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_polling.cpython-314.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_reporting.cpython-314.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_windows.cpython-314.pyc +0 -0
  108. package/tests/__pycache__/test_mcp_mirror.cpython-314.pyc +0 -0
  109. package/tests/__pycache__/test_mcp_mirror_args.cpython-314.pyc +0 -0
  110. package/tests/__pycache__/test_mcp_oauth.cpython-314.pyc +0 -0
  111. package/tests/__pycache__/test_mcp_registry.cpython-314.pyc +0 -0
  112. package/tests/__pycache__/test_selector.cpython-314.pyc +0 -0
  113. package/tests/__pycache__/test_selector_cached_credits.cpython-314.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_scope.cpython-314.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credit_shim.cpython-314.pyc +0 -0
  116. package/tests/__pycache__/test_selector_credits.cpython-314.pyc +0 -0
  117. package/tests/__pycache__/test_selector_spread.cpython-314.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_ceremony.cpython-314.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_integration.cpython-314.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_retry.cpython-314.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_sync.cpython-314.pyc +0 -0
  122. package/tests/claude_oauth_fixture.py +100 -0
  123. package/tests/run-tests.sh +23 -7
  124. package/tests/test_claude_oauth_lock.py +138 -0
  125. package/tests/test_claude_oauth_refresh.py +106 -0
  126. package/tests/test_claude_oauth_refresh_failures.py +112 -0
  127. package/tests/test_codex_speed.py +236 -0
  128. package/tests/test_mutate_lock.py +107 -0
  129. package/tests/test_shim_path.py +44 -4
  130. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  131. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  132. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  133. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  134. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  135. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  136. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  137. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  138. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  139. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  140. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  141. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  142. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  143. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  144. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  145. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  146. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  147. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  148. package/lib/__pycache__/codex_mcp_oauth.cpython-312.pyc +0 -0
  149. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  150. package/lib/__pycache__/codex_resume_cwd.cpython-312.pyc +0 -0
  151. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  152. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  153. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  154. package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
  155. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  156. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  157. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  158. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  159. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  160. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  161. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  162. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  163. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  164. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  165. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  166. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  167. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  168. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  169. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  170. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  171. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  172. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  173. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  174. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  175. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  176. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  177. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  178. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  179. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  180. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  181. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  182. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  183. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  184. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  185. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  186. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  187. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  188. package/tests/__pycache__/test_autoresume_cwd.cpython-312.pyc +0 -0
  189. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-312.pyc +0 -0
  190. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-312.pyc +0 -0
  191. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-312.pyc +0 -0
  192. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  193. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  194. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  195. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  196. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  197. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  198. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  199. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  200. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  201. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  202. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  203. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  204. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  205. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  206. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  207. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  208. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  209. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  210. package/tests/__pycache__/test_codex_exec_stdin.cpython-312.pyc +0 -0
  211. package/tests/__pycache__/test_codex_mcp_oauth.cpython-312.pyc +0 -0
  212. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  213. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  214. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  215. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  216. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  217. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  218. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  219. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  220. package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
  221. package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
  222. package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
  223. package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
  224. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  225. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  226. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  227. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  228. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  229. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  230. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  231. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  232. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  233. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
package/lib/autoresume.py CHANGED
@@ -1214,6 +1214,14 @@ def codex_effort_ok(val):
1214
1214
  and EFFORT_VALUE_RE.match(val[len(CODEX_EFFORT_KEY):]) is not None
1215
1215
 
1216
1216
 
1217
+ def codex_speed_ok(val):
1218
+ """Only known speed choices, never arbitrary config, may cross a relaunch."""
1219
+ if val == "features.fast_mode=true":
1220
+ return True
1221
+ return isinstance(val, str) and re.fullmatch(
1222
+ r'''service_tier=(?:default|fast|priority|ultrafast|"(?:default|fast|priority|ultrafast)"|'(?:default|fast|priority|ultrafast)')''', val) is not None
1223
+
1224
+
1217
1225
  def codex_argv_parse(argv):
1218
1226
  """The argv shapes a codex relaunch can rebuild (mirrors bin/codex's ar_argv_ok) ->
1219
1227
  (kept options, resume target or None), or None for anything else."""
@@ -1231,7 +1239,7 @@ def codex_argv_parse(argv):
1231
1239
  kept += [arg, expected]
1232
1240
  i += 1
1233
1241
  elif arg in ("-m", "--model", "-c"):
1234
- value_ok = codex_effort_ok if arg == "-c" else _value_ok
1242
+ value_ok = (lambda value: codex_effort_ok(value) or codex_speed_ok(value)) if arg == "-c" else _value_ok
1235
1243
  if i + 1 >= len(argv) or not value_ok(argv[i + 1]):
1236
1244
  return None
1237
1245
  kept += [arg, argv[i + 1]]
@@ -1315,7 +1323,11 @@ def settings_argv(provider, settings):
1315
1323
  (anything else counts as unknown). [] when nothing is known."""
1316
1324
  effort = settings.get("effort") if isinstance(settings, dict) else None
1317
1325
  effort = effort if isinstance(effort, str) and EFFORT_VALUE_RE.match(effort) else None
1318
- return settings_flags(provider, history_model(settings), effort)
1326
+ flags = settings_flags(provider, history_model(settings), effort)
1327
+ if provider == "codex" and isinstance(settings, dict) and settings.get("speed") in ("normal", "fast", "ultrafast"):
1328
+ flags += ["-c", "service_tier=" + {"normal": "default", "fast": "fast",
1329
+ "ultrafast": "ultrafast"}[settings["speed"]]]
1330
+ return flags
1319
1331
 
1320
1332
 
1321
1333
  def argv_settings(provider, argv):
@@ -1336,6 +1348,10 @@ def argv_settings(provider, argv):
1336
1348
  elif provider == "codex" and arg == "-c" and codex_effort_ok(val):
1337
1349
  found["effort"] = val[len(CODEX_EFFORT_KEY):]
1338
1350
  i += 1
1351
+ elif provider == "codex" and arg == "-c" and val.startswith("service_tier=") and codex_speed_ok(val):
1352
+ from codex_speed import speed
1353
+ found["speed"] = speed(val.split("=", 1)[1].strip("\"'"))
1354
+ i += 1
1339
1355
  i += 1
1340
1356
  return found
1341
1357
 
@@ -1355,14 +1371,16 @@ def claude_model_left(launch, served):
1355
1371
  return family is not None and family != launch
1356
1372
 
1357
1373
 
1358
- def without_settings(kept, model, effort):
1374
+ def without_settings(kept, model, effort, speed=False):
1359
1375
  """``kept`` without its model options (-m V, --model V, --model=V) when ``model`` and its
1360
1376
  effort options (--effort V, codex's -c model_reasoning_effort=V) when ``effort``. Both
1361
1377
  parsers refuse a value that starts with "-", so no value is ever taken for an option."""
1362
1378
  out, i = [], 0
1363
1379
  while i < len(kept):
1364
1380
  arg = kept[i]
1365
- if (model and arg in ("-m", "--model")) or (effort and arg in ("--effort", "-c")):
1381
+ val = kept[i + 1] if i + 1 < len(kept) else ""
1382
+ if ((model and arg in ("-m", "--model")) or (effort and arg == "--effort")
1383
+ or (arg == "-c" and ((effort and codex_effort_ok(val)) or (speed and codex_speed_ok(val))))):
1366
1384
  i += 2
1367
1385
  continue
1368
1386
  if not (model and arg.startswith("--model=")):
@@ -1407,10 +1425,12 @@ def relaunch_argv(provider, argv, sid, cls, prompt=None, settings=None, detail=N
1407
1425
  left = claude_model_left(argv_settings("claude", kept).get("model"), settings.get("model"))
1408
1426
  settings = {"effort": settings.get("effort")}
1409
1427
  flags = settings_argv(provider, settings)
1410
- model, effort = left or "--model" in flags or "-m" in flags, "--effort" in flags or "-c" in flags
1411
- if provider == "codex" and not flags and history_model(settings):
1428
+ model = left or "--model" in flags or "-m" in flags
1429
+ effort = "--effort" in flags or any(codex_effort_ok(value) for value in flags)
1430
+ speed = provider == "codex" and settings.get("speed") in ("normal", "fast", "ultrafast")
1431
+ if provider == "codex" and not model and history_model(settings):
1412
1432
  model = effort = True # no launch -m or -c alone: neither, and the thread restores both
1413
- return head + without_settings(kept, model, effort) + flags + tail
1433
+ return head + without_settings(kept, model, effort, speed) + flags + tail
1414
1434
 
1415
1435
 
1416
1436
  # ---- relaunch / state files ---------------------------------------------------------
@@ -2721,7 +2741,7 @@ class Watcher:
2721
2741
  try:
2722
2742
  found = session_settings(self.provider, self.transcript)
2723
2743
  self.served_model = history_model(found)
2724
- if not found.get("model") and not found.get("effort"):
2744
+ if not found.get("model") and not found.get("effort") and not found.get("speed"):
2725
2745
  return None
2726
2746
  # The catalog only clamps an effort to the model and supplies Codex's default
2727
2747
  # one: this pool's copy and caches, the other pool's only when the chain named it.
@@ -0,0 +1,172 @@
1
+ """Serialize OAuth token POSTs with Claude Code's native refresh locks.
2
+
3
+ Claude Code 2.1.310 Gnr/Vri use proper-lockfile directories: first
4
+ <config>/.oauth_refresh.lock, then <realpath(config)>.lock, with a 60-second
5
+ stale deadline and five-second mtime renewal. Ec changes callback scheduling,
6
+ not that on-disk protocol. The optional native owner sidecar only accelerates
7
+ proven-dead-holder recovery; without one, native clients retain the stale gate.
8
+
9
+ Hold this lock across credential reread, token exchange and rotated-token save.
10
+ The separate .storage-write.lock protects only short credential transactions.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import stat
16
+ import threading
17
+ import time
18
+ from pathlib import Path
19
+
20
+ STALE_SECONDS = 60.0
21
+ UPDATE_SECONDS = 5.0
22
+ RETRY_SECONDS = 0.05
23
+
24
+
25
+ def _snapshot(path: Path) -> tuple[int, int, int] | None:
26
+ try:
27
+ value = path.lstat()
28
+ if stat.S_ISDIR(value.st_mode):
29
+ return value.st_dev, value.st_ino, value.st_mtime_ns
30
+ except OSError:
31
+ pass
32
+ return None
33
+
34
+
35
+ class RefreshLock:
36
+ """Bounded, fail-closed lock; call release even after an exchange fails."""
37
+
38
+ def __init__(self, directory: str | Path, timeout: float = 1.0):
39
+ directory = Path(directory).absolute()
40
+ self.paths = (directory / ".oauth_refresh.lock", Path(str(directory.resolve()) + ".lock"))
41
+ self.timeout = max(0.0, timeout)
42
+ self._held: list[tuple[Path, int, tuple[int, int, int]]] = []
43
+ self._mutex = threading.Lock()
44
+ self._stop = threading.Event()
45
+ self._thread: threading.Thread | None = None
46
+ self._compromised = False
47
+
48
+ def acquire(self) -> bool:
49
+ deadline = time.monotonic() + self.timeout
50
+ with self._mutex:
51
+ if self._held:
52
+ return not self._compromised and all(
53
+ _snapshot(path) == stamp and time.time_ns() - stamp[2] < STALE_SECONDS * 1e9
54
+ for path, _, stamp in self._held)
55
+ self._compromised = False
56
+ self._stop.clear()
57
+ while True:
58
+ for path in self.paths:
59
+ entry = self._claim(path)
60
+ if entry is None:
61
+ break
62
+ self._held.append(entry)
63
+ if len(self._held) == len(self.paths):
64
+ self._thread = threading.Thread(target=self._heartbeat, daemon=True)
65
+ try:
66
+ self._thread.start()
67
+ except RuntimeError:
68
+ self._release_paths()
69
+ self._thread = None
70
+ return False
71
+ return True
72
+ self._release_paths()
73
+ remaining = deadline - time.monotonic()
74
+ if remaining <= 0:
75
+ return False
76
+ time.sleep(min(RETRY_SECONDS, remaining))
77
+
78
+ def _claim(self, path: Path) -> tuple[Path, int, tuple[int, int, int]] | None:
79
+ try:
80
+ path.mkdir(mode=0o700)
81
+ except FileExistsError:
82
+ before = _snapshot(path)
83
+ if before is None or time.time_ns() - before[2] <= STALE_SECONDS * 1e9:
84
+ return None
85
+ try:
86
+ if _snapshot(path) != before:
87
+ return None
88
+ path.rmdir()
89
+ path.mkdir(mode=0o700)
90
+ except OSError:
91
+ return None
92
+ except OSError:
93
+ return None
94
+ before = _snapshot(path)
95
+ descriptor = None
96
+ claimed = False
97
+ try:
98
+ descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
99
+ value = os.fstat(descriptor)
100
+ if before is None or (value.st_dev, value.st_ino, value.st_mtime_ns) != before:
101
+ return None
102
+ now = time.time_ns()
103
+ os.utime(descriptor, ns=(now, now))
104
+ value = os.fstat(descriptor)
105
+ stamp = value.st_dev, value.st_ino, value.st_mtime_ns
106
+ before = stamp
107
+ if _snapshot(path) != stamp:
108
+ return None
109
+ claimed = True
110
+ result, descriptor = (path, descriptor, stamp), None
111
+ return result
112
+ except OSError:
113
+ return None
114
+ finally:
115
+ if descriptor is not None:
116
+ os.close(descriptor)
117
+ if not claimed and before is not None and _snapshot(path) == before:
118
+ try:
119
+ path.rmdir()
120
+ except OSError:
121
+ pass
122
+
123
+ def _renew(self) -> bool:
124
+ for index, (path, descriptor, stamp) in enumerate(self._held):
125
+ if _snapshot(path) != stamp or time.time_ns() - stamp[2] >= STALE_SECONDS * 1e9:
126
+ return False
127
+ try:
128
+ now = time.time_ns()
129
+ os.utime(descriptor, ns=(now, now))
130
+ value = os.fstat(descriptor)
131
+ updated = value.st_dev, value.st_ino, value.st_mtime_ns
132
+ if _snapshot(path) != updated:
133
+ return False
134
+ self._held[index] = path, descriptor, updated
135
+ except OSError:
136
+ return False
137
+ return True
138
+
139
+ def _heartbeat(self) -> None:
140
+ while not self._stop.wait(UPDATE_SECONDS):
141
+ with self._mutex:
142
+ if not self._renew():
143
+ self._compromised = True
144
+ return
145
+
146
+ def _release_paths(self) -> None:
147
+ for path, descriptor, stamp in reversed(self._held):
148
+ try:
149
+ if _snapshot(path) == stamp:
150
+ path.rmdir()
151
+ except OSError:
152
+ pass
153
+ finally:
154
+ os.close(descriptor)
155
+ self._held.clear()
156
+
157
+ def release(self) -> None:
158
+ self._stop.set()
159
+ thread = self._thread
160
+ if thread is not None:
161
+ thread.join(timeout=1.0)
162
+ with self._mutex:
163
+ self._release_paths()
164
+ self._thread = None
165
+
166
+ @property
167
+ def compromised(self) -> bool:
168
+ with self._mutex:
169
+ if any(_snapshot(path) != stamp or time.time_ns() - stamp[2] >= STALE_SECONDS * 1e9
170
+ for path, _, stamp in self._held):
171
+ self._compromised = True
172
+ return self._compromised
@@ -0,0 +1,225 @@
1
+ """Refresh machine-local Claude logins without racing the native rotating grant.
2
+
3
+ Claude Code 2.1.310 holds both refresh locks across the token request. The storage
4
+ lock protects only the final read/compare/write, never the network round trip.
5
+ An invalid_grant response can leave an empty-token tombstone; that is not a newer
6
+ login and must not displace a successfully rotated grant. A removed login is logout.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ from claude_oauth_lock import RefreshLock
17
+ from mcp_oauth import CLIENT_LOCK, CLIENT_LOCK_STALE_S, ClientLock
18
+
19
+ REFRESH_MIN_EXPIRED = 300
20
+ REFRESH_FAIL_BACKOFF = 600
21
+ REFRESH_DENIED_BACKOFF = 21600
22
+ CLIENT_LOCK_PROBE_S = 1.0
23
+ CLIENT_LOCK_WAIT_S = 10.0
24
+
25
+
26
+ def read_login(store):
27
+ """Unreadable, missing, or non-object logins cannot authorize a token write."""
28
+ try:
29
+ doc = store.read()
30
+ login = doc.get('claudeAiOauth') if isinstance(doc, dict) else None
31
+ return dict(login) if isinstance(login, dict) else None
32
+ except Exception:
33
+ return None
34
+
35
+
36
+ def client_lock_busy(directory):
37
+ deadline = time.monotonic() + CLIENT_LOCK_PROBE_S
38
+ while True:
39
+ try:
40
+ if time.time() - os.stat(os.path.join(directory, CLIENT_LOCK)).st_mtime > CLIENT_LOCK_STALE_S:
41
+ return False
42
+ except OSError:
43
+ return False
44
+ if time.monotonic() >= deadline:
45
+ return True
46
+ time.sleep(0.05)
47
+
48
+
49
+ def rotated_login(login, data, now):
50
+ result = dict(login, accessToken=data['access_token'])
51
+ if data.get('refresh_token'):
52
+ result['refreshToken'] = data['refresh_token']
53
+ result['expiresAt'] = int((now + float(data.get('expires_in') or 3600)) * 1000)
54
+ if data.get('refresh_token_expires_in'):
55
+ result['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
56
+ return result
57
+
58
+
59
+ class OAuthRefresher:
60
+ def __init__(self, *, now, force, token_url, client_id, say, park_dead_grant, clear_expired):
61
+ self.now = now
62
+ self.force = force
63
+ self.token_url = token_url
64
+ self.client_id = client_id
65
+ self.say = say
66
+ self.park = park_dead_grant
67
+ self.clear_expired = clear_expired
68
+
69
+ def refresh(self, aid, directory, store):
70
+ # This lock must fail closed: a grace period after access expiry does not
71
+ # prove that another process has stopped refreshing the same rotating grant.
72
+ lock = RefreshLock(directory)
73
+ if not lock.acquire():
74
+ self.say(f'{aid}: oauth refresh skipped — the client holds its refresh lock; the next pass retries')
75
+ return None
76
+ try:
77
+ # Reread AFTER ownership: the client may have refreshed while we waited.
78
+ login = read_login(store)
79
+ if login is None:
80
+ return None
81
+ if login.get('accessToken') and login.get('expiresAt', 0) / 1000 > self.now + 60:
82
+ return login['accessToken']
83
+ if not self.eligible(aid, directory, login):
84
+ return None
85
+ if client_lock_busy(directory) or lock.compromised:
86
+ self.say(f'{aid}: oauth refresh skipped — the client holds its credential lock ({CLIENT_LOCK})')
87
+ return None
88
+ data = self.post(aid, directory, login['refreshToken'], store)
89
+ if data is None:
90
+ return None
91
+ rotated = rotated_login(login, data, self.now)
92
+ if not self.save(aid, directory, store, login['refreshToken'], rotated):
93
+ return None
94
+ try:
95
+ os.remove(os.path.join(directory, '.oauth-refresh.json'))
96
+ except OSError:
97
+ pass
98
+ if self.clear_expired(directory):
99
+ self.say(f'{aid}: dead-auth marker cleared (refresh grant works again)')
100
+ self.say(f'{aid}: oauth access token refreshed via refresh-token grant')
101
+ return rotated['accessToken']
102
+ finally:
103
+ lock.release()
104
+
105
+ def eligible(self, aid, directory, login):
106
+ if not login.get('refreshToken'):
107
+ self.park(aid, directory, 'no-refresh-token',
108
+ 'credential has no refresh token and its access token expired')
109
+ return False
110
+ if login.get('expiresAt', 0) / 1000 > self.now - REFRESH_MIN_EXPIRED:
111
+ return False
112
+ refresh_expiry = login.get('refreshTokenExpiresAt')
113
+ if refresh_expiry and refresh_expiry / 1000 <= self.now:
114
+ self.park(aid, directory, 'refresh-token-expired',
115
+ 'the refresh token itself expired; only a re-login can fix it')
116
+ return False
117
+ if not self.force:
118
+ try:
119
+ with open(os.path.join(directory, '.oauth-refresh.json')) as stream:
120
+ if json.load(stream).get('retry_after', 0) > self.now:
121
+ return False
122
+ except (OSError, ValueError, AttributeError):
123
+ pass
124
+ return True
125
+
126
+ def back_off(self, aid, directory, wait, why, denials=0):
127
+ path = os.path.join(directory, '.oauth-refresh.json')
128
+ try:
129
+ with open(path + '.tmp', 'w') as stream:
130
+ json.dump({'retry_after': int(self.now + wait), 'error': why, 'denials': denials,
131
+ 'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, stream)
132
+ os.replace(path + '.tmp', path)
133
+ except OSError:
134
+ pass
135
+ self.say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
136
+
137
+ def post(self, aid, directory, refresh_token, store):
138
+ body = json.dumps({'grant_type': 'refresh_token', 'refresh_token': refresh_token,
139
+ 'client_id': self.client_id}).encode()
140
+ request = urllib.request.Request(self.token_url, data=body, headers={
141
+ 'Content-Type': 'application/json', 'User-Agent': 'claude-multiacc/1.0',
142
+ })
143
+ try:
144
+ data = json.loads(urllib.request.urlopen(request, timeout=30).read().decode())
145
+ except Exception as error:
146
+ # A sign-in/logout can replace this grant without the refresh lock.
147
+ # A failure of the old request must never park the new login.
148
+ disk = read_login(store)
149
+ if disk is None or disk.get('refreshToken') != refresh_token:
150
+ self.say(f'{aid}: oauth login changed during a failed refresh; leaving its state unchanged')
151
+ return None
152
+ if isinstance(error, urllib.error.HTTPError) and error.code in (400, 401, 403):
153
+ self.denied(aid, directory, error, store, refresh_token)
154
+ else:
155
+ why = f'HTTP {error.code}' if isinstance(error, urllib.error.HTTPError) else type(error).__name__
156
+ self.back_off(aid, directory, REFRESH_FAIL_BACKOFF, why)
157
+ return None
158
+ if not isinstance(data, dict) or not data.get('access_token'):
159
+ disk = read_login(store)
160
+ if disk is not None and disk.get('refreshToken') == refresh_token:
161
+ self.back_off(aid, directory, REFRESH_DENIED_BACKOFF, 'no access_token in response')
162
+ return None
163
+ return data
164
+
165
+ def denied(self, aid, directory, error, store, refresh_token):
166
+ body = ''
167
+ try:
168
+ body = error.read(400).decode('utf-8', 'replace')
169
+ except Exception:
170
+ pass
171
+ denials = 1
172
+ try:
173
+ with open(os.path.join(directory, '.oauth-refresh.json')) as stream:
174
+ denials += int(json.load(stream).get('denials', 0))
175
+ except (OSError, ValueError, TypeError, AttributeError):
176
+ pass
177
+ lock = ClientLock(directory, CLIENT_LOCK_WAIT_S)
178
+ if not lock.acquire():
179
+ return # no rotation occurred; retry without marking an uncertain login
180
+ try:
181
+ disk = read_login(store)
182
+ if disk is None or disk.get('refreshToken') != refresh_token:
183
+ return
184
+ if 'invalid_grant' in body:
185
+ self.park(aid, directory, f'refresh-denied-http-{error.code}',
186
+ 'the refresh grant was refused as invalid_grant (revoked or rotated away)')
187
+ elif denials >= 3:
188
+ self.park(aid, directory, f'refresh-denied-http-{error.code}',
189
+ f'the refresh grant was refused {denials} times in a row')
190
+ self.back_off(aid, directory, REFRESH_DENIED_BACKOFF,
191
+ f'HTTP {error.code} — refresh token may be revoked; re-login needed', denials)
192
+ finally:
193
+ lock.release()
194
+
195
+ def save(self, aid, directory, store, started_with, login):
196
+ lock = ClientLock(directory, CLIENT_LOCK_WAIT_S)
197
+ held = False
198
+ try:
199
+ held = lock.acquire()
200
+ except Exception:
201
+ pass
202
+ if not held and os.path.isdir(lock.path):
203
+ self.say(f'{aid}: the client kept its credential lock ({CLIENT_LOCK}) past '
204
+ f'{int(CLIENT_LOCK_WAIT_S)}s — writing the rotated login without it')
205
+ try:
206
+ disk = read_login(store)
207
+ # Native invalid_grant clears these three fields but leaves the login
208
+ # object. A missing document/key is logout, never a tombstone to restore.
209
+ tombstone = (disk is not None and disk.get('refreshToken') == ''
210
+ and disk.get('accessToken') == '' and disk.get('expiresAt') == 0)
211
+ if disk is None or (disk.get('refreshToken') != started_with and not tombstone):
212
+ self.say(f'{aid}: credential was refreshed by something else mid-flight — '
213
+ f'keeping the newer one in the {store.kind} (or its removal)')
214
+ return False
215
+ try:
216
+ # Store implementations merge only this key, preserving current MCP state.
217
+ store.write({'claudeAiOauth': login})
218
+ except Exception as error:
219
+ self.say(f'{aid}: token refreshed but credentials NOT persisted '
220
+ f'({type(error).__name__}) — re-login may be needed')
221
+ return False
222
+ return True
223
+ finally:
224
+ if held:
225
+ lock.release()