code-puppy 0.0.214__py3-none-any.whl → 0.0.366__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 (231) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +2 -0
  3. code_puppy/agents/agent_c_reviewer.py +59 -6
  4. code_puppy/agents/agent_code_puppy.py +7 -1
  5. code_puppy/agents/agent_code_reviewer.py +12 -2
  6. code_puppy/agents/agent_cpp_reviewer.py +73 -6
  7. code_puppy/agents/agent_creator_agent.py +45 -4
  8. code_puppy/agents/agent_golang_reviewer.py +92 -3
  9. code_puppy/agents/agent_javascript_reviewer.py +101 -8
  10. code_puppy/agents/agent_manager.py +81 -4
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +28 -6
  15. code_puppy/agents/agent_qa_expert.py +98 -6
  16. code_puppy/agents/agent_qa_kitten.py +12 -7
  17. code_puppy/agents/agent_security_auditor.py +113 -3
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +106 -7
  20. code_puppy/agents/base_agent.py +802 -176
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/pack/__init__.py +34 -0
  23. code_puppy/agents/pack/bloodhound.py +304 -0
  24. code_puppy/agents/pack/husky.py +321 -0
  25. code_puppy/agents/pack/retriever.py +393 -0
  26. code_puppy/agents/pack/shepherd.py +348 -0
  27. code_puppy/agents/pack/terrier.py +287 -0
  28. code_puppy/agents/pack/watchdog.py +367 -0
  29. code_puppy/agents/prompt_reviewer.py +145 -0
  30. code_puppy/agents/subagent_stream_handler.py +276 -0
  31. code_puppy/api/__init__.py +13 -0
  32. code_puppy/api/app.py +169 -0
  33. code_puppy/api/main.py +21 -0
  34. code_puppy/api/pty_manager.py +446 -0
  35. code_puppy/api/routers/__init__.py +12 -0
  36. code_puppy/api/routers/agents.py +36 -0
  37. code_puppy/api/routers/commands.py +217 -0
  38. code_puppy/api/routers/config.py +74 -0
  39. code_puppy/api/routers/sessions.py +232 -0
  40. code_puppy/api/templates/terminal.html +361 -0
  41. code_puppy/api/websocket.py +154 -0
  42. code_puppy/callbacks.py +142 -4
  43. code_puppy/chatgpt_codex_client.py +283 -0
  44. code_puppy/claude_cache_client.py +586 -0
  45. code_puppy/cli_runner.py +916 -0
  46. code_puppy/command_line/add_model_menu.py +1079 -0
  47. code_puppy/command_line/agent_menu.py +395 -0
  48. code_puppy/command_line/attachments.py +10 -5
  49. code_puppy/command_line/autosave_menu.py +605 -0
  50. code_puppy/command_line/clipboard.py +527 -0
  51. code_puppy/command_line/colors_menu.py +520 -0
  52. code_puppy/command_line/command_handler.py +176 -738
  53. code_puppy/command_line/command_registry.py +150 -0
  54. code_puppy/command_line/config_commands.py +715 -0
  55. code_puppy/command_line/core_commands.py +792 -0
  56. code_puppy/command_line/diff_menu.py +863 -0
  57. code_puppy/command_line/load_context_completion.py +15 -22
  58. code_puppy/command_line/mcp/base.py +0 -3
  59. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  60. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  61. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  62. code_puppy/command_line/mcp/edit_command.py +148 -0
  63. code_puppy/command_line/mcp/handler.py +9 -4
  64. code_puppy/command_line/mcp/help_command.py +6 -5
  65. code_puppy/command_line/mcp/install_command.py +15 -26
  66. code_puppy/command_line/mcp/install_menu.py +685 -0
  67. code_puppy/command_line/mcp/list_command.py +2 -2
  68. code_puppy/command_line/mcp/logs_command.py +174 -65
  69. code_puppy/command_line/mcp/remove_command.py +2 -2
  70. code_puppy/command_line/mcp/restart_command.py +12 -4
  71. code_puppy/command_line/mcp/search_command.py +16 -10
  72. code_puppy/command_line/mcp/start_all_command.py +18 -6
  73. code_puppy/command_line/mcp/start_command.py +47 -25
  74. code_puppy/command_line/mcp/status_command.py +4 -5
  75. code_puppy/command_line/mcp/stop_all_command.py +7 -1
  76. code_puppy/command_line/mcp/stop_command.py +8 -4
  77. code_puppy/command_line/mcp/test_command.py +2 -2
  78. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  79. code_puppy/command_line/mcp_completion.py +174 -0
  80. code_puppy/command_line/model_picker_completion.py +75 -25
  81. code_puppy/command_line/model_settings_menu.py +884 -0
  82. code_puppy/command_line/motd.py +14 -8
  83. code_puppy/command_line/onboarding_slides.py +179 -0
  84. code_puppy/command_line/onboarding_wizard.py +340 -0
  85. code_puppy/command_line/pin_command_completion.py +329 -0
  86. code_puppy/command_line/prompt_toolkit_completion.py +463 -63
  87. code_puppy/command_line/session_commands.py +296 -0
  88. code_puppy/command_line/utils.py +54 -0
  89. code_puppy/config.py +898 -112
  90. code_puppy/error_logging.py +118 -0
  91. code_puppy/gemini_code_assist.py +385 -0
  92. code_puppy/gemini_model.py +602 -0
  93. code_puppy/http_utils.py +210 -148
  94. code_puppy/keymap.py +128 -0
  95. code_puppy/main.py +5 -698
  96. code_puppy/mcp_/__init__.py +17 -0
  97. code_puppy/mcp_/async_lifecycle.py +35 -4
  98. code_puppy/mcp_/blocking_startup.py +70 -43
  99. code_puppy/mcp_/captured_stdio_server.py +2 -2
  100. code_puppy/mcp_/config_wizard.py +4 -4
  101. code_puppy/mcp_/dashboard.py +15 -6
  102. code_puppy/mcp_/managed_server.py +65 -38
  103. code_puppy/mcp_/manager.py +146 -52
  104. code_puppy/mcp_/mcp_logs.py +224 -0
  105. code_puppy/mcp_/registry.py +6 -6
  106. code_puppy/mcp_/server_registry_catalog.py +24 -5
  107. code_puppy/messaging/__init__.py +199 -2
  108. code_puppy/messaging/bus.py +610 -0
  109. code_puppy/messaging/commands.py +167 -0
  110. code_puppy/messaging/markdown_patches.py +57 -0
  111. code_puppy/messaging/message_queue.py +17 -48
  112. code_puppy/messaging/messages.py +500 -0
  113. code_puppy/messaging/queue_console.py +1 -24
  114. code_puppy/messaging/renderers.py +43 -146
  115. code_puppy/messaging/rich_renderer.py +1027 -0
  116. code_puppy/messaging/spinner/__init__.py +21 -5
  117. code_puppy/messaging/spinner/console_spinner.py +86 -51
  118. code_puppy/messaging/subagent_console.py +461 -0
  119. code_puppy/model_factory.py +634 -83
  120. code_puppy/model_utils.py +167 -0
  121. code_puppy/models.json +66 -68
  122. code_puppy/models_dev_api.json +1 -0
  123. code_puppy/models_dev_parser.py +592 -0
  124. code_puppy/plugins/__init__.py +164 -10
  125. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  126. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  127. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  128. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  129. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  130. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  131. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  132. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  133. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  134. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  135. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  136. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  137. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  138. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  139. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  140. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  141. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  142. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  143. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  144. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  145. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  146. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  147. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  148. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  149. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  150. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  151. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  152. code_puppy/plugins/example_custom_command/README.md +280 -0
  153. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  154. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  155. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  156. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  157. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  158. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  159. code_puppy/plugins/oauth_puppy_html.py +228 -0
  160. code_puppy/plugins/shell_safety/__init__.py +6 -0
  161. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  162. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  163. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  164. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  165. code_puppy/prompts/codex_system_prompt.md +310 -0
  166. code_puppy/pydantic_patches.py +131 -0
  167. code_puppy/reopenable_async_client.py +8 -8
  168. code_puppy/round_robin_model.py +9 -12
  169. code_puppy/session_storage.py +2 -1
  170. code_puppy/status_display.py +21 -4
  171. code_puppy/summarization_agent.py +41 -13
  172. code_puppy/terminal_utils.py +418 -0
  173. code_puppy/tools/__init__.py +37 -1
  174. code_puppy/tools/agent_tools.py +536 -52
  175. code_puppy/tools/browser/__init__.py +37 -0
  176. code_puppy/tools/browser/browser_control.py +19 -23
  177. code_puppy/tools/browser/browser_interactions.py +41 -48
  178. code_puppy/tools/browser/browser_locators.py +36 -38
  179. code_puppy/tools/browser/browser_manager.py +316 -0
  180. code_puppy/tools/browser/browser_navigation.py +16 -16
  181. code_puppy/tools/browser/browser_screenshot.py +79 -143
  182. code_puppy/tools/browser/browser_scripts.py +32 -42
  183. code_puppy/tools/browser/browser_workflows.py +44 -27
  184. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  185. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  186. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  187. code_puppy/tools/browser/terminal_tools.py +525 -0
  188. code_puppy/tools/command_runner.py +930 -147
  189. code_puppy/tools/common.py +1113 -5
  190. code_puppy/tools/display.py +84 -0
  191. code_puppy/tools/file_modifications.py +288 -89
  192. code_puppy/tools/file_operations.py +226 -154
  193. code_puppy/tools/subagent_context.py +158 -0
  194. code_puppy/uvx_detection.py +242 -0
  195. code_puppy/version_checker.py +30 -11
  196. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  197. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  198. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
  199. code_puppy-0.0.366.dist-info/RECORD +217 -0
  200. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  201. code_puppy/command_line/mcp/add_command.py +0 -183
  202. code_puppy/messaging/spinner/textual_spinner.py +0 -106
  203. code_puppy/tools/browser/camoufox_manager.py +0 -216
  204. code_puppy/tools/browser/vqa_agent.py +0 -70
  205. code_puppy/tui/__init__.py +0 -10
  206. code_puppy/tui/app.py +0 -1105
  207. code_puppy/tui/components/__init__.py +0 -21
  208. code_puppy/tui/components/chat_view.py +0 -551
  209. code_puppy/tui/components/command_history_modal.py +0 -218
  210. code_puppy/tui/components/copy_button.py +0 -139
  211. code_puppy/tui/components/custom_widgets.py +0 -63
  212. code_puppy/tui/components/human_input_modal.py +0 -175
  213. code_puppy/tui/components/input_area.py +0 -167
  214. code_puppy/tui/components/sidebar.py +0 -309
  215. code_puppy/tui/components/status_bar.py +0 -185
  216. code_puppy/tui/messages.py +0 -27
  217. code_puppy/tui/models/__init__.py +0 -8
  218. code_puppy/tui/models/chat_message.py +0 -25
  219. code_puppy/tui/models/command_history.py +0 -89
  220. code_puppy/tui/models/enums.py +0 -24
  221. code_puppy/tui/screens/__init__.py +0 -17
  222. code_puppy/tui/screens/autosave_picker.py +0 -175
  223. code_puppy/tui/screens/help.py +0 -130
  224. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  225. code_puppy/tui/screens/settings.py +0 -306
  226. code_puppy/tui/screens/tools.py +0 -74
  227. code_puppy/tui_state.py +0 -55
  228. code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
  229. code_puppy-0.0.214.dist-info/RECORD +0 -131
  230. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
  231. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -1,32 +1,186 @@
1
1
  import importlib
2
+ import importlib.util
2
3
  import logging
4
+ import sys
3
5
  from pathlib import Path
4
6
 
5
7
  logger = logging.getLogger(__name__)
6
8
 
9
+ # User plugins directory
10
+ USER_PLUGINS_DIR = Path.home() / ".code_puppy" / "plugins"
7
11
 
8
- def load_plugin_callbacks():
9
- """Dynamically load register_callbacks.py from all plugin submodules."""
10
- plugins_dir = Path(__file__).parent
12
+ # Track if plugins have already been loaded to prevent duplicate registration
13
+ _PLUGINS_LOADED = False
14
+
15
+
16
+ def _load_builtin_plugins(plugins_dir: Path) -> list[str]:
17
+ """Load built-in plugins from the package plugins directory.
18
+
19
+ Returns list of successfully loaded plugin names.
20
+ """
21
+ # Import safety permission check for shell_safety plugin
22
+ from code_puppy.config import get_safety_permission_level
23
+
24
+ loaded = []
11
25
 
12
- # Iterate through all subdirectories in the plugins folder
13
26
  for item in plugins_dir.iterdir():
14
27
  if item.is_dir() and not item.name.startswith("_"):
15
28
  plugin_name = item.name
16
29
  callbacks_file = item / "register_callbacks.py"
17
30
 
18
31
  if callbacks_file.exists():
32
+ # Skip shell_safety plugin unless safety_permission_level is "low" or "none"
33
+ if plugin_name == "shell_safety":
34
+ safety_level = get_safety_permission_level()
35
+ if safety_level not in ("none", "low"):
36
+ logger.debug(
37
+ f"Skipping shell_safety plugin - safety_permission_level is '{safety_level}' (needs 'low' or 'none')"
38
+ )
39
+ continue
40
+
19
41
  try:
20
- # Import the register_callbacks module dynamically
21
42
  module_name = f"code_puppy.plugins.{plugin_name}.register_callbacks"
22
- logger.debug(f"Loading plugin callbacks from {module_name}")
23
43
  importlib.import_module(module_name)
24
- logger.info(
25
- f"Successfully loaded callbacks from plugin: {plugin_name}"
44
+ loaded.append(plugin_name)
45
+ except ImportError as e:
46
+ logger.warning(
47
+ f"Failed to import callbacks from built-in plugin {plugin_name}: {e}"
48
+ )
49
+ except Exception as e:
50
+ logger.error(
51
+ f"Unexpected error loading built-in plugin {plugin_name}: {e}"
52
+ )
53
+
54
+ return loaded
55
+
56
+
57
+ def _load_user_plugins(user_plugins_dir: Path) -> list[str]:
58
+ """Load user plugins from ~/.code_puppy/plugins/.
59
+
60
+ Each plugin should be a directory containing a register_callbacks.py file.
61
+ Plugins are loaded by adding their parent to sys.path and importing them.
62
+
63
+ Returns list of successfully loaded plugin names.
64
+ """
65
+ loaded = []
66
+
67
+ if not user_plugins_dir.exists():
68
+ return loaded
69
+
70
+ if not user_plugins_dir.is_dir():
71
+ logger.warning(f"User plugins path is not a directory: {user_plugins_dir}")
72
+ return loaded
73
+
74
+ # Add user plugins directory to sys.path if not already there
75
+ user_plugins_str = str(user_plugins_dir)
76
+ if user_plugins_str not in sys.path:
77
+ sys.path.insert(0, user_plugins_str)
78
+
79
+ for item in user_plugins_dir.iterdir():
80
+ if (
81
+ item.is_dir()
82
+ and not item.name.startswith("_")
83
+ and not item.name.startswith(".")
84
+ ):
85
+ plugin_name = item.name
86
+ callbacks_file = item / "register_callbacks.py"
87
+
88
+ if callbacks_file.exists():
89
+ try:
90
+ # Load the plugin module directly from the file
91
+ module_name = f"{plugin_name}.register_callbacks"
92
+ spec = importlib.util.spec_from_file_location(
93
+ module_name, callbacks_file
26
94
  )
95
+ if spec is None or spec.loader is None:
96
+ logger.warning(
97
+ f"Could not create module spec for user plugin: {plugin_name}"
98
+ )
99
+ continue
100
+
101
+ module = importlib.util.module_from_spec(spec)
102
+ sys.modules[module_name] = module
103
+
104
+ spec.loader.exec_module(module)
105
+ loaded.append(plugin_name)
106
+
27
107
  except ImportError as e:
28
108
  logger.warning(
29
- f"Failed to import callbacks from plugin {plugin_name}: {e}"
109
+ f"Failed to import callbacks from user plugin {plugin_name}: {e}"
30
110
  )
31
111
  except Exception as e:
32
- logger.error(f"Unexpected error loading plugin {plugin_name}: {e}")
112
+ logger.error(
113
+ f"Unexpected error loading user plugin {plugin_name}: {e}",
114
+ exc_info=True,
115
+ )
116
+ else:
117
+ # Check if there's an __init__.py - might be a simple plugin
118
+ init_file = item / "__init__.py"
119
+ if init_file.exists():
120
+ try:
121
+ module_name = plugin_name
122
+ spec = importlib.util.spec_from_file_location(
123
+ module_name, init_file
124
+ )
125
+ if spec is None or spec.loader is None:
126
+ continue
127
+
128
+ module = importlib.util.module_from_spec(spec)
129
+ sys.modules[module_name] = module
130
+ spec.loader.exec_module(module)
131
+ loaded.append(plugin_name)
132
+
133
+ except Exception as e:
134
+ logger.error(
135
+ f"Unexpected error loading user plugin {plugin_name}: {e}",
136
+ exc_info=True,
137
+ )
138
+
139
+ return loaded
140
+
141
+
142
+ def load_plugin_callbacks() -> dict[str, list[str]]:
143
+ """Dynamically load register_callbacks.py from all plugin sources.
144
+
145
+ Loads plugins from:
146
+ 1. Built-in plugins in the code_puppy/plugins/ directory
147
+ 2. User plugins in ~/.code_puppy/plugins/
148
+
149
+ Returns dict with 'builtin' and 'user' keys containing lists of loaded plugin names.
150
+
151
+ NOTE: This function is idempotent - calling it multiple times will only
152
+ load plugins once. Subsequent calls return empty lists.
153
+ """
154
+ global _PLUGINS_LOADED
155
+
156
+ # Prevent duplicate loading - plugins register callbacks at import time,
157
+ # so re-importing would cause duplicate registrations
158
+ if _PLUGINS_LOADED:
159
+ logger.debug("Plugins already loaded, skipping duplicate load")
160
+ return {"builtin": [], "user": []}
161
+
162
+ plugins_dir = Path(__file__).parent
163
+
164
+ result = {
165
+ "builtin": _load_builtin_plugins(plugins_dir),
166
+ "user": _load_user_plugins(USER_PLUGINS_DIR),
167
+ }
168
+
169
+ _PLUGINS_LOADED = True
170
+ logger.debug(f"Loaded plugins: builtin={result['builtin']}, user={result['user']}")
171
+
172
+ return result
173
+
174
+
175
+ def get_user_plugins_dir() -> Path:
176
+ """Return the path to the user plugins directory."""
177
+ return USER_PLUGINS_DIR
178
+
179
+
180
+ def ensure_user_plugins_dir() -> Path:
181
+ """Create the user plugins directory if it doesn't exist.
182
+
183
+ Returns the path to the directory.
184
+ """
185
+ USER_PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
186
+ return USER_PLUGINS_DIR
@@ -0,0 +1,10 @@
1
+ """Antigravity OAuth Plugin for Code Puppy.
2
+
3
+ Enables authentication with Google/Antigravity APIs to access Gemini and Claude models
4
+ via Google credentials. Supports multi-account load balancing and automatic failover.
5
+ """
6
+
7
+ from .config import ANTIGRAVITY_OAUTH_CONFIG
8
+ from .register_callbacks import * # noqa: F401, F403
9
+
10
+ __all__ = ["ANTIGRAVITY_OAUTH_CONFIG"]
@@ -0,0 +1,406 @@
1
+ """Multi-account manager for Antigravity OAuth with load balancing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Dict, List, Literal, Optional
9
+
10
+ from .storage import (
11
+ AccountMetadata,
12
+ AccountStorage,
13
+ HeaderStyle,
14
+ ModelFamily,
15
+ QuotaKey,
16
+ RateLimitState,
17
+ load_accounts,
18
+ save_accounts,
19
+ )
20
+ from .token import RefreshParts, parse_refresh_parts
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class ManagedAccount:
27
+ """In-memory representation of a managed account."""
28
+
29
+ index: int
30
+ email: Optional[str]
31
+ added_at: float
32
+ last_used: float
33
+ parts: RefreshParts
34
+ access_token: Optional[str] = None
35
+ expires_at: Optional[float] = None
36
+ rate_limit_reset_times: Dict[str, float] = field(default_factory=dict)
37
+ last_switch_reason: Optional[Literal["rate-limit", "initial", "rotation"]] = None
38
+
39
+
40
+ def _now_ms() -> float:
41
+ """Current time in milliseconds."""
42
+ return time.time() * 1000
43
+
44
+
45
+ def _get_quota_key(family: ModelFamily, header_style: HeaderStyle) -> QuotaKey:
46
+ """Get the quota key for a model family and header style."""
47
+ if family == "claude":
48
+ return "claude"
49
+ return "gemini-cli" if header_style == "gemini-cli" else "gemini-antigravity"
50
+
51
+
52
+ def _is_rate_limited_for_quota_key(account: ManagedAccount, key: QuotaKey) -> bool:
53
+ """Check if account is rate limited for a specific quota key."""
54
+ reset_time = account.rate_limit_reset_times.get(key)
55
+ return reset_time is not None and _now_ms() < reset_time
56
+
57
+
58
+ def _is_rate_limited_for_family(account: ManagedAccount, family: ModelFamily) -> bool:
59
+ """Check if account is rate limited for an entire model family."""
60
+ if family == "claude":
61
+ return _is_rate_limited_for_quota_key(account, "claude")
62
+ # For Gemini, both pools must be rate limited
63
+ return _is_rate_limited_for_quota_key(
64
+ account, "gemini-antigravity"
65
+ ) and _is_rate_limited_for_quota_key(account, "gemini-cli")
66
+
67
+
68
+ def _clear_expired_rate_limits(account: ManagedAccount) -> None:
69
+ """Clear expired rate limits from an account."""
70
+ now = _now_ms()
71
+ keys_to_remove = [
72
+ key
73
+ for key, reset_time in account.rate_limit_reset_times.items()
74
+ if now >= reset_time
75
+ ]
76
+ for key in keys_to_remove:
77
+ del account.rate_limit_reset_times[key]
78
+
79
+
80
+ class AccountManager:
81
+ """Multi-account manager with sticky account selection and load balancing.
82
+
83
+ Uses the same account until it hits a rate limit (429), then switches.
84
+ Rate limits are tracked per-model-family (claude/gemini) so an account
85
+ rate-limited for Claude can still be used for Gemini.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ initial_refresh_token: Optional[str] = None,
91
+ stored: Optional[AccountStorage] = None,
92
+ ):
93
+ self._accounts: List[ManagedAccount] = []
94
+ self._cursor = 0
95
+ self._current_index_by_family: Dict[ModelFamily, int] = {
96
+ "claude": -1,
97
+ "gemini": -1,
98
+ }
99
+ self._last_toast_index = -1
100
+ self._last_toast_time = 0.0
101
+
102
+ initial_parts = parse_refresh_parts(initial_refresh_token or "")
103
+
104
+ if stored and not stored.accounts:
105
+ return
106
+
107
+ if stored and stored.accounts:
108
+ now = _now_ms()
109
+ for i, acc in enumerate(stored.accounts):
110
+ if not acc.refresh_token:
111
+ continue
112
+
113
+ parts = RefreshParts(
114
+ refresh_token=acc.refresh_token,
115
+ project_id=acc.project_id,
116
+ managed_project_id=acc.managed_project_id,
117
+ )
118
+
119
+ # Convert rate limits from storage
120
+ rate_limits: Dict[str, float] = {}
121
+ if acc.rate_limit_reset_times.claude:
122
+ rate_limits["claude"] = acc.rate_limit_reset_times.claude
123
+ if acc.rate_limit_reset_times.gemini_antigravity:
124
+ rate_limits["gemini-antigravity"] = (
125
+ acc.rate_limit_reset_times.gemini_antigravity
126
+ )
127
+ if acc.rate_limit_reset_times.gemini_cli:
128
+ rate_limits["gemini-cli"] = acc.rate_limit_reset_times.gemini_cli
129
+
130
+ self._accounts.append(
131
+ ManagedAccount(
132
+ index=i,
133
+ email=acc.email,
134
+ added_at=acc.added_at or now,
135
+ last_used=acc.last_used or 0,
136
+ parts=parts,
137
+ access_token=None, # Tokens loaded separately
138
+ expires_at=None,
139
+ rate_limit_reset_times=rate_limits,
140
+ last_switch_reason=acc.last_switch_reason,
141
+ )
142
+ )
143
+
144
+ if self._accounts:
145
+ self._cursor = max(0, min(stored.active_index, len(self._accounts) - 1))
146
+ default_idx = self._cursor
147
+ self._current_index_by_family["claude"] = (
148
+ stored.active_index_by_family.get("claude", default_idx)
149
+ % len(self._accounts)
150
+ )
151
+ self._current_index_by_family["gemini"] = (
152
+ stored.active_index_by_family.get("gemini", default_idx)
153
+ % len(self._accounts)
154
+ )
155
+ return
156
+
157
+ # Fallback: create single account from initial token
158
+ if initial_parts.refresh_token:
159
+ now = _now_ms()
160
+ self._accounts.append(
161
+ ManagedAccount(
162
+ index=0,
163
+ email=None,
164
+ added_at=now,
165
+ last_used=0,
166
+ parts=initial_parts,
167
+ rate_limit_reset_times={},
168
+ )
169
+ )
170
+ self._current_index_by_family["claude"] = 0
171
+ self._current_index_by_family["gemini"] = 0
172
+
173
+ @classmethod
174
+ def load_from_disk(
175
+ cls, initial_refresh_token: Optional[str] = None
176
+ ) -> "AccountManager":
177
+ """Load account manager from disk."""
178
+ stored = load_accounts()
179
+ return cls(initial_refresh_token, stored)
180
+
181
+ @property
182
+ def account_count(self) -> int:
183
+ """Number of accounts in the pool."""
184
+ return len(self._accounts)
185
+
186
+ def get_accounts_snapshot(self) -> List[ManagedAccount]:
187
+ """Get a snapshot of all accounts."""
188
+ return list(self._accounts)
189
+
190
+ def get_current_account_for_family(
191
+ self,
192
+ family: ModelFamily,
193
+ ) -> Optional[ManagedAccount]:
194
+ """Get the current active account for a model family."""
195
+ idx = self._current_index_by_family.get(family, -1)
196
+ if 0 <= idx < len(self._accounts):
197
+ return self._accounts[idx]
198
+ return None
199
+
200
+ def get_current_or_next_for_family(
201
+ self,
202
+ family: ModelFamily,
203
+ ) -> Optional[ManagedAccount]:
204
+ """Get current account if not rate limited, otherwise find next available."""
205
+ current = self.get_current_account_for_family(family)
206
+
207
+ if current:
208
+ _clear_expired_rate_limits(current)
209
+ if not _is_rate_limited_for_family(current, family):
210
+ current.last_used = _now_ms()
211
+ return current
212
+
213
+ # Find next available account
214
+ next_account = self._get_next_for_family(family)
215
+ if next_account:
216
+ self._current_index_by_family[family] = next_account.index
217
+ return next_account
218
+
219
+ def _get_next_for_family(self, family: ModelFamily) -> Optional[ManagedAccount]:
220
+ """Get next available account for a model family."""
221
+ available = []
222
+ for acc in self._accounts:
223
+ _clear_expired_rate_limits(acc)
224
+ if not _is_rate_limited_for_family(acc, family):
225
+ available.append(acc)
226
+
227
+ if not available:
228
+ return None
229
+
230
+ account = available[self._cursor % len(available)]
231
+ self._cursor += 1
232
+ account.last_used = _now_ms()
233
+ return account
234
+
235
+ def mark_rate_limited(
236
+ self,
237
+ account: ManagedAccount,
238
+ retry_after_ms: float,
239
+ family: ModelFamily,
240
+ header_style: HeaderStyle = "antigravity",
241
+ ) -> None:
242
+ """Mark an account as rate limited."""
243
+ key = _get_quota_key(family, header_style)
244
+ account.rate_limit_reset_times[key] = _now_ms() + retry_after_ms
245
+
246
+ def is_rate_limited_for_header_style(
247
+ self,
248
+ account: ManagedAccount,
249
+ family: ModelFamily,
250
+ header_style: HeaderStyle,
251
+ ) -> bool:
252
+ """Check if account is rate limited for a specific header style."""
253
+ _clear_expired_rate_limits(account)
254
+ key = _get_quota_key(family, header_style)
255
+ return _is_rate_limited_for_quota_key(account, key)
256
+
257
+ def get_available_header_style(
258
+ self,
259
+ account: ManagedAccount,
260
+ family: ModelFamily,
261
+ ) -> Optional[HeaderStyle]:
262
+ """Get an available header style for the account, or None if all limited."""
263
+ _clear_expired_rate_limits(account)
264
+
265
+ if family == "claude":
266
+ if not _is_rate_limited_for_quota_key(account, "claude"):
267
+ return "antigravity"
268
+ return None
269
+
270
+ # For Gemini, try Antigravity first, then Gemini CLI
271
+ if not _is_rate_limited_for_quota_key(account, "gemini-antigravity"):
272
+ return "antigravity"
273
+ if not _is_rate_limited_for_quota_key(account, "gemini-cli"):
274
+ return "gemini-cli"
275
+ return None
276
+
277
+ def get_min_wait_time_for_family(self, family: ModelFamily) -> float:
278
+ """Get minimum wait time until an account becomes available (in ms)."""
279
+ # Check if any account is already available
280
+ for acc in self._accounts:
281
+ _clear_expired_rate_limits(acc)
282
+ if not _is_rate_limited_for_family(acc, family):
283
+ return 0
284
+
285
+ # Calculate minimum wait time
286
+ wait_times: List[float] = []
287
+ now = _now_ms()
288
+
289
+ for acc in self._accounts:
290
+ if family == "claude":
291
+ reset = acc.rate_limit_reset_times.get("claude")
292
+ if reset is not None:
293
+ wait_times.append(max(0, reset - now))
294
+ else:
295
+ # For Gemini, account available when EITHER pool expires
296
+ ag_reset = acc.rate_limit_reset_times.get("gemini-antigravity")
297
+ cli_reset = acc.rate_limit_reset_times.get("gemini-cli")
298
+
299
+ ag_wait = max(0, ag_reset - now) if ag_reset else float("inf")
300
+ cli_wait = max(0, cli_reset - now) if cli_reset else float("inf")
301
+
302
+ account_wait = min(ag_wait, cli_wait)
303
+ if account_wait != float("inf"):
304
+ wait_times.append(account_wait)
305
+
306
+ return min(wait_times) if wait_times else 0
307
+
308
+ def add_account(
309
+ self,
310
+ refresh_token: str,
311
+ email: Optional[str] = None,
312
+ project_id: Optional[str] = None,
313
+ ) -> ManagedAccount:
314
+ """Add a new account to the pool."""
315
+ now = _now_ms()
316
+ parts = parse_refresh_parts(refresh_token)
317
+ if project_id:
318
+ parts.project_id = project_id
319
+
320
+ account = ManagedAccount(
321
+ index=len(self._accounts),
322
+ email=email,
323
+ added_at=now,
324
+ last_used=0,
325
+ parts=parts,
326
+ rate_limit_reset_times={},
327
+ )
328
+ self._accounts.append(account)
329
+
330
+ # Set as active if this is the first account
331
+ if len(self._accounts) == 1:
332
+ self._current_index_by_family["claude"] = 0
333
+ self._current_index_by_family["gemini"] = 0
334
+
335
+ return account
336
+
337
+ def remove_account(self, account: ManagedAccount) -> bool:
338
+ """Remove an account from the pool."""
339
+ try:
340
+ idx = self._accounts.index(account)
341
+ except ValueError:
342
+ return False
343
+
344
+ self._accounts.pop(idx)
345
+
346
+ # Re-index remaining accounts
347
+ for i, acc in enumerate(self._accounts):
348
+ acc.index = i
349
+
350
+ if not self._accounts:
351
+ self._cursor = 0
352
+ self._current_index_by_family["claude"] = -1
353
+ self._current_index_by_family["gemini"] = -1
354
+ return True
355
+
356
+ # Adjust cursor and active indices
357
+ if self._cursor > idx:
358
+ self._cursor -= 1
359
+ self._cursor = self._cursor % len(self._accounts)
360
+
361
+ for family in ["claude", "gemini"]:
362
+ family_key: ModelFamily = family # type: ignore
363
+ if self._current_index_by_family[family_key] > idx:
364
+ self._current_index_by_family[family_key] -= 1
365
+ if self._current_index_by_family[family_key] >= len(self._accounts):
366
+ self._current_index_by_family[family_key] = -1
367
+
368
+ return True
369
+
370
+ def save_to_disk(self) -> None:
371
+ """Persist account state to disk."""
372
+ claude_idx = max(0, self._current_index_by_family.get("claude", 0))
373
+ gemini_idx = max(0, self._current_index_by_family.get("gemini", 0))
374
+
375
+ accounts: List[AccountMetadata] = []
376
+ for acc in self._accounts:
377
+ rate_limits = RateLimitState(
378
+ claude=acc.rate_limit_reset_times.get("claude"),
379
+ gemini_antigravity=acc.rate_limit_reset_times.get("gemini-antigravity"),
380
+ gemini_cli=acc.rate_limit_reset_times.get("gemini-cli"),
381
+ )
382
+
383
+ accounts.append(
384
+ AccountMetadata(
385
+ refresh_token=acc.parts.refresh_token,
386
+ email=acc.email,
387
+ project_id=acc.parts.project_id,
388
+ managed_project_id=acc.parts.managed_project_id,
389
+ added_at=acc.added_at,
390
+ last_used=acc.last_used,
391
+ last_switch_reason=acc.last_switch_reason,
392
+ rate_limit_reset_times=rate_limits,
393
+ )
394
+ )
395
+
396
+ storage = AccountStorage(
397
+ version=3,
398
+ accounts=accounts,
399
+ active_index=claude_idx,
400
+ active_index_by_family={
401
+ "claude": claude_idx,
402
+ "gemini": gemini_idx,
403
+ },
404
+ )
405
+
406
+ save_accounts(storage)