code-puppy 0.0.348__py3-none-any.whl → 0.0.372__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/__init__.py +8 -0
  2. code_puppy/agents/agent_manager.py +272 -1
  3. code_puppy/agents/agent_pack_leader.py +383 -0
  4. code_puppy/agents/agent_qa_kitten.py +12 -7
  5. code_puppy/agents/agent_terminal_qa.py +323 -0
  6. code_puppy/agents/base_agent.py +11 -8
  7. code_puppy/agents/event_stream_handler.py +101 -8
  8. code_puppy/agents/pack/__init__.py +34 -0
  9. code_puppy/agents/pack/bloodhound.py +304 -0
  10. code_puppy/agents/pack/husky.py +321 -0
  11. code_puppy/agents/pack/retriever.py +393 -0
  12. code_puppy/agents/pack/shepherd.py +348 -0
  13. code_puppy/agents/pack/terrier.py +287 -0
  14. code_puppy/agents/pack/watchdog.py +367 -0
  15. code_puppy/agents/subagent_stream_handler.py +276 -0
  16. code_puppy/api/__init__.py +13 -0
  17. code_puppy/api/app.py +169 -0
  18. code_puppy/api/main.py +21 -0
  19. code_puppy/api/pty_manager.py +446 -0
  20. code_puppy/api/routers/__init__.py +12 -0
  21. code_puppy/api/routers/agents.py +36 -0
  22. code_puppy/api/routers/commands.py +217 -0
  23. code_puppy/api/routers/config.py +74 -0
  24. code_puppy/api/routers/sessions.py +232 -0
  25. code_puppy/api/templates/terminal.html +361 -0
  26. code_puppy/api/websocket.py +154 -0
  27. code_puppy/callbacks.py +73 -0
  28. code_puppy/chatgpt_codex_client.py +53 -0
  29. code_puppy/claude_cache_client.py +294 -41
  30. code_puppy/command_line/add_model_menu.py +13 -4
  31. code_puppy/command_line/agent_menu.py +662 -0
  32. code_puppy/command_line/core_commands.py +89 -112
  33. code_puppy/command_line/model_picker_completion.py +3 -20
  34. code_puppy/command_line/model_settings_menu.py +21 -3
  35. code_puppy/config.py +145 -70
  36. code_puppy/gemini_model.py +706 -0
  37. code_puppy/http_utils.py +6 -3
  38. code_puppy/messaging/__init__.py +15 -0
  39. code_puppy/messaging/messages.py +27 -0
  40. code_puppy/messaging/queue_console.py +1 -1
  41. code_puppy/messaging/rich_renderer.py +36 -1
  42. code_puppy/messaging/spinner/__init__.py +20 -2
  43. code_puppy/messaging/subagent_console.py +461 -0
  44. code_puppy/model_factory.py +50 -16
  45. code_puppy/model_switching.py +63 -0
  46. code_puppy/model_utils.py +27 -24
  47. code_puppy/models.json +12 -12
  48. code_puppy/plugins/antigravity_oauth/antigravity_model.py +206 -172
  49. code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
  50. code_puppy/plugins/antigravity_oauth/transport.py +236 -45
  51. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
  52. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
  53. code_puppy/plugins/claude_code_oauth/utils.py +4 -1
  54. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  55. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  56. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  57. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  58. code_puppy/pydantic_patches.py +52 -0
  59. code_puppy/status_display.py +6 -2
  60. code_puppy/tools/__init__.py +37 -1
  61. code_puppy/tools/agent_tools.py +83 -33
  62. code_puppy/tools/browser/__init__.py +37 -0
  63. code_puppy/tools/browser/browser_control.py +6 -6
  64. code_puppy/tools/browser/browser_interactions.py +21 -20
  65. code_puppy/tools/browser/browser_locators.py +9 -9
  66. code_puppy/tools/browser/browser_manager.py +316 -0
  67. code_puppy/tools/browser/browser_navigation.py +7 -7
  68. code_puppy/tools/browser/browser_screenshot.py +78 -140
  69. code_puppy/tools/browser/browser_scripts.py +15 -13
  70. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  71. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  72. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  73. code_puppy/tools/browser/terminal_tools.py +525 -0
  74. code_puppy/tools/command_runner.py +292 -101
  75. code_puppy/tools/common.py +176 -1
  76. code_puppy/tools/display.py +84 -0
  77. code_puppy/tools/subagent_context.py +158 -0
  78. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
  79. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/METADATA +17 -16
  80. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/RECORD +84 -51
  81. code_puppy/prompts/codex_system_prompt.md +0 -310
  82. code_puppy/tools/browser/camoufox_manager.py +0 -235
  83. code_puppy/tools/browser/vqa_agent.py +0 -90
  84. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
  85. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
  86. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
  87. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/licenses/LICENSE +0 -0
@@ -23,20 +23,78 @@ from .constants import (
23
23
  logger = logging.getLogger(__name__)
24
24
 
25
25
 
26
- def _inline_refs(
27
- schema: dict, convert_unions: bool = False, simplify_for_claude: bool = False
28
- ) -> dict:
26
+ def _flatten_union_to_object(union_items: list, defs: dict, resolve_fn) -> dict:
27
+ """Flatten a union of object types into a single object with all properties.
28
+
29
+ For discriminated unions like EditFilePayload (ContentPayload | ReplacementsPayload | DeleteSnippetPayload),
30
+ we merge all object types into one with all properties marked as optional.
31
+ """
32
+ merged_properties = {}
33
+ has_string_type = False
34
+
35
+ for item in union_items:
36
+ if not isinstance(item, dict):
37
+ continue
38
+
39
+ # Resolve $ref first
40
+ if "$ref" in item:
41
+ ref_path = item["$ref"]
42
+ ref_name = None
43
+ if ref_path.startswith("#/$defs/"):
44
+ ref_name = ref_path[8:]
45
+ elif ref_path.startswith("#/definitions/"):
46
+ ref_name = ref_path[14:]
47
+ if ref_name and ref_name in defs:
48
+ item = copy.deepcopy(defs[ref_name])
49
+ else:
50
+ continue
51
+
52
+ # Check for string type (common fallback)
53
+ if item.get("type") == "string":
54
+ has_string_type = True
55
+ continue
56
+
57
+ # Skip null types
58
+ if item.get("type") == "null":
59
+ continue
60
+
61
+ # Merge properties from object types
62
+ if item.get("type") == "object" or "properties" in item:
63
+ props = item.get("properties", {})
64
+ for prop_name, prop_schema in props.items():
65
+ if prop_name not in merged_properties:
66
+ # Resolve the property schema
67
+ merged_properties[prop_name] = resolve_fn(
68
+ copy.deepcopy(prop_schema)
69
+ )
70
+
71
+ if not merged_properties:
72
+ # No object properties found, return string type as fallback
73
+ return {"type": "string"} if has_string_type else {"type": "object"}
74
+
75
+ # Build merged object - no required fields since any subset is valid
76
+ result = {
77
+ "type": "object",
78
+ "properties": merged_properties,
79
+ }
80
+
81
+ return result
82
+
83
+
84
+ def _inline_refs(schema: dict, simplify_unions: bool = False) -> dict:
29
85
  """Inline $ref references and transform schema for Antigravity compatibility.
30
86
 
31
87
  - Inlines $ref references
32
88
  - Removes $defs, definitions, $schema, $id
33
- - Optionally converts anyOf/oneOf/allOf to any_of/one_of/all_of (only for Gemini)
34
89
  - Removes unsupported fields like 'default', 'examples', 'const'
35
- - For Claude: simplifies anyOf unions to single types
90
+ - When simplify_unions=True: flattens anyOf/oneOf unions:
91
+ - For unions of objects: merges into single object with all properties
92
+ - For simple unions (string | null): picks first non-null type
93
+ (required for both Gemini AND Claude - neither supports union types in function schemas!)
36
94
 
37
95
  Args:
38
- convert_unions: If True, convert anyOf->any_of etc. (for Gemini).
39
- simplify_for_claude: If True, simplify anyOf to single types.
96
+ simplify_unions: If True, simplify anyOf/oneOf unions.
97
+ Required for Gemini and Claude models.
40
98
  """
41
99
  if not isinstance(schema, dict):
42
100
  return schema
@@ -47,31 +105,73 @@ def _inline_refs(
47
105
  # Extract $defs for reference resolution
48
106
  defs = schema.pop("$defs", schema.pop("definitions", {}))
49
107
 
50
- def resolve_refs(
51
- obj, convert_unions=convert_unions, simplify_for_claude=simplify_for_claude
52
- ):
108
+ def resolve_refs(obj, simplify_unions=simplify_unions):
53
109
  """Recursively resolve $ref references and transform schema."""
54
110
  if isinstance(obj, dict):
55
- # For Claude: simplify anyOf/oneOf unions to first non-null type
56
- if simplify_for_claude:
111
+ # Handle anyOf/oneOf unions
112
+ if simplify_unions:
57
113
  for union_key in ["anyOf", "oneOf"]:
58
114
  if union_key in obj:
59
115
  union = obj[union_key]
60
116
  if isinstance(union, list):
61
- # Find first non-null type
117
+ # Check if this is a complex union of objects (discriminated union)
118
+ # vs a simple nullable type (string | null)
119
+ object_count = 0
120
+ has_refs = False
121
+ for item in union:
122
+ if isinstance(item, dict):
123
+ if "$ref" in item:
124
+ has_refs = True
125
+ object_count += 1
126
+ elif (
127
+ item.get("type") == "object"
128
+ or "properties" in item
129
+ ):
130
+ object_count += 1
131
+
132
+ # If multiple objects or has refs, flatten to single object
133
+ if object_count > 1 or has_refs:
134
+ flattened = _flatten_union_to_object(
135
+ union, defs, resolve_refs
136
+ )
137
+ # Keep description if present
138
+ if "description" in obj:
139
+ flattened["description"] = obj["description"]
140
+ return flattened
141
+
142
+ # Simple union - pick first non-null type
62
143
  for item in union:
63
144
  if (
64
145
  isinstance(item, dict)
65
146
  and item.get("type") != "null"
66
147
  ):
67
- # Replace the whole object with this type
68
148
  result = dict(item)
69
- # Keep description if present
70
149
  if "description" in obj:
71
150
  result["description"] = obj["description"]
72
- return resolve_refs(
73
- result, convert_unions, simplify_for_claude
74
- )
151
+ return resolve_refs(result, simplify_unions)
152
+
153
+ # Also handle allOf by merging all schemas
154
+ if simplify_unions and "allOf" in obj:
155
+ all_of = obj["allOf"]
156
+ if isinstance(all_of, list):
157
+ merged = {}
158
+ merged_properties = {}
159
+ for item in all_of:
160
+ if isinstance(item, dict):
161
+ resolved_item = resolve_refs(item, simplify_unions)
162
+ # Deep merge properties dicts
163
+ if "properties" in resolved_item:
164
+ merged_properties.update(
165
+ resolved_item.pop("properties")
166
+ )
167
+ merged.update(resolved_item)
168
+ if merged_properties:
169
+ merged["properties"] = merged_properties
170
+ # Keep other fields from original object (except allOf)
171
+ for k, v in obj.items():
172
+ if k != "allOf":
173
+ merged[k] = v
174
+ return resolve_refs(merged, simplify_unions)
75
175
 
76
176
  # Check for $ref
77
177
  if "$ref" in obj:
@@ -111,34 +211,23 @@ def _inline_refs(
111
211
  ):
112
212
  continue
113
213
 
114
- # For Claude: skip additionalProperties
115
- if simplify_for_claude and key == "additionalProperties":
214
+ # Skip additionalProperties (not supported by Gemini or Claude)
215
+ if simplify_unions and key == "additionalProperties":
116
216
  continue
117
217
 
118
- # Optionally transform union types for Gemini
119
- new_key = key
120
- if convert_unions:
121
- if key == "anyOf":
122
- new_key = "any_of"
123
- elif key == "oneOf":
124
- new_key = "one_of"
125
- elif key == "allOf":
126
- new_key = "all_of"
127
- elif key == "additionalProperties":
128
- new_key = "additional_properties"
129
-
130
- result[new_key] = resolve_refs(
131
- value, convert_unions, simplify_for_claude
132
- )
218
+ # Skip any remaining union type keys that weren't simplified above
219
+ # (This shouldn't happen normally, but just in case)
220
+ if simplify_unions and key in ("anyOf", "oneOf", "allOf"):
221
+ continue
222
+
223
+ result[key] = resolve_refs(value, simplify_unions)
133
224
  return result
134
225
  elif isinstance(obj, list):
135
- return [
136
- resolve_refs(item, convert_unions, simplify_for_claude) for item in obj
137
- ]
226
+ return [resolve_refs(item, simplify_unions) for item in obj]
138
227
  else:
139
228
  return obj
140
229
 
141
- return resolve_refs(schema, convert_unions, simplify_for_claude)
230
+ return resolve_refs(schema, simplify_unions)
142
231
 
143
232
 
144
233
  class UnwrappedResponse(httpx.Response):
@@ -264,17 +353,91 @@ class UnwrappedSSEResponse(httpx.Response):
264
353
 
265
354
 
266
355
  class AntigravityClient(httpx.AsyncClient):
267
- """Custom httpx client that handles Antigravity request/response wrapping."""
356
+ """Custom httpx client that handles Antigravity request/response wrapping.
357
+
358
+ Supports proactive token refresh to prevent expiry during long sessions.
359
+ """
268
360
 
269
361
  def __init__(
270
362
  self,
271
363
  project_id: str = "",
272
364
  model_name: str = "",
365
+ refresh_token: str = "",
366
+ expires_at: Optional[float] = None,
367
+ on_token_refreshed: Optional[Any] = None,
273
368
  **kwargs: Any,
274
369
  ):
275
370
  super().__init__(**kwargs)
276
371
  self.project_id = project_id
277
372
  self.model_name = model_name
373
+ self._refresh_token = refresh_token
374
+ self._expires_at = expires_at
375
+ self._on_token_refreshed = on_token_refreshed
376
+ self._refresh_lock = None # Lazy init for async lock
377
+
378
+ async def _ensure_valid_token(self) -> None:
379
+ """Proactively refresh the access token if it's expired or about to expire.
380
+
381
+ This prevents 401 errors during long-running sessions by checking and
382
+ refreshing the token BEFORE making requests, not after they fail.
383
+ """
384
+ import asyncio
385
+
386
+ from .token import is_token_expired, refresh_access_token
387
+
388
+ # Skip if no refresh token configured
389
+ if not self._refresh_token:
390
+ return
391
+
392
+ # Check if token needs refresh (includes 60-second buffer)
393
+ if not is_token_expired(self._expires_at):
394
+ return
395
+
396
+ # Lazy init the async lock
397
+ if self._refresh_lock is None:
398
+ self._refresh_lock = asyncio.Lock()
399
+
400
+ async with self._refresh_lock:
401
+ # Double-check after acquiring lock (another coroutine may have refreshed)
402
+ if not is_token_expired(self._expires_at):
403
+ return
404
+
405
+ logger.debug("Proactively refreshing Antigravity access token...")
406
+
407
+ try:
408
+ # Run the synchronous refresh in a thread pool to avoid blocking
409
+ loop = asyncio.get_event_loop()
410
+ new_tokens = await loop.run_in_executor(
411
+ None, refresh_access_token, self._refresh_token
412
+ )
413
+
414
+ if new_tokens:
415
+ # Update internal state
416
+ new_access_token = new_tokens.access_token
417
+ self._expires_at = new_tokens.expires_at
418
+ self._refresh_token = new_tokens.refresh_token
419
+
420
+ # Update the Authorization header
421
+ self.headers["Authorization"] = f"Bearer {new_access_token}"
422
+
423
+ logger.info(
424
+ "Proactively refreshed Antigravity token (expires in %ds)",
425
+ int(self._expires_at - __import__("time").time()),
426
+ )
427
+
428
+ # Notify callback (e.g., to persist updated tokens)
429
+ if self._on_token_refreshed:
430
+ try:
431
+ self._on_token_refreshed(new_tokens)
432
+ except Exception as e:
433
+ logger.warning("Token refresh callback failed: %s", e)
434
+ else:
435
+ logger.warning(
436
+ "Failed to proactively refresh token - request may fail with 401"
437
+ )
438
+
439
+ except Exception as e:
440
+ logger.warning("Proactive token refresh error: %s", e)
278
441
 
279
442
  def _wrap_request(self, content: bytes, url: str) -> tuple[bytes, str, str, bool]:
280
443
  """Wrap request body in Antigravity envelope and transform URL.
@@ -331,15 +494,14 @@ class AntigravityClient(httpx.AsyncClient):
331
494
  )
332
495
 
333
496
  # Inline $refs and remove $defs from parameters
334
- # Convert unions (anyOf->any_of) only for Gemini
335
- # Simplify schemas for Claude (no anyOf, no additionalProperties)
497
+ # Simplify union types (anyOf/oneOf/allOf) for BOTH Gemini and Claude
498
+ # Neither API supports union types in function schemas!
336
499
  if "parameters" in func_decl:
337
500
  is_gemini = "gemini" in model.lower()
338
501
  is_claude = "claude" in model.lower()
339
502
  func_decl["parameters"] = _inline_refs(
340
503
  func_decl["parameters"],
341
- convert_unions=is_gemini,
342
- simplify_for_claude=is_claude,
504
+ simplify_unions=(is_gemini or is_claude),
343
505
  )
344
506
 
345
507
  # Fix generationConfig for Antigravity compatibility
@@ -393,6 +555,7 @@ class AntigravityClient(httpx.AsyncClient):
393
555
  "request": original_body,
394
556
  "userAgent": "antigravity",
395
557
  "requestId": request_id,
558
+ "requestType": "agent",
396
559
  }
397
560
 
398
561
  # Transform URL to Antigravity format
@@ -424,6 +587,9 @@ class AntigravityClient(httpx.AsyncClient):
424
587
  """Override send to intercept at the lowest level with endpoint fallback."""
425
588
  import asyncio
426
589
 
590
+ # Proactively refresh token BEFORE making the request
591
+ await self._ensure_valid_token()
592
+
427
593
  # Transform POST requests to Antigravity format
428
594
  if request.method == "POST" and request.content:
429
595
  new_content, new_path, new_query, is_claude_thinking = self._wrap_request(
@@ -637,14 +803,36 @@ class AntigravityClient(httpx.AsyncClient):
637
803
  return None
638
804
 
639
805
 
806
+ # Type alias for token refresh callback
807
+ TokenRefreshCallback = Any # Callable[[OAuthTokens], None]
808
+
809
+
640
810
  def create_antigravity_client(
641
811
  access_token: str,
642
812
  project_id: str = "",
643
813
  model_name: str = "",
644
814
  base_url: str = "https://daily-cloudcode-pa.sandbox.googleapis.com",
645
815
  headers: Optional[Dict[str, str]] = None,
816
+ refresh_token: str = "",
817
+ expires_at: Optional[float] = None,
818
+ on_token_refreshed: Optional[TokenRefreshCallback] = None,
646
819
  ) -> AntigravityClient:
647
- """Create an httpx client configured for Antigravity API."""
820
+ """Create an httpx client configured for Antigravity API.
821
+
822
+ Args:
823
+ access_token: The OAuth access token for authentication
824
+ project_id: The GCP project ID
825
+ model_name: The model name being used
826
+ base_url: The API base URL
827
+ headers: Additional headers to include
828
+ refresh_token: The OAuth refresh token for proactive token refresh
829
+ expires_at: Unix timestamp when the access token expires
830
+ on_token_refreshed: Callback called when token is proactively refreshed,
831
+ receives OAuthTokens object to persist the new tokens
832
+
833
+ Returns:
834
+ An AntigravityClient configured for API requests with proactive token refresh
835
+ """
648
836
  # Start with Antigravity-specific headers
649
837
  default_headers = {
650
838
  "Authorization": f"Bearer {access_token}",
@@ -658,6 +846,9 @@ def create_antigravity_client(
658
846
  return AntigravityClient(
659
847
  project_id=project_id,
660
848
  model_name=model_name,
849
+ refresh_token=refresh_token,
850
+ expires_at=expires_at,
851
+ on_token_refreshed=on_token_refreshed,
661
852
  base_url=base_url,
662
853
  headers=default_headers,
663
854
  timeout=httpx.Timeout(180.0, connect=30.0),
@@ -6,8 +6,8 @@ import os
6
6
  from typing import List, Optional, Tuple
7
7
 
8
8
  from code_puppy.callbacks import register_callback
9
- from code_puppy.config import set_model_name
10
9
  from code_puppy.messaging import emit_info, emit_success, emit_warning
10
+ from code_puppy.model_switching import set_model_and_reload_agent
11
11
 
12
12
  from .config import CHATGPT_OAUTH_CONFIG, get_token_storage_path
13
13
  from .oauth_flow import run_oauth_flow
@@ -76,7 +76,7 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
76
76
 
77
77
  if name == "chatgpt-auth":
78
78
  run_oauth_flow()
79
- set_model_name("chatgpt-gpt-5.2-codex")
79
+ set_model_and_reload_agent("chatgpt-gpt-5.2-codex")
80
80
  return True
81
81
 
82
82
  if name == "chatgpt-status":
@@ -12,8 +12,8 @@ from typing import Any, Dict, List, Optional, Tuple
12
12
  from urllib.parse import parse_qs, urlparse
13
13
 
14
14
  from code_puppy.callbacks import register_callback
15
- from code_puppy.config import set_model_name
16
15
  from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
16
+ from code_puppy.model_switching import set_model_and_reload_agent
17
17
 
18
18
  from ..oauth_puppy_html import oauth_failure_html, oauth_success_html
19
19
  from .config import CLAUDE_CODE_OAUTH_CONFIG, get_token_storage_path
@@ -181,31 +181,6 @@ def _custom_help() -> List[Tuple[str, str]]:
181
181
  ]
182
182
 
183
183
 
184
- def _reload_current_agent() -> None:
185
- """Reload the current agent so new auth tokens are picked up immediately."""
186
- try:
187
- from code_puppy.agents import get_current_agent
188
-
189
- current_agent = get_current_agent()
190
- if current_agent is None:
191
- logger.debug("No current agent to reload")
192
- return
193
-
194
- # JSON agents may need to refresh their config before reload
195
- if hasattr(current_agent, "refresh_config"):
196
- try:
197
- current_agent.refresh_config()
198
- except Exception:
199
- # Non-fatal, continue to reload
200
- pass
201
-
202
- current_agent.reload_code_generation_agent()
203
- emit_info("Active agent reloaded with new authentication")
204
- except Exception as e:
205
- emit_warning(f"Authentication succeeded but agent reload failed: {e}")
206
- logger.exception("Failed to reload agent after authentication")
207
-
208
-
209
184
  def _perform_authentication() -> None:
210
185
  context = prepare_oauth_context()
211
186
  code = _await_callback(context)
@@ -245,9 +220,6 @@ def _perform_authentication() -> None:
245
220
  "Claude Code models added to your configuration. Use the `claude-code-` prefix!"
246
221
  )
247
222
 
248
- # Reload the current agent so the new auth token is picked up immediately
249
- _reload_current_agent()
250
-
251
223
 
252
224
  def _handle_custom_command(command: str, name: str) -> Optional[bool]:
253
225
  if not name:
@@ -261,7 +233,7 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
261
233
  "Existing Claude Code tokens found. Continuing will overwrite them."
262
234
  )
263
235
  _perform_authentication()
264
- set_model_name("claude-code-claude-opus-4-5-20251101")
236
+ set_model_and_reload_agent("claude-code-claude-opus-4-5-20251101")
265
237
  return True
266
238
 
267
239
  if name == "claude-code-status":
@@ -21,7 +21,10 @@ from .config import (
21
21
  get_token_storage_path,
22
22
  )
23
23
 
24
- TOKEN_REFRESH_BUFFER_SECONDS = 60
24
+ # Proactive refresh buffer: refresh tokens 1 hour before expiration
25
+ # This ensures smooth operation by refreshing during model requests,
26
+ # well before the token actually expires
27
+ TOKEN_REFRESH_BUFFER_SECONDS = 3600 # 1 hour
25
28
 
26
29
  logger = logging.getLogger(__name__)
27
30
 
@@ -0,0 +1,25 @@
1
+ """Frontend emitter plugin for Code Puppy.
2
+
3
+ This plugin provides event emission capabilities for frontend integration,
4
+ allowing WebSocket handlers to subscribe to real-time events from the
5
+ agent system including tool calls, streaming events, and agent invocations.
6
+
7
+ Usage:
8
+ from code_puppy.plugins.frontend_emitter.emitter import (
9
+ emit_event,
10
+ subscribe,
11
+ unsubscribe,
12
+ get_recent_events,
13
+ )
14
+
15
+ # Subscribe to events
16
+ queue = subscribe()
17
+
18
+ # Process events in your WebSocket handler
19
+ while True:
20
+ event = await queue.get()
21
+ await websocket.send_json(event)
22
+
23
+ # Clean up
24
+ unsubscribe(queue)
25
+ """
@@ -0,0 +1,121 @@
1
+ """Event emitter for frontend integration.
2
+
3
+ Provides a global event queue that WebSocket handlers can subscribe to.
4
+ Events are JSON-serializable dicts with type, timestamp, and data.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Set
11
+ from uuid import uuid4
12
+
13
+ from code_puppy.config import (
14
+ get_frontend_emitter_enabled,
15
+ get_frontend_emitter_max_recent_events,
16
+ get_frontend_emitter_queue_size,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Global state for event distribution
22
+ _subscribers: Set[asyncio.Queue[Dict[str, Any]]] = set()
23
+ _recent_events: List[Dict[str, Any]] = [] # Keep last N events for new subscribers
24
+
25
+
26
+ def emit_event(event_type: str, data: Any = None) -> None:
27
+ """Emit an event to all subscribers.
28
+
29
+ Creates a structured event dict with unique ID, type, timestamp, and data,
30
+ then broadcasts it to all active subscriber queues.
31
+
32
+ Args:
33
+ event_type: Type of event (e.g., "tool_call_start", "stream_token")
34
+ data: Event data payload - should be JSON-serializable
35
+ """
36
+ # Early return if emitter is disabled
37
+ if not get_frontend_emitter_enabled():
38
+ return
39
+
40
+ event: Dict[str, Any] = {
41
+ "id": str(uuid4()),
42
+ "type": event_type,
43
+ "timestamp": datetime.now(timezone.utc).isoformat(),
44
+ "data": data or {},
45
+ }
46
+
47
+ # Store in recent events for replay to new subscribers
48
+ max_recent = get_frontend_emitter_max_recent_events()
49
+ _recent_events.append(event)
50
+ if len(_recent_events) > max_recent:
51
+ _recent_events.pop(0)
52
+
53
+ # Broadcast to all active subscribers
54
+ for subscriber_queue in _subscribers.copy():
55
+ try:
56
+ subscriber_queue.put_nowait(event)
57
+ except asyncio.QueueFull:
58
+ logger.warning(f"Subscriber queue full, dropping event: {event_type}")
59
+ except Exception as e:
60
+ logger.error(f"Failed to emit event to subscriber: {e}")
61
+
62
+
63
+ def subscribe() -> asyncio.Queue[Dict[str, Any]]:
64
+ """Subscribe to events.
65
+
66
+ Creates and returns a new async queue that will receive all future events.
67
+ The queue has a configurable max size (via frontend_emitter_queue_size)
68
+ to prevent unbounded memory growth if the subscriber is slow to process events.
69
+
70
+ Returns:
71
+ An asyncio.Queue that will receive event dictionaries.
72
+ """
73
+ queue_size = get_frontend_emitter_queue_size()
74
+ queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue(maxsize=queue_size)
75
+ _subscribers.add(queue)
76
+ logger.debug(f"New subscriber added, total subscribers: {len(_subscribers)}")
77
+ return queue
78
+
79
+
80
+ def unsubscribe(queue: asyncio.Queue[Dict[str, Any]]) -> None:
81
+ """Unsubscribe from events.
82
+
83
+ Removes the queue from the subscriber set. Safe to call even if the queue
84
+ was never subscribed or already unsubscribed.
85
+
86
+ Args:
87
+ queue: The queue returned from subscribe()
88
+ """
89
+ _subscribers.discard(queue)
90
+ logger.debug(f"Subscriber removed, remaining subscribers: {len(_subscribers)}")
91
+
92
+
93
+ def get_recent_events() -> List[Dict[str, Any]]:
94
+ """Get recent events for new subscribers.
95
+
96
+ Returns a copy of the most recent events (up to frontend_emitter_max_recent_events).
97
+ Useful for allowing new WebSocket connections to "catch up" on
98
+ recent activity.
99
+
100
+ Returns:
101
+ A list of recent event dictionaries.
102
+ """
103
+ return _recent_events.copy()
104
+
105
+
106
+ def get_subscriber_count() -> int:
107
+ """Get the current number of active subscribers.
108
+
109
+ Returns:
110
+ Number of active subscriber queues.
111
+ """
112
+ return len(_subscribers)
113
+
114
+
115
+ def clear_recent_events() -> None:
116
+ """Clear the recent events buffer.
117
+
118
+ Useful for testing or resetting state.
119
+ """
120
+ _recent_events.clear()
121
+ logger.debug("Recent events cleared")