code-puppy 0.0.302__py3-none-any.whl → 0.0.323__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 (65) hide show
  1. code_puppy/agents/base_agent.py +373 -46
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +795 -0
  4. code_puppy/command_line/add_model_menu.py +8 -1
  5. code_puppy/command_line/autosave_menu.py +266 -35
  6. code_puppy/command_line/colors_menu.py +515 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +59 -10
  9. code_puppy/command_line/core_commands.py +19 -7
  10. code_puppy/command_line/mcp/edit_command.py +3 -1
  11. code_puppy/command_line/mcp/handler.py +7 -2
  12. code_puppy/command_line/mcp/install_command.py +8 -3
  13. code_puppy/command_line/mcp/logs_command.py +173 -64
  14. code_puppy/command_line/mcp/restart_command.py +7 -2
  15. code_puppy/command_line/mcp/search_command.py +10 -4
  16. code_puppy/command_line/mcp/start_all_command.py +16 -6
  17. code_puppy/command_line/mcp/start_command.py +3 -1
  18. code_puppy/command_line/mcp/status_command.py +2 -1
  19. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  20. code_puppy/command_line/mcp/stop_command.py +3 -1
  21. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  22. code_puppy/command_line/model_settings_menu.py +53 -7
  23. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  24. code_puppy/command_line/session_commands.py +11 -4
  25. code_puppy/config.py +103 -15
  26. code_puppy/keymap.py +8 -2
  27. code_puppy/main.py +5 -828
  28. code_puppy/mcp_/__init__.py +17 -0
  29. code_puppy/mcp_/blocking_startup.py +61 -32
  30. code_puppy/mcp_/config_wizard.py +5 -1
  31. code_puppy/mcp_/managed_server.py +23 -3
  32. code_puppy/mcp_/manager.py +65 -0
  33. code_puppy/mcp_/mcp_logs.py +224 -0
  34. code_puppy/messaging/__init__.py +20 -4
  35. code_puppy/messaging/bus.py +64 -0
  36. code_puppy/messaging/markdown_patches.py +57 -0
  37. code_puppy/messaging/messages.py +16 -0
  38. code_puppy/messaging/renderers.py +21 -9
  39. code_puppy/messaging/rich_renderer.py +113 -67
  40. code_puppy/messaging/spinner/console_spinner.py +34 -0
  41. code_puppy/model_factory.py +185 -30
  42. code_puppy/model_utils.py +57 -48
  43. code_puppy/models.json +19 -5
  44. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  45. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  46. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  47. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  48. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  49. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  50. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  51. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  52. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  53. code_puppy/prompts/codex_system_prompt.md +310 -0
  54. code_puppy/pydantic_patches.py +131 -0
  55. code_puppy/terminal_utils.py +126 -0
  56. code_puppy/tools/agent_tools.py +34 -9
  57. code_puppy/tools/command_runner.py +361 -32
  58. code_puppy/tools/file_operations.py +33 -45
  59. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  60. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/METADATA +1 -1
  61. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/RECORD +65 -57
  62. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  63. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  64. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  65. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
@@ -4,10 +4,23 @@ import asyncio
4
4
  import json
5
5
  import math
6
6
  import signal
7
+ import sys
7
8
  import threading
8
9
  import uuid
9
10
  from abc import ABC, abstractmethod
10
- from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union
11
+ from collections.abc import AsyncIterable
12
+ from typing import (
13
+ Any,
14
+ Callable,
15
+ Dict,
16
+ List,
17
+ Optional,
18
+ Sequence,
19
+ Set,
20
+ Tuple,
21
+ Type,
22
+ Union,
23
+ )
11
24
 
12
25
  import mcp
13
26
  import pydantic
@@ -18,6 +31,7 @@ from pydantic_ai import (
18
31
  BinaryContent,
19
32
  DocumentUrl,
20
33
  ImageUrl,
34
+ PartEndEvent,
21
35
  RunContext,
22
36
  UsageLimitExceeded,
23
37
  UsageLimits,
@@ -33,6 +47,7 @@ from pydantic_ai.messages import (
33
47
  ToolReturn,
34
48
  ToolReturnPart,
35
49
  )
50
+ from rich.text import Text
36
51
 
37
52
  # Consolidated relative imports
38
53
  from code_puppy.config import (
@@ -44,11 +59,10 @@ from code_puppy.config import (
44
59
  get_protected_token_count,
45
60
  get_use_dbos,
46
61
  get_value,
47
- load_mcp_server_configs,
48
62
  )
49
63
  from code_puppy.error_logging import log_error
50
64
  from code_puppy.keymap import cancel_agent_uses_signal, get_cancel_agent_char_code
51
- from code_puppy.mcp_ import ServerConfig, get_mcp_manager
65
+ from code_puppy.mcp_ import get_mcp_manager
52
66
  from code_puppy.messaging import (
53
67
  emit_error,
54
68
  emit_info,
@@ -87,6 +101,9 @@ class BaseAgent(ABC):
87
101
  # Cache for MCP tool definitions (for token estimation)
88
102
  # This is populated after the first successful run when MCP tools are retrieved
89
103
  self._mcp_tool_definitions_cache: List[Dict[str, Any]] = []
104
+ # Shared console for streaming output - should be set by cli_runner
105
+ # to avoid conflicts between spinner's Live display and response streaming
106
+ self._console: Optional[Any] = None
90
107
 
91
108
  @property
92
109
  @abstractmethod
@@ -364,7 +381,9 @@ class BaseAgent(ABC):
364
381
  # fixed instructions. For other models, count the full system prompt.
365
382
  try:
366
383
  from code_puppy.model_utils import (
384
+ get_chatgpt_codex_instructions,
367
385
  get_claude_code_instructions,
386
+ is_chatgpt_codex_model,
368
387
  is_claude_code_model,
369
388
  )
370
389
 
@@ -376,6 +395,11 @@ class BaseAgent(ABC):
376
395
  # The full system prompt is already in the message history
377
396
  instructions = get_claude_code_instructions()
378
397
  total_tokens += self.estimate_token_count(instructions)
398
+ elif is_chatgpt_codex_model(model_name):
399
+ # For ChatGPT Codex models, only count the short fixed instructions
400
+ # The full system prompt is already in the message history
401
+ instructions = get_chatgpt_codex_instructions()
402
+ total_tokens += self.estimate_token_count(instructions)
379
403
  else:
380
404
  # For other models, count the full system prompt
381
405
  system_prompt = self.get_system_prompt()
@@ -979,45 +1003,31 @@ class BaseAgent(ABC):
979
1003
  return self._puppy_rules
980
1004
 
981
1005
  def load_mcp_servers(self, extra_headers: Optional[Dict[str, str]] = None):
982
- """Load MCP servers through the manager and return pydantic-ai compatible servers."""
1006
+ """Load MCP servers through the manager and return pydantic-ai compatible servers.
1007
+
1008
+ Note: The manager automatically syncs from mcp_servers.json during initialization,
1009
+ so we don't need to sync here. Use reload_mcp_servers() to force a re-sync.
1010
+ """
983
1011
 
984
1012
  mcp_disabled = get_value("disable_mcp_servers")
985
1013
  if mcp_disabled and str(mcp_disabled).lower() in ("1", "true", "yes", "on"):
986
1014
  return []
987
1015
 
988
1016
  manager = get_mcp_manager()
989
- configs = load_mcp_server_configs()
990
- if not configs:
991
- existing_servers = manager.list_servers()
992
- if not existing_servers:
993
- return []
994
- else:
995
- for name, conf in configs.items():
996
- try:
997
- server_config = ServerConfig(
998
- id=conf.get("id", f"{name}_{hash(name)}"),
999
- name=name,
1000
- type=conf.get("type", "sse"),
1001
- enabled=conf.get("enabled", True),
1002
- config=conf,
1003
- )
1004
- existing = manager.get_server_by_name(name)
1005
- if not existing:
1006
- manager.register_server(server_config)
1007
- else:
1008
- if existing.config != server_config.config:
1009
- manager.update_server(existing.id, server_config)
1010
- except Exception:
1011
- continue
1012
-
1013
1017
  return manager.get_servers_for_agent()
1014
1018
 
1015
1019
  def reload_mcp_servers(self):
1016
- """Reload MCP servers and return updated servers."""
1020
+ """Reload MCP servers and return updated servers.
1021
+
1022
+ Forces a re-sync from mcp_servers.json to pick up any configuration changes.
1023
+ """
1017
1024
  # Clear the MCP tool cache when servers are reloaded
1018
1025
  self._mcp_tool_definitions_cache = []
1019
- self.load_mcp_servers()
1026
+
1027
+ # Force re-sync from mcp_servers.json
1020
1028
  manager = get_mcp_manager()
1029
+ manager.sync_from_config()
1030
+
1021
1031
  return manager.get_servers_for_agent()
1022
1032
 
1023
1033
  def _load_model_with_fallback(
@@ -1173,7 +1183,9 @@ class BaseAgent(ABC):
1173
1183
 
1174
1184
  if len(filtered_mcp_servers) != len(mcp_servers):
1175
1185
  emit_info(
1176
- f"[dim]Filtered {len(mcp_servers) - len(filtered_mcp_servers)} conflicting MCP tools[/dim]"
1186
+ Text.from_markup(
1187
+ f"[dim]Filtered {len(mcp_servers) - len(filtered_mcp_servers)} conflicting MCP tools[/dim]"
1188
+ )
1177
1189
  )
1178
1190
 
1179
1191
  self._last_model_name = resolved_model_name
@@ -1230,6 +1242,74 @@ class BaseAgent(ABC):
1230
1242
  self._mcp_servers = mcp_servers
1231
1243
  return self._code_generation_agent
1232
1244
 
1245
+ def _create_agent_with_output_type(self, output_type: Type[Any]) -> PydanticAgent:
1246
+ """Create a temporary agent configured with a custom output_type.
1247
+
1248
+ This is used when structured output is requested via run_with_mcp.
1249
+ The agent is created fresh with the same configuration as the main agent
1250
+ but with the specified output_type instead of str.
1251
+
1252
+ Args:
1253
+ output_type: The Pydantic model or type for structured output.
1254
+
1255
+ Returns:
1256
+ A configured PydanticAgent (or DBOSAgent wrapper) with the custom output_type.
1257
+ """
1258
+ from code_puppy.model_utils import prepare_prompt_for_model
1259
+ from code_puppy.tools import register_tools_for_agent
1260
+
1261
+ model_name = self.get_model_name()
1262
+ models_config = ModelFactory.load_config()
1263
+ model, resolved_model_name = self._load_model_with_fallback(
1264
+ model_name, models_config, str(uuid.uuid4())
1265
+ )
1266
+
1267
+ instructions = self.get_system_prompt()
1268
+ puppy_rules = self.load_puppy_rules()
1269
+ if puppy_rules:
1270
+ instructions += f"\n{puppy_rules}"
1271
+
1272
+ mcp_servers = getattr(self, "_mcp_servers", []) or []
1273
+ model_settings = make_model_settings(resolved_model_name)
1274
+
1275
+ prepared = prepare_prompt_for_model(
1276
+ model_name, instructions, "", prepend_system_to_user=False
1277
+ )
1278
+ instructions = prepared.instructions
1279
+
1280
+ global _reload_count
1281
+ _reload_count += 1
1282
+
1283
+ if get_use_dbos():
1284
+ temp_agent = PydanticAgent(
1285
+ model=model,
1286
+ instructions=instructions,
1287
+ output_type=output_type,
1288
+ retries=3,
1289
+ toolsets=[],
1290
+ history_processors=[self.message_history_accumulator],
1291
+ model_settings=model_settings,
1292
+ )
1293
+ agent_tools = self.get_available_tools()
1294
+ register_tools_for_agent(temp_agent, agent_tools)
1295
+ dbos_agent = DBOSAgent(
1296
+ temp_agent, name=f"{self.name}-structured-{_reload_count}"
1297
+ )
1298
+ return dbos_agent
1299
+ else:
1300
+ temp_agent = PydanticAgent(
1301
+ model=model,
1302
+ instructions=instructions,
1303
+ output_type=output_type,
1304
+ retries=3,
1305
+ toolsets=mcp_servers,
1306
+ history_processors=[self.message_history_accumulator],
1307
+ model_settings=model_settings,
1308
+ )
1309
+ agent_tools = self.get_available_tools()
1310
+ register_tools_for_agent(temp_agent, agent_tools)
1311
+ return temp_agent
1312
+
1233
1313
  # It's okay to decorate it with DBOS.step even if not using DBOS; the decorator is a no-op in that case.
1234
1314
  @DBOS.step()
1235
1315
  def message_history_accumulator(self, ctx: RunContext, messages: List[Any]):
@@ -1255,6 +1335,187 @@ class BaseAgent(ABC):
1255
1335
  self.set_message_history(result_messages_filtered_empty_thinking)
1256
1336
  return self.get_message_history()
1257
1337
 
1338
+ async def _event_stream_handler(
1339
+ self, ctx: RunContext, events: AsyncIterable[Any]
1340
+ ) -> None:
1341
+ """Handle streaming events from the agent run.
1342
+
1343
+ This method processes streaming events and emits TextPart and ThinkingPart
1344
+ content with styled banners as they stream in.
1345
+
1346
+ Args:
1347
+ ctx: The run context.
1348
+ events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
1349
+ """
1350
+ from pydantic_ai import PartDeltaEvent, PartStartEvent
1351
+ from pydantic_ai.messages import TextPartDelta, ThinkingPartDelta
1352
+ from rich.console import Console
1353
+ from rich.markdown import Markdown
1354
+ from rich.markup import escape
1355
+
1356
+ from code_puppy.messaging.spinner import pause_all_spinners
1357
+
1358
+ # IMPORTANT: Use the shared console (set by cli_runner) to avoid conflicts
1359
+ # with the spinner's Live display. Multiple Console instances with separate
1360
+ # Live displays cause cursor positioning chaos and line duplication.
1361
+ if self._console is not None:
1362
+ console = self._console
1363
+ else:
1364
+ # Fallback if console not set (shouldn't happen in normal use)
1365
+ console = Console()
1366
+
1367
+ # Track which part indices we're currently streaming (for Text/Thinking parts)
1368
+ streaming_parts: set[int] = set()
1369
+ thinking_parts: set[int] = (
1370
+ set()
1371
+ ) # Track which parts are thinking (for dim style)
1372
+ text_parts: set[int] = set() # Track which parts are text
1373
+ banner_printed: set[int] = set() # Track if banner was already printed
1374
+ text_buffer: dict[int, list[str]] = {} # Buffer text for final markdown render
1375
+ token_count: dict[int, int] = {} # Track token count per text part
1376
+ did_stream_anything = False # Track if we streamed any content
1377
+
1378
+ def _print_thinking_banner() -> None:
1379
+ """Print the THINKING banner with spinner pause and line clear."""
1380
+ nonlocal did_stream_anything
1381
+ import sys
1382
+ import time
1383
+
1384
+ from code_puppy.config import get_banner_color
1385
+
1386
+ pause_all_spinners()
1387
+ time.sleep(0.1) # Delay to let spinner fully clear
1388
+ sys.stdout.write("\r\x1b[K") # Clear line
1389
+ sys.stdout.flush()
1390
+ console.print() # Newline before banner
1391
+ # Bold banner with configurable color and lightning bolt
1392
+ thinking_color = get_banner_color("thinking")
1393
+ console.print(
1394
+ Text.from_markup(
1395
+ f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]⚡ "
1396
+ ),
1397
+ end="",
1398
+ )
1399
+ sys.stdout.flush()
1400
+ did_stream_anything = True
1401
+
1402
+ def _print_response_banner() -> None:
1403
+ """Print the AGENT RESPONSE banner with spinner pause and line clear."""
1404
+ nonlocal did_stream_anything
1405
+ import sys
1406
+ import time
1407
+
1408
+ from code_puppy.config import get_banner_color
1409
+
1410
+ pause_all_spinners()
1411
+ time.sleep(0.1) # Delay to let spinner fully clear
1412
+ sys.stdout.write("\r\x1b[K") # Clear line
1413
+ sys.stdout.flush()
1414
+ console.print() # Newline before banner
1415
+ response_color = get_banner_color("agent_response")
1416
+ console.print(
1417
+ Text.from_markup(
1418
+ f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
1419
+ )
1420
+ )
1421
+ sys.stdout.flush()
1422
+ did_stream_anything = True
1423
+
1424
+ async for event in events:
1425
+ # PartStartEvent - register the part but defer banner until content arrives
1426
+ if isinstance(event, PartStartEvent):
1427
+ part = event.part
1428
+ if isinstance(part, ThinkingPart):
1429
+ streaming_parts.add(event.index)
1430
+ thinking_parts.add(event.index)
1431
+ # If there's initial content, print banner + content now
1432
+ if part.content and part.content.strip():
1433
+ _print_thinking_banner()
1434
+ escaped = escape(part.content)
1435
+ console.print(f"[dim]{escaped}[/dim]", end="")
1436
+ banner_printed.add(event.index)
1437
+ elif isinstance(part, TextPart):
1438
+ streaming_parts.add(event.index)
1439
+ text_parts.add(event.index)
1440
+ text_buffer[event.index] = [] # Initialize buffer
1441
+ token_count[event.index] = 0 # Initialize token counter
1442
+ # Buffer initial content if present
1443
+ if part.content and part.content.strip():
1444
+ text_buffer[event.index].append(part.content)
1445
+ token_count[event.index] += 1
1446
+
1447
+ # PartDeltaEvent - stream the content as it arrives
1448
+ elif isinstance(event, PartDeltaEvent):
1449
+ if event.index in streaming_parts:
1450
+ delta = event.delta
1451
+ if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
1452
+ if delta.content_delta:
1453
+ # For text parts, show token counter then render at end
1454
+ if event.index in text_parts:
1455
+ import sys
1456
+
1457
+ # Print banner on first content
1458
+ if event.index not in banner_printed:
1459
+ _print_response_banner()
1460
+ banner_printed.add(event.index)
1461
+ # Accumulate text for final markdown render
1462
+ text_buffer[event.index].append(delta.content_delta)
1463
+ token_count[event.index] += 1
1464
+ # Update token counter in place (single line)
1465
+ count = token_count[event.index]
1466
+ sys.stdout.write(
1467
+ f"\r\x1b[K ⏳ Receiving... {count} tokens"
1468
+ )
1469
+ sys.stdout.flush()
1470
+ else:
1471
+ # For thinking parts, stream immediately (dim)
1472
+ if event.index not in banner_printed:
1473
+ _print_thinking_banner()
1474
+ banner_printed.add(event.index)
1475
+ escaped = escape(delta.content_delta)
1476
+ console.print(f"[dim]{escaped}[/dim]", end="")
1477
+
1478
+ # PartEndEvent - finish the streaming with a newline
1479
+ elif isinstance(event, PartEndEvent):
1480
+ if event.index in streaming_parts:
1481
+ # For text parts, clear counter line and render markdown
1482
+ if event.index in text_parts:
1483
+ import sys
1484
+
1485
+ # Clear the token counter line
1486
+ sys.stdout.write("\r\x1b[K")
1487
+ sys.stdout.flush()
1488
+ # Render the final markdown nicely
1489
+ if event.index in text_buffer:
1490
+ try:
1491
+ final_content = "".join(text_buffer[event.index])
1492
+ if final_content.strip():
1493
+ console.print(Markdown(final_content))
1494
+ except Exception:
1495
+ pass
1496
+ del text_buffer[event.index]
1497
+ # Clean up token count
1498
+ token_count.pop(event.index, None)
1499
+ # For thinking parts, just print newline
1500
+ elif event.index in banner_printed:
1501
+ console.print() # Final newline after streaming
1502
+ # Clean up all tracking sets
1503
+ streaming_parts.discard(event.index)
1504
+ thinking_parts.discard(event.index)
1505
+ text_parts.discard(event.index)
1506
+ banner_printed.discard(event.index)
1507
+
1508
+ # Resume spinner if next part is NOT text/thinking (avoid race condition)
1509
+ # If next part is a tool call or None, it's safe to resume
1510
+ # Note: spinner itself handles blank line before appearing
1511
+ from code_puppy.messaging.spinner import resume_all_spinners
1512
+
1513
+ next_kind = getattr(event, "next_part_kind", None)
1514
+ if next_kind not in ("text", "thinking"):
1515
+ resume_all_spinners()
1516
+
1517
+ # Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
1518
+
1258
1519
  def _spawn_ctrl_x_key_listener(
1259
1520
  self,
1260
1521
  stop_event: threading.Event,
@@ -1409,6 +1670,7 @@ class BaseAgent(ABC):
1409
1670
  *,
1410
1671
  attachments: Optional[Sequence[BinaryContent]] = None,
1411
1672
  link_attachments: Optional[Sequence[Union[ImageUrl, DocumentUrl]]] = None,
1673
+ output_type: Optional[Type[Any]] = None,
1412
1674
  **kwargs,
1413
1675
  ) -> Any:
1414
1676
  """Run the agent with MCP servers, attachments, and full cancellation support.
@@ -1417,10 +1679,13 @@ class BaseAgent(ABC):
1417
1679
  prompt: Primary user prompt text (may be empty when attachments present).
1418
1680
  attachments: Local binary payloads (e.g., dragged images) to include.
1419
1681
  link_attachments: Remote assets (image/document URLs) to include.
1682
+ output_type: Optional Pydantic model or type for structured output.
1683
+ When provided, creates a temporary agent configured to return
1684
+ this type instead of the default string output.
1420
1685
  **kwargs: Additional arguments forwarded to `pydantic_ai.Agent.run`.
1421
1686
 
1422
1687
  Returns:
1423
- The agent's response.
1688
+ The agent's response (typed according to output_type if specified).
1424
1689
 
1425
1690
  Raises:
1426
1691
  asyncio.CancelledError: When execution is cancelled by user.
@@ -1444,10 +1709,17 @@ class BaseAgent(ABC):
1444
1709
  pydantic_agent = (
1445
1710
  self._code_generation_agent or self.reload_code_generation_agent()
1446
1711
  )
1447
- # Handle claude-code models: prepend system prompt to first user message
1448
- from code_puppy.model_utils import is_claude_code_model
1449
1712
 
1450
- if is_claude_code_model(self.get_model_name()):
1713
+ # If a custom output_type is specified, create a temporary agent with that type
1714
+ if output_type is not None:
1715
+ pydantic_agent = self._create_agent_with_output_type(output_type)
1716
+
1717
+ # Handle claude-code and chatgpt-codex models: prepend system prompt to first user message
1718
+ from code_puppy.model_utils import is_chatgpt_codex_model, is_claude_code_model
1719
+
1720
+ if is_claude_code_model(self.get_model_name()) or is_chatgpt_codex_model(
1721
+ self.get_model_name()
1722
+ ):
1451
1723
  if len(self.get_message_history()) == 0:
1452
1724
  system_prompt = self.get_system_prompt()
1453
1725
  puppy_rules = self.load_puppy_rules()
@@ -1511,6 +1783,7 @@ class BaseAgent(ABC):
1511
1783
  prompt_payload,
1512
1784
  message_history=self.get_message_history(),
1513
1785
  usage_limits=usage_limits,
1786
+ event_stream_handler=self._event_stream_handler,
1514
1787
  **kwargs,
1515
1788
  )
1516
1789
  finally:
@@ -1523,6 +1796,7 @@ class BaseAgent(ABC):
1523
1796
  prompt_payload,
1524
1797
  message_history=self.get_message_history(),
1525
1798
  usage_limits=usage_limits,
1799
+ event_stream_handler=self._event_stream_handler,
1526
1800
  **kwargs,
1527
1801
  )
1528
1802
  else:
@@ -1531,6 +1805,7 @@ class BaseAgent(ABC):
1531
1805
  prompt_payload,
1532
1806
  message_history=self.get_message_history(),
1533
1807
  usage_limits=usage_limits,
1808
+ event_stream_handler=self._event_stream_handler,
1534
1809
  **kwargs,
1535
1810
  )
1536
1811
  return result_
@@ -1636,29 +1911,72 @@ class BaseAgent(ABC):
1636
1911
  # When using keyboard-based cancel, SIGINT should be a no-op
1637
1912
  # (just show a hint to user about the configured cancel key)
1638
1913
  from code_puppy.keymap import get_cancel_agent_display_name
1914
+ import sys
1639
1915
 
1640
1916
  cancel_key = get_cancel_agent_display_name()
1641
- emit_info(f"Use {cancel_key} to cancel the agent task.")
1917
+ if sys.platform == "win32":
1918
+ # On Windows, we use keyboard listener, so SIGINT might still fire
1919
+ # but we handle cancellation via the key listener
1920
+ pass # Silent on Windows - the key listener handles it
1921
+ else:
1922
+ emit_info(f"Use {cancel_key} to cancel the agent task.")
1642
1923
 
1643
1924
  original_handler = None
1644
1925
  key_listener_stop_event = None
1645
1926
  _key_listener_thread = None
1927
+ _windows_ctrl_handler = None # Store reference to prevent garbage collection
1646
1928
 
1647
1929
  try:
1648
- if cancel_agent_uses_signal():
1649
- # Use SIGINT-based cancellation (default Ctrl+C behavior)
1930
+ if sys.platform == "win32":
1931
+ # Windows: Use SetConsoleCtrlHandler for reliable Ctrl+C handling
1932
+ import ctypes
1933
+
1934
+ # Define the handler function type
1935
+ HANDLER_ROUTINE = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_ulong)
1936
+
1937
+ def windows_ctrl_handler(ctrl_type):
1938
+ """Handle Windows console control events."""
1939
+ CTRL_C_EVENT = 0
1940
+ CTRL_BREAK_EVENT = 1
1941
+
1942
+ if ctrl_type in (CTRL_C_EVENT, CTRL_BREAK_EVENT):
1943
+ # Check if we're awaiting user input
1944
+ if is_awaiting_user_input():
1945
+ return False # Let default handler run
1946
+
1947
+ # Schedule agent cancellation
1948
+ schedule_agent_cancel()
1949
+ return True # We handled it, don't terminate
1950
+
1951
+ return False # Let other handlers process it
1952
+
1953
+ # Create the callback - must keep reference alive!
1954
+ _windows_ctrl_handler = HANDLER_ROUTINE(windows_ctrl_handler)
1955
+
1956
+ # Register the handler
1957
+ kernel32 = ctypes.windll.kernel32
1958
+ if not kernel32.SetConsoleCtrlHandler(_windows_ctrl_handler, True):
1959
+ emit_warning("Failed to set Windows Ctrl+C handler")
1960
+
1961
+ # Also spawn keyboard listener for Ctrl+X (shell cancel) and other keys
1962
+ key_listener_stop_event = threading.Event()
1963
+ _key_listener_thread = self._spawn_ctrl_x_key_listener(
1964
+ key_listener_stop_event,
1965
+ on_escape=lambda: None, # Ctrl+X handled by command_runner
1966
+ on_cancel_agent=None, # Ctrl+C handled by SetConsoleCtrlHandler above
1967
+ )
1968
+ elif cancel_agent_uses_signal():
1969
+ # Unix with Ctrl+C: Use SIGINT-based cancellation
1650
1970
  original_handler = signal.signal(
1651
1971
  signal.SIGINT, keyboard_interrupt_handler
1652
1972
  )
1653
1973
  else:
1654
- # Use keyboard listener for agent cancellation
1655
- # Set a graceful SIGINT handler that shows a hint
1974
+ # Unix with different cancel key: Use keyboard listener
1656
1975
  original_handler = signal.signal(signal.SIGINT, graceful_sigint_handler)
1657
- # Spawn keyboard listener with the cancel agent callback
1658
1976
  key_listener_stop_event = threading.Event()
1659
1977
  _key_listener_thread = self._spawn_ctrl_x_key_listener(
1660
1978
  key_listener_stop_event,
1661
- on_escape=lambda: None, # Ctrl+X handled by command_runner
1979
+ on_escape=lambda: None,
1662
1980
  on_cancel_agent=schedule_agent_cancel,
1663
1981
  )
1664
1982
 
@@ -1683,8 +2001,17 @@ class BaseAgent(ABC):
1683
2001
  # Stop keyboard listener if it was started
1684
2002
  if key_listener_stop_event is not None:
1685
2003
  key_listener_stop_event.set()
1686
- # Restore original signal handler
1687
- if (
1688
- original_handler is not None
1689
- ): # Explicit None check - SIG_DFL can be 0/falsy!
2004
+
2005
+ # Unregister Windows Ctrl handler
2006
+ if sys.platform == "win32" and _windows_ctrl_handler is not None:
2007
+ try:
2008
+ import ctypes
2009
+
2010
+ kernel32 = ctypes.windll.kernel32
2011
+ kernel32.SetConsoleCtrlHandler(_windows_ctrl_handler, False)
2012
+ except Exception:
2013
+ pass # Best effort cleanup
2014
+
2015
+ # Restore original signal handler (Unix)
2016
+ if original_handler is not None:
1690
2017
  signal.signal(signal.SIGINT, original_handler)