meltygui 0.1.0__py3-none-any.whl

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 (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,499 @@
1
+ """Claude subscription usage — the Max / Pro rate-limit windows (session,
2
+ weekly all-models, weekly per-model such as Fable) and the extra-usage
3
+ spend, for the Internet Accounts window's "Claude subscription" row.
4
+
5
+ Source: the claude.ai OAuth login Claude Code keeps in
6
+ `~/.claude/.credentials.json` (`claudeAiOauth.accessToken`). The studio
7
+ only READS that file — it never refreshes the token and never sends it
8
+ for inference; the numbers come from GET /api/oauth/usage, the call
9
+ Claude Code's own `/usage` makes. An API-org token (the studio's Console
10
+ sign-in, anthropic_oauth.py) is refused by that endpoint ("Usage limits
11
+ are not applicable to API organizations"), which is why the subscription
12
+ is its own account kind. A stale token is Claude Code's to refresh: run
13
+ `claude` once and the file is rewritten.
14
+
15
+ `parse_usage` normalises the payload to rows the window paints as bars:
16
+ the `limits` list (kind / percent / severity / resets_at / scope — the
17
+ general form, one entry per window incl. per-model ones), with the
18
+ legacy `five_hour` / `seven_day*` blocks as the fallback, plus `spend`
19
+ (or `extra_usage`) as an "Extra usage" row.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import collections
24
+ import json
25
+ import os
26
+ import re
27
+ import shutil
28
+ import subprocess
29
+ import threading
30
+ import time
31
+ import urllib.error
32
+ import urllib.request
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+
36
+ USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
37
+
38
+ # Every usage call this process ran: (wall time, outcome) - printed on a
39
+ # 429 so the log shows the real cadence behind a rate limit.
40
+ recent_requests = collections.deque(maxlen=40)
41
+
42
+
43
+ # ──────────────────────────────────────────────────────────────────────────
44
+ # Claude Code's login file
45
+ # ──────────────────────────────────────────────────────────────────────────
46
+
47
+ def read_login(path):
48
+ """Claude Code's claude.ai login: {"token", "expires_at" (unix s or
49
+ None), "expired", "plan" ("Max 20x" / "Pro" / …), "scopes", "email",
50
+ "organization", "path"} — or None when the file / login is missing.
51
+ `email` / `organization` come from Claude Code's identity file next to
52
+ it (`read_identity`); they say WHOSE usage the file yields, so two
53
+ Internet Accounts rows sharing one file don't both paint it. No
54
+ network."""
55
+ path = Path(path).expanduser()
56
+ try:
57
+ data = json.loads(path.read_text(encoding="utf-8"))
58
+ except (OSError, ValueError):
59
+ return None
60
+ oauth = data.get("claudeAiOauth") if isinstance(data, dict) else None
61
+ if not isinstance(oauth, dict) or not oauth.get("accessToken"):
62
+ return None
63
+ expires_at = oauth.get("expiresAt")
64
+ try:
65
+ expires_at = float(expires_at) / 1000.0 if expires_at is not None else None
66
+ except (TypeError, ValueError):
67
+ expires_at = None
68
+ identity = read_identity(path)
69
+ return {"token": oauth["accessToken"],
70
+ "expires_at": expires_at,
71
+ "expired": expires_at is not None and expires_at <= time.time(),
72
+ "plan": plan_label(oauth.get("subscriptionType"), oauth.get("rateLimitTier")),
73
+ "scopes": list(oauth.get("scopes") or []),
74
+ "email": (identity.get("emailAddress") or "").strip(),
75
+ "organization": (identity.get("organizationName") or "").strip(),
76
+ "path": path}
77
+
78
+
79
+ _login_cache = {} # credentials path → (file signature, read_login result); see cached_login
80
+
81
+
82
+ def cached_login(path):
83
+ """read_login(path), re-read only when the credentials file or the
84
+ identity file beside it changed (mtime + size) — cheap enough for a
85
+ repaint, and the way a Claude Code login switch (`claude auth login`,
86
+ the Use-in-Claude-Code button) reaches every row on its next draw."""
87
+ path = Path(path).expanduser()
88
+ signature = tuple(_file_signature(candidate) for candidate in (
89
+ path, path.parent / ".claude.json", path.parent.parent / ".claude.json"))
90
+ hit = _login_cache.get(path)
91
+ if hit is not None and hit[0] == signature:
92
+ return hit[1]
93
+ login = read_login(path)
94
+ _login_cache[path] = (signature, login)
95
+ return login
96
+
97
+
98
+ def _file_signature(path):
99
+ try:
100
+ info = os.stat(path)
101
+ except OSError:
102
+ return None
103
+ return (info.st_mtime_ns, info.st_size)
104
+
105
+
106
+ def read_identity(credentials_path) -> dict:
107
+ """The `oauthAccount` block of Claude Code's `.claude.json` (no secrets:
108
+ emailAddress, organizationName, displayName, …) for a credentials
109
+ file: `<CLAUDE_CONFIG_DIR>/.claude.json` beside it, else the default
110
+ layout's `~/.claude.json` one level up from `~/.claude/`. {} when
111
+ absent."""
112
+ credentials_path = Path(credentials_path).expanduser()
113
+ for candidate in (credentials_path.parent / ".claude.json",
114
+ credentials_path.parent.parent / ".claude.json"):
115
+ try:
116
+ data = json.loads(candidate.read_text(encoding="utf-8"))
117
+ except (OSError, ValueError):
118
+ # Claude Code rewrites this file constantly (stats, sessions); a
119
+ # read that lands mid-write parses as nothing. Keep the last good
120
+ # identity for this path rather than reporting "nobody" for a
121
+ # frame - a flicker to "nobody" reads as an account switch.
122
+ if candidate in _identity_cache:
123
+ return _identity_cache[candidate]
124
+ continue
125
+ account = data.get("oauthAccount") if isinstance(data, dict) else None
126
+ if isinstance(account, dict):
127
+ _identity_cache[candidate] = account
128
+ return account
129
+ return {}
130
+
131
+
132
+ _identity_cache = {} # identity file path → last successfully parsed oauthAccount
133
+
134
+
135
+ def plan_label(subscription_type, rate_limit_tier) -> str:
136
+ """"Max 20x" from subscriptionType "max" + rateLimitTier
137
+ "default_claude_max_20x"; "Pro"; or whatever the type says."""
138
+ name = (subscription_type or "").strip()
139
+ name = name[:1].upper() + name[1:] if name else "Claude"
140
+ multiplier = re.search(r"_(\d+)x$", rate_limit_tier or "")
141
+ if multiplier:
142
+ name += f" {multiplier.group(1)}x"
143
+ return name
144
+
145
+
146
+ # ──────────────────────────────────────────────────────────────────────────
147
+ # The usage endpoint
148
+ # ──────────────────────────────────────────────────────────────────────────
149
+
150
+ class UsageRateLimited(RuntimeError):
151
+ """The usage endpoint answered 429; `retry_after` is its Retry-After in
152
+ seconds (None when it sent none)."""
153
+
154
+ def __init__(self, message, retry_after=None):
155
+ super().__init__(message)
156
+ self.retry_after = retry_after
157
+
158
+
159
+ def fetch_usage(token: str, url: str = USAGE_URL, timeout_s: float = 15.0) -> dict:
160
+ """GET the usage payload with the claude.ai OAuth token (Bearer + the
161
+ oauth beta header). RuntimeError with the status on failure
162
+ (UsageRateLimited on 429)."""
163
+ from meltygui.completion.providers.anthropic_requests import notify_request
164
+ notify_request("GET /api/oauth/usage")
165
+ request = urllib.request.Request(
166
+ url, headers={"Authorization": f"Bearer {token}", "anthropic-beta": "oauth-2025-04-20",
167
+ "Accept": "application/json", "User-Agent": "latent-descent"})
168
+ started = time.time()
169
+ try:
170
+ with urllib.request.urlopen(request, timeout=timeout_s) as response:
171
+ raw = response.read()
172
+ recent_requests.append((started, "ok"))
173
+ except urllib.error.HTTPError as error:
174
+ recent_requests.append((started, f"http {error.code}"))
175
+ detail = error.read().decode("utf-8", "replace")
176
+ try:
177
+ detail = json.loads(detail)["error"]["message"]
178
+ except (ValueError, KeyError, TypeError):
179
+ detail = detail.strip()[:160]
180
+ if error.code == 401:
181
+ detail = "token rejected — open Claude Code to refresh its login"
182
+ if error.code == 429:
183
+ retry_after = None
184
+ try:
185
+ retry_after = float(error.headers.get("retry-after")) if error.headers else None
186
+ except (TypeError, ValueError):
187
+ retry_after = None
188
+ cadence = ", ".join(time.strftime("%H:%M:%S", time.localtime(when)) + f" {outcome}"
189
+ for when, outcome in recent_requests)
190
+ print(f"[claude_usage] 429 from {url} (retry-after={retry_after}); requests this process: {cadence}")
191
+ raise UsageRateLimited(f"usage endpoint rate limited: {detail}", retry_after) from None
192
+ raise RuntimeError(f"usage endpoint returned {error.code}: {detail}") from None
193
+ except urllib.error.URLError as error:
194
+ recent_requests.append((started, "unreachable"))
195
+ raise RuntimeError(f"usage endpoint unreachable: {error.reason}") from None
196
+ try:
197
+ payload = json.loads(raw.decode("utf-8"))
198
+ except ValueError:
199
+ raise RuntimeError("usage endpoint returned a non-JSON response") from None
200
+ if not isinstance(payload, dict):
201
+ raise RuntimeError("usage endpoint returned an unexpected payload")
202
+ return payload
203
+
204
+
205
+ # ──────────────────────────────────────────────────────────────────────────
206
+ # Normalisation
207
+ # ──────────────────────────────────────────────────────────────────────────
208
+
209
+ def parse_usage(payload: dict) -> list[dict]:
210
+ """Rows for the window: {"key", "label", "percent", "severity"
211
+ ("normal" | "warning" | "critical" | "exceeded" | …), "resets_at" (unix
212
+ s or None), "active", "detail"}. Limits first (the `limits` list, or
213
+ the legacy blocks), the spend row last."""
214
+ rows = []
215
+ limits = payload.get("limits")
216
+ if isinstance(limits, list) and limits:
217
+ for entry in limits:
218
+ if not isinstance(entry, dict):
219
+ continue
220
+ rows.append({"key": _limit_key(entry),
221
+ "label": _limit_label(entry),
222
+ "percent": _percent(entry.get("percent")),
223
+ "severity": entry.get("severity") or "normal",
224
+ "resets_at": parse_iso(entry.get("resets_at")),
225
+ "active": bool(entry.get("is_active")),
226
+ "detail": ""})
227
+ else:
228
+ for key, label in (("five_hour", "Session (5 h)"), ("seven_day", "Week · all models")):
229
+ block = payload.get(key)
230
+ if isinstance(block, dict):
231
+ rows.append(_legacy_row(key, label, block))
232
+ for key, block in payload.items():
233
+ if (key.startswith("seven_day_") and isinstance(block, dict)
234
+ and key not in ("seven_day",)):
235
+ rows.append(_legacy_row(key, "Week · " + key[len("seven_day_"):].replace("_", " ").title(), block))
236
+
237
+ spend = payload.get("spend")
238
+ if isinstance(spend, dict) and (spend.get("used") or spend.get("limit")):
239
+ used = _money(spend.get("used"))
240
+ limit = _money(spend.get("limit"))
241
+ detail = f"{used} of {limit}" if limit else used
242
+ if not spend.get("enabled"):
243
+ reason = (spend.get("disabled_reason") or "off").replace("_", " ")
244
+ detail += f" · off ({reason})"
245
+ rows.append({"key": "spend", "label": "Extra usage",
246
+ "percent": _percent(spend.get("percent")),
247
+ "severity": spend.get("severity") or "normal",
248
+ "resets_at": None, "active": bool(spend.get("enabled")), "detail": detail})
249
+ else:
250
+ extra = payload.get("extra_usage")
251
+ if isinstance(extra, dict) and extra.get("monthly_limit") is not None:
252
+ places = int(extra.get("decimal_places") or 2)
253
+ scale = 10 ** places
254
+ currency = extra.get("currency") or "USD"
255
+ used = _format_money(float(extra.get("used_credits") or 0) / scale, currency, places)
256
+ limit = _format_money(float(extra["monthly_limit"]) / scale, currency, places)
257
+ detail = f"{used} of {limit}"
258
+ if not extra.get("is_enabled"):
259
+ detail += f" · off ({(extra.get('disabled_reason') or 'off').replace('_', ' ')})"
260
+ rows.append({"key": "spend", "label": "Extra usage",
261
+ "percent": _percent(extra.get("utilization")),
262
+ "severity": "normal", "resets_at": None,
263
+ "active": bool(extra.get("is_enabled")), "detail": detail})
264
+ return rows
265
+
266
+
267
+ def summary(rows) -> str:
268
+ """"session 13% · week 46% · Fable 88%" — the account row's status text."""
269
+ parts = []
270
+ for row in rows:
271
+ if row["key"] == "spend":
272
+ continue
273
+ parts.append(f"{_short_label(row)} {row['percent']:.0f}%")
274
+ return " · ".join(parts)
275
+
276
+
277
+ def reset_text(resets_at, now=None) -> str:
278
+ """"resets in 3d 2h" / "2h 13m" / "14m" / "resetting…"; "" for None."""
279
+ if resets_at is None:
280
+ return ""
281
+ now = time.time() if now is None else now
282
+ remaining = int(resets_at - now)
283
+ if remaining <= 0:
284
+ return "resetting…"
285
+ days, rest = divmod(remaining, 86400)
286
+ hours, rest = divmod(rest, 3600)
287
+ minutes = rest // 60
288
+ if days:
289
+ return f"resets in {days}d {hours}h"
290
+ if hours:
291
+ return f"resets in {hours}h {minutes:02d}m"
292
+ return f"resets in {max(1, minutes)}m"
293
+
294
+
295
+ def parse_iso(text):
296
+ """ISO-8601 (with offset) → unix seconds, None when absent/unparseable."""
297
+ if not text:
298
+ return None
299
+ try:
300
+ parsed = datetime.fromisoformat(str(text).replace("Z", "+00:00"))
301
+ except ValueError:
302
+ return None
303
+ if parsed.tzinfo is None:
304
+ parsed = parsed.replace(tzinfo=timezone.utc)
305
+ return parsed.timestamp()
306
+
307
+
308
+ # -- helpers ---------------------------------------------------------------
309
+
310
+ def _percent(value) -> float:
311
+ try:
312
+ return max(0.0, float(value or 0.0))
313
+ except (TypeError, ValueError):
314
+ return 0.0
315
+
316
+
317
+ def _scope_name(entry) -> str:
318
+ scope = entry.get("scope") or {}
319
+ model = scope.get("model") or {}
320
+ return (model.get("display_name") or model.get("id") or scope.get("surface") or "").strip()
321
+
322
+
323
+ def _limit_key(entry) -> str:
324
+ kind = entry.get("kind") or "limit"
325
+ scope = _scope_name(entry)
326
+ return f"{kind}:{scope}" if scope else kind
327
+
328
+
329
+ def _limit_label(entry) -> str:
330
+ kind = entry.get("kind") or ""
331
+ scope = _scope_name(entry)
332
+ if kind == "session":
333
+ return "Session (5 h)"
334
+ if kind == "weekly_all":
335
+ return "Week · all models"
336
+ if kind.startswith("weekly"):
337
+ return f"Week · {scope}" if scope else "Week"
338
+ label = kind.replace("_", " ") or "limit"
339
+ return f"{label} · {scope}" if scope else label
340
+
341
+
342
+ def _short_label(row) -> str:
343
+ key = row["key"]
344
+ if key == "session":
345
+ return "session"
346
+ if key == "weekly_all":
347
+ return "week"
348
+ if ":" in key:
349
+ return key.split(":", 1)[1]
350
+ return row["label"].lower()
351
+
352
+
353
+ def _legacy_row(key, label, block) -> dict:
354
+ percent = _percent(block.get("utilization"))
355
+ severity = "exceeded" if percent >= 100 else "critical" if percent >= 90 else "warning" if percent >= 75 else "normal"
356
+ return {"key": key, "label": label, "percent": percent, "severity": severity,
357
+ "resets_at": parse_iso(block.get("resets_at")), "active": False, "detail": ""}
358
+
359
+
360
+ def _money(block) -> str:
361
+ if not isinstance(block, dict) or block.get("amount_minor") is None:
362
+ return ""
363
+ exponent = int(block.get("exponent") or 2)
364
+ return _format_money(float(block["amount_minor"]) / (10 ** exponent),
365
+ block.get("currency") or "USD", exponent)
366
+
367
+
368
+ def _format_money(amount: float, currency: str, places: int) -> str:
369
+ symbol = {"USD": "$", "EUR": "€", "GBP": "£"}.get(currency, f"{currency} ")
370
+ return f"{symbol}{amount:,.{places}f}"
371
+
372
+ # ──────────────────────────────────────────────────────────────────────────
373
+ # Switching Claude Code's login
374
+ # ──────────────────────────────────────────────────────────────────────────
375
+
376
+ def find_claude(explicit=""):
377
+ """The Claude Code executable: an explicit path, else PATH, else the
378
+ usual install spots (the studio's PATH may lack ~/.local/bin)."""
379
+ if explicit:
380
+ candidate = Path(explicit).expanduser()
381
+ return str(candidate) if candidate.is_file() else None
382
+ found = shutil.which("claude")
383
+ if found:
384
+ return found
385
+ for candidate in (Path.home() / ".local" / "bin" / "claude",
386
+ Path.home() / ".claude" / "local" / "claude",
387
+ Path("/usr/local/bin/claude")):
388
+ if candidate.is_file():
389
+ return str(candidate)
390
+ return None
391
+
392
+
393
+ def strip_terminal_codes(text: str) -> str:
394
+ """Drop OSC (hyperlinks) and CSI sequences from Claude Code's output."""
395
+ text = re.sub(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)", "", text)
396
+ return re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", text)
397
+
398
+
399
+ class ClaudeCodeLogin:
400
+ """`claude auth login --email <email>` driven from the studio (verified
401
+ without a TTY): Claude Code opens the browser itself with a LOOPBACK
402
+ redirect, so the login normally completes on its own; it also prints the
403
+ paste-a-code fallback URL and reads a code from stdin, which `url` /
404
+ `open_in_browser` / `submit_code` expose for a browser that can't reach
405
+ the callback. Exit 0 = Claude Code rewrote its credentials + identity
406
+ files (cached_login notices on the next draw). `config_dir` targets a
407
+ non-default CLAUDE_CONFIG_DIR (a row pointed at its own login file).
408
+ Spawned with close_fds=False (posix_spawn — never fork the studio)."""
409
+
410
+ def __init__(self, email, executable=None, config_dir=None, on_change=None):
411
+ self.email = email
412
+ self.executable = executable
413
+ self.config_dir = config_dir
414
+ self.on_change = on_change
415
+ self.url = None
416
+ self.output = []
417
+ self.done = False
418
+ self.ok = False
419
+ self.error = None
420
+ self._process = None
421
+
422
+ def start(self):
423
+ executable = self.executable or find_claude()
424
+ if not executable:
425
+ raise RuntimeError("claude (Claude Code) not found — install it or set Toggles.InternetAccounts.claude_code_bin")
426
+ from meltygui.completion.providers.anthropic_requests import notify_request
427
+ import meltygui.completion.providers.oauth_popup as oauth_popup
428
+ notify_request("claude auth login", f"Claude Code's own requests · {self.email}")
429
+ # Claude Code opens the browser itself (xdg-open) - the shim on its
430
+ # PATH turns that into the placed popup, then place_async parks it.
431
+ env = oauth_popup.shim_env(os.environ)
432
+ if self.config_dir:
433
+ env["CLAUDE_CONFIG_DIR"] = str(self.config_dir)
434
+ self._process = subprocess.Popen(
435
+ [executable, "auth", "login", "--email", self.email],
436
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
437
+ close_fds=False, env=env)
438
+ if oauth_popup.popup_available():
439
+ oauth_popup.place_async()
440
+ threading.Thread(target=self._pump, daemon=True, name="claude-auth-login").start()
441
+
442
+ def _pump(self):
443
+ process = self._process
444
+ try:
445
+ for raw in iter(process.stdout.readline, b""):
446
+ line = strip_terminal_codes(raw.decode("utf-8", "replace")).strip()
447
+ if line:
448
+ self.output.append(line)
449
+ if self.url is None:
450
+ match = re.search(r"https://[^\s]+?/oauth/authorize\?[^\s]+", line)
451
+ if match:
452
+ self.url = match.group(0)
453
+ self._notify()
454
+ except Exception as error:
455
+ self.output.append(f"output pump failed: {error}")
456
+ code = process.wait()
457
+ self.ok = code == 0
458
+ if not self.ok:
459
+ failures = [line for line in self.output if "fail" in line.lower() or "error" in line.lower()]
460
+ self.error = (failures[-1] if failures else (self.output[-1] if self.output else f"claude exited with {code}"))
461
+ self.error = self.error.replace("Paste code here if prompted >", "").strip() or f"claude exited with {code}"
462
+ self.done = True
463
+ import meltygui.completion.providers.oauth_popup as oauth_popup
464
+ if oauth_popup.popup_available():
465
+ oauth_popup.close_popups() # the shim's popup isn't our child - close by class
466
+ self._notify()
467
+
468
+ def submit_code(self, code: str):
469
+ """The paste-a-code fallback: what the Console page showed, into
470
+ Claude Code's stdin."""
471
+ if self._process is None or self._process.poll() is not None:
472
+ return False
473
+ try:
474
+ self._process.stdin.write((code.strip() + "\n").encode())
475
+ self._process.stdin.flush()
476
+ return True
477
+ except (OSError, ValueError):
478
+ return False
479
+
480
+ def open_in_browser(self) -> bool:
481
+ if not self.url:
482
+ return False
483
+ import meltygui.completion.providers.oauth_popup as oauth_popup
484
+ oauth_popup.open_auth_popup(self.url) # falls back to xdg-open otherwise
485
+ return True
486
+
487
+ def cancel(self):
488
+ if self._process is not None and self._process.poll() is None:
489
+ try:
490
+ self._process.terminate()
491
+ except OSError:
492
+ pass
493
+
494
+ def _notify(self):
495
+ if self.on_change is not None:
496
+ try:
497
+ self.on_change(self)
498
+ except Exception:
499
+ pass
@@ -0,0 +1,180 @@
1
+ """Codex account RPC over stdio. No model requests and no GUI dependencies.
2
+
3
+ The default row shares the native Codex home; additional rows are isolated.
4
+ Codex manages OAuth and token refresh; accounts.json never receives tokens.
5
+ """
6
+ from collections import deque
7
+ import json
8
+ import math
9
+ import os
10
+ from pathlib import Path
11
+ import queue
12
+ import shutil
13
+ import subprocess
14
+ import threading
15
+ import time
16
+ from urllib.parse import quote
17
+
18
+
19
+ class AppServer:
20
+ def __init__(self, home, executable="", timeout=30.0):
21
+ executable = shutil.which(executable or "codex")
22
+ if not executable:
23
+ raise RuntimeError("Codex not found — install Codex CLI or set the Codex executable")
24
+ home = Path(home).expanduser().resolve()
25
+ home.mkdir(parents=True, exist_ok=True, mode=0o700)
26
+ environment = dict(os.environ, CODEX_HOME=str(home))
27
+ # These can otherwise select an unrelated account in the child.
28
+ for key in ("OPENAI_API_KEY", "CODEX_API_KEY"):
29
+ environment.pop(key, None)
30
+ self.timeout = timeout
31
+ self.cancelled = threading.Event()
32
+ self.messages = queue.Queue()
33
+ self.notifications = deque(maxlen=64)
34
+ self.sequence = 0
35
+ command = [os.path.abspath(executable), "app-server"]
36
+ if home != native_home():
37
+ command += ["-c", 'cli_auth_credentials_store="file"']
38
+ self.process = subprocess.Popen(
39
+ command,
40
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
41
+ text=True, encoding="utf-8", bufsize=1, env=environment, close_fds=False)
42
+ self.reader = threading.Thread(target=self._read, daemon=True, name="codex-account-rpc")
43
+ self.reader.start()
44
+ try:
45
+ self.request("initialize", {"clientInfo": {
46
+ "name": "meltygui", "title": "Melty", "version": "0.1.0"}})
47
+ self._send({"method": "initialized", "params": {}})
48
+ except Exception:
49
+ self.close()
50
+ raise
51
+
52
+ def _read(self):
53
+ try:
54
+ for line in self.process.stdout:
55
+ self.messages.put(json.loads(line))
56
+ except (ValueError, OSError):
57
+ pass
58
+ finally:
59
+ self.messages.put(None)
60
+
61
+ def _send(self, message):
62
+ self.process.stdin.write(json.dumps(message) + "\n")
63
+ self.process.stdin.flush()
64
+
65
+ def _receive(self, deadline):
66
+ remaining = deadline - time.monotonic()
67
+ if remaining <= 0:
68
+ raise TimeoutError("Codex account request timed out")
69
+ try:
70
+ message = self.messages.get(timeout=min(remaining, 0.2))
71
+ except queue.Empty:
72
+ if self.process.poll() is not None:
73
+ raise RuntimeError("Codex app-server stopped; try Refresh")
74
+ return {}
75
+ if message is None:
76
+ raise RuntimeError("Codex app-server disconnected; try Refresh")
77
+ # No tools are exposed by this account-only client.
78
+ if "method" in message and "id" in message:
79
+ self._send({"id": message["id"], "error": {
80
+ "code": -32601, "message": "Unsupported by Melty accounts"}})
81
+ return {}
82
+ return message
83
+
84
+ def request(self, method, params=None):
85
+ """Single worker per client; notifications may precede the reply."""
86
+ self.sequence += 1
87
+ identifier = self.sequence
88
+ self._send({"method": method, "id": identifier, "params": params or {}})
89
+ deadline = time.monotonic() + self.timeout
90
+ while True:
91
+ message = self._receive(deadline)
92
+ if message.get("id") == identifier:
93
+ if "error" in message:
94
+ raise RuntimeError(message["error"].get("message", "Codex request failed"))
95
+ return message.get("result") or {}
96
+ if message.get("method"):
97
+ self.notifications.append(message)
98
+
99
+ def wait_login(self, login_id, timeout):
100
+ deadline = time.monotonic() + timeout
101
+ try:
102
+ while not self.cancelled.is_set():
103
+ message = (self.notifications.popleft() if self.notifications
104
+ else self._receive(deadline))
105
+ params = message.get("params") or {}
106
+ if (message.get("method") == "account/login/completed"
107
+ and params.get("loginId") == login_id):
108
+ if not params.get("success"):
109
+ raise RuntimeError(params.get("error") or "Codex sign-in failed")
110
+ return True
111
+ return False
112
+ finally:
113
+ if self.cancelled.is_set() or time.monotonic() >= deadline:
114
+ self.request("account/login/cancel", {"loginId": login_id})
115
+
116
+ def close(self):
117
+ if self.process.poll() is None:
118
+ self.process.terminate()
119
+ try:
120
+ self.process.wait(timeout=1)
121
+ except subprocess.TimeoutExpired:
122
+ self.process.kill()
123
+ self.process.wait(timeout=1)
124
+ self.reader.join(timeout=1)
125
+ self.process.stdin.close()
126
+ self.process.stdout.close()
127
+
128
+ def __enter__(self):
129
+ return self
130
+
131
+ def __exit__(self, *_):
132
+ self.close()
133
+
134
+
135
+ def native_home():
136
+ return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser().resolve()
137
+
138
+
139
+ def account_home(account_id):
140
+ if account_id == "codex":
141
+ return native_home()
142
+ return Path.home() / ".lsd" / "codex" / ("account-" + quote(account_id, safe=""))
143
+
144
+
145
+ def usage_rows(payload):
146
+ """Adapt all returned limit buckets to the Internet Accounts bar schema.
147
+
148
+ Missing windows/percentages mean unavailable, never zero usage.
149
+ """
150
+ buckets = payload.get("rateLimitsByLimitId")
151
+ if not isinstance(buckets, dict) or not buckets:
152
+ legacy = payload.get("rateLimits")
153
+ buckets = {legacy.get("limitId") or "codex": legacy} if legacy else {}
154
+ rows = []
155
+ for bucket_id, bucket in buckets.items():
156
+ if not isinstance(bucket, dict):
157
+ continue
158
+ for slot in ("primary", "secondary"):
159
+ window = bucket.get(slot)
160
+ if not isinstance(window, dict):
161
+ continue
162
+ percent = window.get("usedPercent")
163
+ if not isinstance(percent, (int, float)) or not math.isfinite(percent):
164
+ continue
165
+ percent = max(0.0, min(100.0, percent))
166
+ minutes = window.get("windowDurationMins")
167
+ label = slot.title()
168
+ if isinstance(minutes, (int, float)) and minutes > 0:
169
+ label = (f"{minutes / 1440:g}d" if minutes % 1440 == 0 else
170
+ f"{minutes / 60:g}h" if minutes % 60 == 0 else f"{minutes:g}m")
171
+ if len(buckets) > 1 or bucket_id != "codex":
172
+ label = f"{bucket.get('limitName') or bucket_id} · {label}"
173
+ severity = ("exceeded" if percent >= 100 else "critical" if percent >= 90
174
+ else "warning" if percent >= 75 else "normal")
175
+ resets = window.get("resetsAt")
176
+ rows.append({"key": f"{bucket_id}:{slot}", "label": label,
177
+ "percent": percent, "severity": severity,
178
+ "resets_at": resets if isinstance(resets, (int, float)) else None,
179
+ "active": True, "detail": ""})
180
+ return rows