code-puppy 0.0.302__py3-none-any.whl → 0.0.335__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 (87) hide show
  1. code_puppy/agents/base_agent.py +343 -35
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +898 -0
  4. code_puppy/command_line/add_model_menu.py +23 -1
  5. code_puppy/command_line/autosave_menu.py +271 -35
  6. code_puppy/command_line/colors_menu.py +520 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +82 -10
  9. code_puppy/command_line/core_commands.py +70 -7
  10. code_puppy/command_line/diff_menu.py +5 -0
  11. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  12. code_puppy/command_line/mcp/edit_command.py +3 -1
  13. code_puppy/command_line/mcp/handler.py +7 -2
  14. code_puppy/command_line/mcp/install_command.py +8 -3
  15. code_puppy/command_line/mcp/install_menu.py +5 -1
  16. code_puppy/command_line/mcp/logs_command.py +173 -64
  17. code_puppy/command_line/mcp/restart_command.py +7 -2
  18. code_puppy/command_line/mcp/search_command.py +10 -4
  19. code_puppy/command_line/mcp/start_all_command.py +16 -6
  20. code_puppy/command_line/mcp/start_command.py +3 -1
  21. code_puppy/command_line/mcp/status_command.py +2 -1
  22. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  23. code_puppy/command_line/mcp/stop_command.py +3 -1
  24. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  25. code_puppy/command_line/model_settings_menu.py +58 -7
  26. code_puppy/command_line/motd.py +13 -7
  27. code_puppy/command_line/onboarding_slides.py +180 -0
  28. code_puppy/command_line/onboarding_wizard.py +340 -0
  29. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  30. code_puppy/command_line/session_commands.py +11 -4
  31. code_puppy/config.py +106 -17
  32. code_puppy/http_utils.py +155 -196
  33. code_puppy/keymap.py +8 -0
  34. code_puppy/main.py +5 -828
  35. code_puppy/mcp_/__init__.py +17 -0
  36. code_puppy/mcp_/blocking_startup.py +61 -32
  37. code_puppy/mcp_/config_wizard.py +5 -1
  38. code_puppy/mcp_/managed_server.py +23 -3
  39. code_puppy/mcp_/manager.py +65 -0
  40. code_puppy/mcp_/mcp_logs.py +224 -0
  41. code_puppy/messaging/__init__.py +20 -4
  42. code_puppy/messaging/bus.py +64 -0
  43. code_puppy/messaging/markdown_patches.py +57 -0
  44. code_puppy/messaging/messages.py +16 -0
  45. code_puppy/messaging/renderers.py +21 -9
  46. code_puppy/messaging/rich_renderer.py +113 -67
  47. code_puppy/messaging/spinner/console_spinner.py +34 -0
  48. code_puppy/model_factory.py +271 -45
  49. code_puppy/model_utils.py +57 -48
  50. code_puppy/models.json +21 -7
  51. code_puppy/plugins/__init__.py +12 -0
  52. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  53. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  54. code_puppy/plugins/antigravity_oauth/antigravity_model.py +612 -0
  55. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  56. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  57. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  58. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  59. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  60. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  61. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  62. code_puppy/plugins/antigravity_oauth/transport.py +595 -0
  63. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  64. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  65. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  66. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +5 -3
  67. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  68. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  69. code_puppy/plugins/claude_code_oauth/register_callbacks.py +30 -0
  70. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  71. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  72. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  73. code_puppy/prompts/codex_system_prompt.md +310 -0
  74. code_puppy/pydantic_patches.py +131 -0
  75. code_puppy/reopenable_async_client.py +8 -8
  76. code_puppy/terminal_utils.py +291 -0
  77. code_puppy/tools/agent_tools.py +34 -9
  78. code_puppy/tools/command_runner.py +344 -27
  79. code_puppy/tools/file_operations.py +33 -45
  80. code_puppy/uvx_detection.py +242 -0
  81. {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models.json +21 -7
  82. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/METADATA +30 -1
  83. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/RECORD +87 -64
  84. {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models_dev_api.json +0 -0
  85. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/WHEEL +0 -0
  86. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/entry_points.txt +0 -0
  87. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/licenses/LICENSE +0 -0
code_puppy/config.py CHANGED
@@ -53,6 +53,7 @@ _DEFAULT_SQLITE_FILE = os.path.join(DATA_DIR, "dbos_store.sqlite")
53
53
  GEMINI_MODELS_FILE = os.path.join(DATA_DIR, "gemini_models.json")
54
54
  CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
55
55
  CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
56
+ ANTIGRAVITY_MODELS_FILE = os.path.join(DATA_DIR, "antigravity_models.json")
56
57
 
57
58
  # Cache files (XDG_CACHE_HOME)
58
59
  AUTOSAVE_DIR = os.path.join(CACHE_DIR, "autosaves")
@@ -212,6 +213,9 @@ def get_config_keys():
212
213
  default_keys.append("enable_dbos")
213
214
  # Add cancel agent key configuration
214
215
  default_keys.append("cancel_agent_key")
216
+ # Add banner color keys
217
+ for banner_name in DEFAULT_BANNER_COLORS:
218
+ default_keys.append(f"banner_color_{banner_name}")
215
219
 
216
220
  config = configparser.ConfigParser()
217
221
  config.read(CONFIG_FILE)
@@ -256,9 +260,8 @@ def load_mcp_server_configs():
256
260
  def _default_model_from_models_json():
257
261
  """Load the default model name from models.json.
258
262
 
259
- Prefers synthetic-GLM-4.6 as the default model.
260
- Falls back to the first model in models.json if synthetic-GLM-4.6 is not available.
261
- As a last resort, falls back to ``gpt-5`` if the file cannot be read.
263
+ Returns the first model in models.json as the default.
264
+ Falls back to ``gpt-5`` if the file cannot be read.
262
265
  """
263
266
  global _default_model_cache
264
267
 
@@ -270,11 +273,7 @@ def _default_model_from_models_json():
270
273
 
271
274
  models_config = ModelFactory.load_config()
272
275
  if models_config:
273
- # Prefer synthetic-GLM-4.6 as default
274
- if "synthetic-GLM-4.6" in models_config:
275
- _default_model_cache = "synthetic-GLM-4.6"
276
- return "synthetic-GLM-4.6"
277
- # Fall back to first model if synthetic-GLM-4.6 is not available
276
+ # Use first model in models.json as default
278
277
  first_key = next(iter(models_config))
279
278
  _default_model_cache = first_key
280
279
  return first_key
@@ -497,8 +496,8 @@ def set_puppy_token(token: str):
497
496
 
498
497
 
499
498
  def get_openai_reasoning_effort() -> str:
500
- """Return the configured OpenAI reasoning effort (low, medium, high)."""
501
- allowed_values = {"low", "medium", "high"}
499
+ """Return the configured OpenAI reasoning effort (minimal, low, medium, high, xhigh)."""
500
+ allowed_values = {"minimal", "low", "medium", "high", "xhigh"}
502
501
  configured = (get_value("openai_reasoning_effort") or "medium").strip().lower()
503
502
  if configured not in allowed_values:
504
503
  return "medium"
@@ -507,7 +506,7 @@ def get_openai_reasoning_effort() -> str:
507
506
 
508
507
  def set_openai_reasoning_effort(value: str) -> None:
509
508
  """Persist the OpenAI reasoning effort ensuring it remains within allowed values."""
510
- allowed_values = {"low", "medium", "high"}
509
+ allowed_values = {"minimal", "low", "medium", "high", "xhigh"}
511
510
  normalized = (value or "").strip().lower()
512
511
  if normalized not in allowed_values:
513
512
  raise ValueError(
@@ -658,10 +657,22 @@ def get_all_model_settings(model_name: str) -> dict:
658
657
  for key, val in config[DEFAULT_SECTION].items():
659
658
  if key.startswith(prefix) and val.strip():
660
659
  setting_name = key[len(prefix) :]
661
- try:
662
- settings[setting_name] = float(val)
663
- except (ValueError, TypeError):
664
- pass
660
+ # Handle different value types
661
+ val_stripped = val.strip()
662
+ # Check for boolean values first
663
+ if val_stripped.lower() in ("true", "false"):
664
+ settings[setting_name] = val_stripped.lower() == "true"
665
+ else:
666
+ # Try to parse as number (int first, then float)
667
+ try:
668
+ # Try int first for cleaner values like budget_tokens
669
+ if "." not in val_stripped:
670
+ settings[setting_name] = int(val_stripped)
671
+ else:
672
+ settings[setting_name] = float(val_stripped)
673
+ except (ValueError, TypeError):
674
+ # Keep as string if not a number
675
+ settings[setting_name] = val_stripped
665
676
 
666
677
  return settings
667
678
 
@@ -1041,11 +1052,11 @@ def set_enable_dbos(enabled: bool) -> None:
1041
1052
  set_config_value("enable_dbos", "true" if enabled else "false")
1042
1053
 
1043
1054
 
1044
- def get_message_limit(default: int = 100) -> int:
1055
+ def get_message_limit(default: int = 1000) -> int:
1045
1056
  """
1046
1057
  Returns the user-configured message/request limit for the agent.
1047
1058
  This controls how many steps/requests the agent can take.
1048
- Defaults to 100 if unset or misconfigured.
1059
+ Defaults to 1000 if unset or misconfigured.
1049
1060
  Configurable by 'message_limit' key.
1050
1061
  """
1051
1062
  val = get_value("message_limit")
@@ -1257,6 +1268,84 @@ def set_diff_deletion_color(color: str):
1257
1268
  set_config_value("highlight_deletion_color", color)
1258
1269
 
1259
1270
 
1271
+ # =============================================================================
1272
+ # Banner Color Configuration
1273
+ # =============================================================================
1274
+
1275
+ # Default banner colors (Rich color names)
1276
+ # A beautiful jewel-tone palette with semantic meaning:
1277
+ # - Blues/Teals: Reading & navigation (calm, informational)
1278
+ # - Warm tones: Actions & changes (edits, shell commands)
1279
+ # - Purples: AI thinking & reasoning (the "brain" colors)
1280
+ # - Greens: Completions & success
1281
+ # - Neutrals: Search & listings
1282
+ DEFAULT_BANNER_COLORS = {
1283
+ "thinking": "deep_sky_blue4", # Sapphire - contemplation
1284
+ "agent_response": "medium_purple4", # Amethyst - main AI output
1285
+ "shell_command": "dark_orange3", # Amber - system commands
1286
+ "read_file": "steel_blue", # Steel - reading files
1287
+ "edit_file": "dark_goldenrod", # Gold - modifications
1288
+ "grep": "grey37", # Silver - search results
1289
+ "directory_listing": "dodger_blue2", # Sky - navigation
1290
+ "agent_reasoning": "dark_violet", # Violet - deep thought
1291
+ "invoke_agent": "deep_pink4", # Ruby - agent invocation
1292
+ "subagent_response": "sea_green3", # Emerald - sub-agent success
1293
+ "list_agents": "dark_slate_gray3", # Slate - neutral listing
1294
+ }
1295
+
1296
+
1297
+ def get_banner_color(banner_name: str) -> str:
1298
+ """Get the background color for a specific banner.
1299
+
1300
+ Args:
1301
+ banner_name: The banner identifier (e.g., 'thinking', 'agent_response')
1302
+
1303
+ Returns:
1304
+ Rich color name or hex code for the banner background
1305
+ """
1306
+ config_key = f"banner_color_{banner_name}"
1307
+ val = get_value(config_key)
1308
+ if val:
1309
+ return val
1310
+ return DEFAULT_BANNER_COLORS.get(banner_name, "blue")
1311
+
1312
+
1313
+ def set_banner_color(banner_name: str, color: str):
1314
+ """Set the background color for a specific banner.
1315
+
1316
+ Args:
1317
+ banner_name: The banner identifier (e.g., 'thinking', 'agent_response')
1318
+ color: Rich color name or hex code
1319
+ """
1320
+ config_key = f"banner_color_{banner_name}"
1321
+ set_config_value(config_key, color)
1322
+
1323
+
1324
+ def get_all_banner_colors() -> dict:
1325
+ """Get all banner colors (configured or default).
1326
+
1327
+ Returns:
1328
+ Dict mapping banner names to their colors
1329
+ """
1330
+ return {name: get_banner_color(name) for name in DEFAULT_BANNER_COLORS}
1331
+
1332
+
1333
+ def reset_banner_color(banner_name: str):
1334
+ """Reset a banner color to its default.
1335
+
1336
+ Args:
1337
+ banner_name: The banner identifier to reset
1338
+ """
1339
+ default_color = DEFAULT_BANNER_COLORS.get(banner_name, "blue")
1340
+ set_banner_color(banner_name, default_color)
1341
+
1342
+
1343
+ def reset_all_banner_colors():
1344
+ """Reset all banner colors to their defaults."""
1345
+ for name, color in DEFAULT_BANNER_COLORS.items():
1346
+ set_banner_color(name, color)
1347
+
1348
+
1260
1349
  def get_current_autosave_id() -> str:
1261
1350
  """Get or create the current autosave session ID for this process."""
1262
1351
  global _CURRENT_AUTOSAVE_ID
code_puppy/http_utils.py CHANGED
@@ -4,29 +4,19 @@ HTTP utilities module for code-puppy.
4
4
  This module provides functions for creating properly configured HTTP clients.
5
5
  """
6
6
 
7
+ import asyncio
8
+ import logging
7
9
  import os
8
10
  import socket
9
- from typing import Dict, Optional, Union
11
+ import time
12
+ from typing import Any, Dict, Optional, Union
10
13
 
11
14
  import httpx
12
15
  import requests
13
- from tenacity import stop_after_attempt, wait_exponential
14
16
 
15
17
  from code_puppy.config import get_http2
16
18
 
17
- try:
18
- from pydantic_ai.retries import (
19
- AsyncTenacityTransport,
20
- RetryConfig,
21
- TenacityTransport,
22
- wait_retry_after,
23
- )
24
- except ImportError:
25
- # Fallback if pydantic_ai.retries is not available
26
- AsyncTenacityTransport = None
27
- RetryConfig = None
28
- TenacityTransport = None
29
- wait_retry_after = None
19
+ logger = logging.getLogger(__name__)
30
20
 
31
21
  try:
32
22
  from .reopenable_async_client import ReopenableAsyncClient
@@ -34,12 +24,109 @@ except ImportError:
34
24
  ReopenableAsyncClient = None
35
25
 
36
26
  try:
37
- from .messaging import emit_info
27
+ from .messaging import emit_info, emit_warning
38
28
  except ImportError:
39
29
  # Fallback if messaging system is not available
40
30
  def emit_info(content: str, **metadata):
41
31
  pass # No-op if messaging system is not available
42
32
 
33
+ def emit_warning(content: str, **metadata):
34
+ pass
35
+
36
+
37
+ class RetryingAsyncClient(httpx.AsyncClient):
38
+ """AsyncClient with built-in rate limit handling (429) and retries.
39
+
40
+ This replaces the Tenacity transport with a more direct subclass implementation,
41
+ which plays nicer with proxies and custom transports (like Antigravity).
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ retry_status_codes: tuple = (429, 502, 503, 504),
47
+ max_retries: int = 5,
48
+ **kwargs,
49
+ ):
50
+ super().__init__(**kwargs)
51
+ self.retry_status_codes = retry_status_codes
52
+ self.max_retries = max_retries
53
+
54
+ async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
55
+ """Send request with automatic retries for rate limits and server errors."""
56
+ last_response = None
57
+ last_exception = None
58
+
59
+ for attempt in range(self.max_retries + 1):
60
+ try:
61
+ # Clone request for retry (streams might be consumed)
62
+ # But only if it's not the first attempt
63
+ req_to_send = request
64
+ if attempt > 0:
65
+ # httpx requests are reusable, but we need to be careful with streams
66
+ pass
67
+
68
+ response = await super().send(req_to_send, **kwargs)
69
+ last_response = response
70
+
71
+ # Check for retryable status
72
+ if response.status_code not in self.retry_status_codes:
73
+ return response
74
+
75
+ # Close response if we're going to retry
76
+ await response.aclose()
77
+
78
+ # Determine wait time
79
+ wait_time = 1.0 * (
80
+ 2**attempt
81
+ ) # Default exponential backoff: 1s, 2s, 4s...
82
+
83
+ # Check Retry-After header
84
+ retry_after = response.headers.get("Retry-After")
85
+ if retry_after:
86
+ try:
87
+ wait_time = float(retry_after)
88
+ except ValueError:
89
+ # Try parsing http-date
90
+ from email.utils import parsedate_to_datetime
91
+
92
+ try:
93
+ date = parsedate_to_datetime(retry_after)
94
+ wait_time = date.timestamp() - time.time()
95
+ except Exception:
96
+ pass
97
+
98
+ # Cap wait time
99
+ wait_time = max(0.5, min(wait_time, 60.0))
100
+
101
+ if attempt < self.max_retries:
102
+ emit_info(
103
+ f"HTTP retry: {response.status_code} received. Waiting {wait_time:.1f}s (attempt {attempt + 1}/{self.max_retries})"
104
+ )
105
+ await asyncio.sleep(wait_time)
106
+
107
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.PoolTimeout) as e:
108
+ last_exception = e
109
+ wait_time = 1.0 * (2**attempt)
110
+ if attempt < self.max_retries:
111
+ emit_warning(
112
+ f"HTTP connection error: {e}. Retrying in {wait_time}s..."
113
+ )
114
+ await asyncio.sleep(wait_time)
115
+ else:
116
+ raise
117
+ except Exception:
118
+ raise
119
+
120
+ # Return last response (even if it's an error status)
121
+ if last_response:
122
+ return last_response
123
+
124
+ # Should catch this in loop, but just in case
125
+ if last_exception:
126
+ raise last_exception
127
+
128
+ return last_response
129
+
43
130
 
44
131
  def get_cert_bundle_path() -> str:
45
132
  # First check if SSL_CERT_FILE environment variable is set
@@ -60,53 +147,15 @@ def create_client(
60
147
  # Check if HTTP/2 is enabled in config
61
148
  http2_enabled = get_http2()
62
149
 
63
- # Check if custom retry transport should be disabled (e.g., for integration tests with proxies)
64
- disable_retry_transport = os.environ.get(
65
- "CODE_PUPPY_DISABLE_RETRY_TRANSPORT", ""
66
- ).lower() in ("1", "true", "yes")
67
-
68
150
  # If retry components are available, create a client with retry transport
69
- if (
70
- TenacityTransport
71
- and RetryConfig
72
- and wait_retry_after
73
- and not disable_retry_transport
74
- ):
75
-
76
- def should_retry_status(response):
77
- """Raise exceptions for retryable HTTP status codes."""
78
- if response.status_code in retry_status_codes:
79
- emit_info(
80
- f"HTTP retry: Retrying request due to status code {response.status_code}"
81
- )
82
- return True
83
-
84
- transport = TenacityTransport(
85
- config=RetryConfig(
86
- retry=lambda e: isinstance(e, httpx.HTTPStatusError)
87
- and e.response.status_code in retry_status_codes,
88
- wait=wait_retry_after(
89
- fallback_strategy=wait_exponential(multiplier=1, max=60),
90
- max_wait=300,
91
- ),
92
- stop=stop_after_attempt(10),
93
- reraise=True,
94
- ),
95
- validate_response=should_retry_status,
96
- )
97
-
98
- return httpx.Client(
99
- transport=transport,
100
- verify=verify,
101
- headers=headers or {},
102
- timeout=timeout,
103
- http2=http2_enabled,
104
- )
105
- else:
106
- # Fallback to regular client if retry components are not available
107
- return httpx.Client(
108
- verify=verify, headers=headers or {}, timeout=timeout, http2=http2_enabled
109
- )
151
+ # Note: TenacityTransport was removed. For now we just return a standard client.
152
+ # Future TODO: Implement RetryingClient(httpx.Client) if needed.
153
+ return httpx.Client(
154
+ verify=verify,
155
+ headers=headers or {},
156
+ timeout=timeout,
157
+ http2=http2_enabled,
158
+ )
110
159
 
111
160
 
112
161
  def create_async_client(
@@ -145,52 +194,21 @@ def create_async_client(
145
194
  else:
146
195
  trust_env = False
147
196
 
148
- # If retry components are available, create a client with retry transport
149
- # BUT: disable retry transport when proxies are detected because custom transports
150
- # don't play nicely with proxy configuration
151
- if (
152
- AsyncTenacityTransport
153
- and RetryConfig
154
- and wait_retry_after
155
- and not disable_retry_transport
156
- and not has_proxy
157
- ):
158
-
159
- def should_retry_status(response):
160
- """Raise exceptions for retryable HTTP status codes."""
161
- if response.status_code in retry_status_codes:
162
- emit_info(
163
- f"HTTP retry: Retrying request due to status code {response.status_code}"
164
- )
165
- return True
166
-
167
- # Create transport (with or without proxy base)
168
- if has_proxy:
169
- # Extract proxy URL from environment
170
- proxy_url = (
171
- os.environ.get("HTTPS_PROXY")
172
- or os.environ.get("https_proxy")
173
- or os.environ.get("HTTP_PROXY")
174
- or os.environ.get("http_proxy")
175
- )
176
- else:
177
- proxy_url = None
178
-
179
- # Create retry transport wrapper
180
- transport = AsyncTenacityTransport(
181
- config=RetryConfig(
182
- retry=lambda e: isinstance(e, httpx.HTTPStatusError)
183
- and e.response.status_code in retry_status_codes,
184
- wait=wait_retry_after(10),
185
- stop=stop_after_attempt(10),
186
- reraise=True,
187
- ),
188
- validate_response=should_retry_status,
197
+ # Extract proxy URL if needed
198
+ proxy_url = None
199
+ if has_proxy:
200
+ proxy_url = (
201
+ os.environ.get("HTTPS_PROXY")
202
+ or os.environ.get("https_proxy")
203
+ or os.environ.get("HTTP_PROXY")
204
+ or os.environ.get("http_proxy")
189
205
  )
190
206
 
191
- return httpx.AsyncClient(
192
- transport=transport,
193
- proxy=proxy_url, # Pass proxy to client, not transport
207
+ # Use RetryingAsyncClient if retries are enabled
208
+ if not disable_retry_transport:
209
+ return RetryingAsyncClient(
210
+ retry_status_codes=retry_status_codes,
211
+ proxy=proxy_url,
194
212
  verify=verify,
195
213
  headers=headers or {},
196
214
  timeout=timeout,
@@ -198,19 +216,7 @@ def create_async_client(
198
216
  trust_env=trust_env,
199
217
  )
200
218
  else:
201
- # Fallback to regular client if retry components are not available,
202
- # when retry transport is explicitly disabled, or when proxies are detected
203
- # Extract proxy URL if needed
204
- if has_proxy:
205
- proxy_url = (
206
- os.environ.get("HTTPS_PROXY")
207
- or os.environ.get("https_proxy")
208
- or os.environ.get("HTTP_PROXY")
209
- or os.environ.get("http_proxy")
210
- )
211
- else:
212
- proxy_url = None
213
-
219
+ # Regular client for testing
214
220
  return httpx.AsyncClient(
215
221
  proxy=proxy_url,
216
222
  verify=verify,
@@ -295,87 +301,41 @@ def create_reopenable_async_client(
295
301
  else:
296
302
  trust_env = False
297
303
 
298
- # If retry components are available, create a client with retry transport
299
- # BUT: disable retry transport when proxies are detected because custom transports
300
- # don't play nicely with proxy configuration
301
- if (
302
- AsyncTenacityTransport
303
- and RetryConfig
304
- and wait_retry_after
305
- and not disable_retry_transport
306
- and not has_proxy
307
- ):
304
+ # Extract proxy URL if needed
305
+ proxy_url = None
306
+ if has_proxy:
307
+ proxy_url = (
308
+ os.environ.get("HTTPS_PROXY")
309
+ or os.environ.get("https_proxy")
310
+ or os.environ.get("HTTP_PROXY")
311
+ or os.environ.get("http_proxy")
312
+ )
308
313
 
309
- def should_retry_status(response):
310
- """Raise exceptions for retryable HTTP status codes."""
311
- if response.status_code in retry_status_codes:
312
- emit_info(
313
- f"HTTP retry: Retrying request due to status code {response.status_code}"
314
- )
315
- return True
316
-
317
- transport = AsyncTenacityTransport(
318
- config=RetryConfig(
319
- retry=lambda e: isinstance(e, httpx.HTTPStatusError)
320
- and e.response.status_code in retry_status_codes,
321
- wait=wait_retry_after(
322
- fallback_strategy=wait_exponential(multiplier=1, max=60),
323
- max_wait=300,
324
- ),
325
- stop=stop_after_attempt(10),
326
- reraise=True,
327
- ),
328
- validate_response=should_retry_status,
314
+ if ReopenableAsyncClient is not None:
315
+ # Use RetryingAsyncClient if retries are enabled
316
+ client_class = (
317
+ RetryingAsyncClient if not disable_retry_transport else httpx.AsyncClient
329
318
  )
330
319
 
331
- # Extract proxy URL if needed
332
- if has_proxy:
333
- proxy_url = (
334
- os.environ.get("HTTPS_PROXY")
335
- or os.environ.get("https_proxy")
336
- or os.environ.get("HTTP_PROXY")
337
- or os.environ.get("http_proxy")
338
- )
339
- else:
340
- proxy_url = None
320
+ # Pass retry config only if using RetryingAsyncClient
321
+ kwargs = {
322
+ "proxy": proxy_url,
323
+ "verify": verify,
324
+ "headers": headers or {},
325
+ "timeout": timeout,
326
+ "http2": http2_enabled,
327
+ "trust_env": trust_env,
328
+ }
341
329
 
342
- if ReopenableAsyncClient is not None:
343
- return ReopenableAsyncClient(
344
- transport=transport,
345
- proxy=proxy_url,
346
- verify=verify,
347
- headers=headers or {},
348
- timeout=timeout,
349
- http2=http2_enabled,
350
- trust_env=trust_env,
351
- )
352
- else:
353
- # Fallback to regular AsyncClient if ReopenableAsyncClient is not available
354
- return httpx.AsyncClient(
355
- transport=transport,
356
- proxy=proxy_url,
357
- verify=verify,
358
- headers=headers or {},
359
- timeout=timeout,
360
- http2=http2_enabled,
361
- trust_env=trust_env,
362
- )
363
- else:
364
- # Fallback to regular clients if retry components are not available
365
- # or when proxies are detected
366
- # Extract proxy URL if needed
367
- if has_proxy:
368
- proxy_url = (
369
- os.environ.get("HTTPS_PROXY")
370
- or os.environ.get("https_proxy")
371
- or os.environ.get("HTTP_PROXY")
372
- or os.environ.get("http_proxy")
373
- )
374
- else:
375
- proxy_url = None
330
+ if not disable_retry_transport:
331
+ kwargs["retry_status_codes"] = retry_status_codes
376
332
 
377
- if ReopenableAsyncClient is not None:
378
- return ReopenableAsyncClient(
333
+ return ReopenableAsyncClient(client_class=client_class, **kwargs)
334
+ else:
335
+ # Fallback to RetryingAsyncClient
336
+ if not disable_retry_transport:
337
+ return RetryingAsyncClient(
338
+ retry_status_codes=retry_status_codes,
379
339
  proxy=proxy_url,
380
340
  verify=verify,
381
341
  headers=headers or {},
@@ -384,7 +344,6 @@ def create_reopenable_async_client(
384
344
  trust_env=trust_env,
385
345
  )
386
346
  else:
387
- # Fallback to regular AsyncClient if ReopenableAsyncClient is not available
388
347
  return httpx.AsyncClient(
389
348
  proxy=proxy_url,
390
349
  verify=verify,
code_puppy/keymap.py CHANGED
@@ -55,11 +55,19 @@ class KeymapError(Exception):
55
55
  def get_cancel_agent_key() -> str:
56
56
  """Get the configured cancel agent key from config.
57
57
 
58
+ On Windows when launched via uvx, this automatically returns "ctrl+k"
59
+ to work around uvx capturing Ctrl+C before it reaches Python.
60
+
58
61
  Returns:
59
62
  The key name (e.g., "ctrl+c", "ctrl+k") from config,
60
63
  or the default if not configured.
61
64
  """
62
65
  from code_puppy.config import get_value
66
+ from code_puppy.uvx_detection import should_use_alternate_cancel_key
67
+
68
+ # On Windows + uvx, force ctrl+k to bypass uvx's SIGINT capture
69
+ if should_use_alternate_cancel_key():
70
+ return "ctrl+k"
63
71
 
64
72
  key = get_value("cancel_agent_key")
65
73
  if key is None or key.strip() == "":