codepp 0.0.437__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 (288) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +117 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2156 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +532 -0
  57. code_puppy/command_line/command_handler.py +293 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +867 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +96 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,470 @@
1
+ """File Permission Handler Plugin.
2
+
3
+ This plugin handles user permission prompts for file operations,
4
+ providing a consistent and extensible permission system.
5
+ """
6
+
7
+ import difflib
8
+ import os
9
+ import threading
10
+ from typing import Any
11
+
12
+ from rich.text import Text as RichText
13
+
14
+ from code_puppy.callbacks import register_callback
15
+ from code_puppy.config import get_diff_context_lines, get_yolo_mode
16
+ from code_puppy.messaging import emit_warning
17
+ from code_puppy.tools.common import (
18
+ _find_best_window,
19
+ get_user_approval,
20
+ )
21
+
22
+ # Lock for preventing multiple simultaneous permission prompts
23
+ _FILE_CONFIRMATION_LOCK = threading.Lock()
24
+
25
+ # Thread-local storage for user feedback from permission prompts
26
+ _thread_local = threading.local()
27
+
28
+
29
+ def get_last_user_feedback() -> str | None:
30
+ """Get the last user feedback from a permission prompt in this thread.
31
+
32
+ Returns:
33
+ The user feedback string, or None if no feedback was provided.
34
+ """
35
+ return getattr(_thread_local, "last_user_feedback", None)
36
+
37
+
38
+ def _set_user_feedback(feedback: str | None) -> None:
39
+ """Store user feedback in thread-local storage."""
40
+ _thread_local.last_user_feedback = feedback
41
+
42
+
43
+ def clear_user_feedback() -> None:
44
+ """Clear any stored user feedback."""
45
+ _thread_local.last_user_feedback = None
46
+
47
+
48
+ def set_diff_already_shown(shown: bool = True) -> None:
49
+ """Mark that a diff preview was already shown during permission prompt."""
50
+ _thread_local.diff_already_shown = shown
51
+
52
+
53
+ def was_diff_already_shown() -> bool:
54
+ """Check if a diff was already shown during the permission prompt.
55
+
56
+ Returns:
57
+ True if diff was shown, False otherwise
58
+ """
59
+ return getattr(_thread_local, "diff_already_shown", False)
60
+
61
+
62
+ def clear_diff_shown_flag() -> None:
63
+ """Clear the diff-already-shown flag."""
64
+ _thread_local.diff_already_shown = False
65
+
66
+
67
+ # Diff formatting is now handled by common.format_diff_with_colors()
68
+ # Arrow selector and approval UI now handled by common.get_user_approval()
69
+
70
+
71
+ def _preview_delete_snippet(file_path: str, snippet: str) -> str | None:
72
+ """Generate a preview diff for deleting a snippet without modifying the file."""
73
+ try:
74
+ file_path = os.path.abspath(file_path)
75
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
76
+ return None
77
+
78
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
79
+ original = f.read()
80
+
81
+ # Sanitize any surrogate characters
82
+ try:
83
+ original = original.encode("utf-8", errors="surrogatepass").decode(
84
+ "utf-8", errors="replace"
85
+ )
86
+ except (UnicodeEncodeError, UnicodeDecodeError):
87
+ pass
88
+
89
+ if snippet not in original:
90
+ return None
91
+
92
+ modified = original.replace(snippet, "")
93
+ diff_text = "".join(
94
+ difflib.unified_diff(
95
+ original.splitlines(keepends=True),
96
+ modified.splitlines(keepends=True),
97
+ fromfile=f"a/{os.path.basename(file_path)}",
98
+ tofile=f"b/{os.path.basename(file_path)}",
99
+ n=get_diff_context_lines(),
100
+ )
101
+ )
102
+ return diff_text
103
+ except Exception:
104
+ return None
105
+
106
+
107
+ def _preview_write_to_file(
108
+ file_path: str, content: str, overwrite: bool = False
109
+ ) -> str | None:
110
+ """Generate a preview diff for writing to a file without modifying it."""
111
+ try:
112
+ file_path = os.path.abspath(file_path)
113
+ exists = os.path.exists(file_path)
114
+
115
+ if exists and not overwrite:
116
+ return None
117
+
118
+ diff_lines = difflib.unified_diff(
119
+ [] if not exists else [""],
120
+ content.splitlines(keepends=True),
121
+ fromfile="/dev/null" if not exists else f"a/{os.path.basename(file_path)}",
122
+ tofile=f"b/{os.path.basename(file_path)}",
123
+ n=get_diff_context_lines(),
124
+ )
125
+ return "".join(diff_lines)
126
+ except Exception:
127
+ return None
128
+
129
+
130
+ def _preview_replace_in_file(
131
+ file_path: str, replacements: list[dict[str, str]]
132
+ ) -> str | None:
133
+ """Generate a preview diff for replacing text in a file without modifying the file."""
134
+ try:
135
+ file_path = os.path.abspath(file_path)
136
+
137
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
138
+ original = f.read()
139
+
140
+ # Sanitize any surrogate characters
141
+ try:
142
+ original = original.encode("utf-8", errors="surrogatepass").decode(
143
+ "utf-8", errors="replace"
144
+ )
145
+ except (UnicodeEncodeError, UnicodeDecodeError):
146
+ pass
147
+
148
+ modified = original
149
+ for rep in replacements:
150
+ old_snippet = rep.get("old_str", "")
151
+ new_snippet = rep.get("new_str", "")
152
+
153
+ if old_snippet and old_snippet in modified:
154
+ modified = modified.replace(old_snippet, new_snippet)
155
+ continue
156
+
157
+ # Use the same logic as file_modifications for fuzzy matching
158
+ orig_lines = modified.splitlines()
159
+ loc, score = _find_best_window(orig_lines, old_snippet)
160
+
161
+ if score < 0.95 or loc is None:
162
+ return None
163
+
164
+ start, end = loc
165
+ had_trailing_newline = modified.endswith("\n")
166
+ prefix = "\n".join(orig_lines[:start])
167
+ suffix = "\n".join(orig_lines[end:])
168
+ parts = []
169
+ if prefix:
170
+ parts.append(prefix)
171
+ parts.append(new_snippet.rstrip("\n"))
172
+ if suffix:
173
+ parts.append(suffix)
174
+ modified = "\n".join(parts)
175
+ if had_trailing_newline and not modified.endswith("\n"):
176
+ modified += "\n"
177
+
178
+ if modified == original:
179
+ return None
180
+
181
+ diff_text = "".join(
182
+ difflib.unified_diff(
183
+ original.splitlines(keepends=True),
184
+ modified.splitlines(keepends=True),
185
+ fromfile=f"a/{os.path.basename(file_path)}",
186
+ tofile=f"b/{os.path.basename(file_path)}",
187
+ n=get_diff_context_lines(),
188
+ )
189
+ )
190
+ return diff_text
191
+ except Exception:
192
+ return None
193
+
194
+
195
+ def _preview_delete_file(file_path: str) -> str | None:
196
+ """Generate a preview diff for deleting a file without modifying it."""
197
+ try:
198
+ file_path = os.path.abspath(file_path)
199
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
200
+ return None
201
+
202
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
203
+ original = f.read()
204
+
205
+ # Sanitize any surrogate characters
206
+ try:
207
+ original = original.encode("utf-8", errors="surrogatepass").decode(
208
+ "utf-8", errors="replace"
209
+ )
210
+ except (UnicodeEncodeError, UnicodeDecodeError):
211
+ pass
212
+
213
+ diff_text = "".join(
214
+ difflib.unified_diff(
215
+ original.splitlines(keepends=True),
216
+ [],
217
+ fromfile=f"a/{os.path.basename(file_path)}",
218
+ tofile=f"b/{os.path.basename(file_path)}",
219
+ n=get_diff_context_lines(),
220
+ )
221
+ )
222
+ return diff_text
223
+ except Exception:
224
+ return None
225
+
226
+
227
+ def prompt_for_file_permission(
228
+ file_path: str,
229
+ operation: str,
230
+ preview: str | None = None,
231
+ message_group: str | None = None,
232
+ ) -> tuple[bool, str | None]:
233
+ """Prompt the user for permission to perform a file operation.
234
+
235
+ This function provides a unified permission prompt system for all file operations.
236
+
237
+ Args:
238
+ file_path: Path to the file being modified.
239
+ operation: Description of the operation (e.g., "edit", "delete", "create").
240
+ preview: Optional preview of changes (diff or content preview).
241
+ message_group: Optional message group for organizing output.
242
+
243
+ Returns:
244
+ Tuple of (confirmed: bool, user_feedback: str | None)
245
+ - confirmed: True if permission is granted, False otherwise
246
+ - user_feedback: Optional feedback message from user to send back to the model
247
+ """
248
+ yolo_mode = get_yolo_mode()
249
+
250
+ # Skip confirmation only if in yolo mode (removed TTY check for better compatibility)
251
+ if yolo_mode:
252
+ return True, None
253
+
254
+ # Try to acquire the lock to prevent multiple simultaneous prompts
255
+ confirmation_lock_acquired = _FILE_CONFIRMATION_LOCK.acquire(blocking=False)
256
+ if not confirmation_lock_acquired:
257
+ emit_warning(
258
+ "Another file operation is currently awaiting confirmation",
259
+ message_group=message_group,
260
+ )
261
+ return False, None
262
+
263
+ try:
264
+ # Build panel content
265
+ panel_content = RichText()
266
+ panel_content.append("🔒 Requesting permission to ", style="bold yellow")
267
+ panel_content.append(operation, style="bold cyan")
268
+ panel_content.append(":\n", style="bold yellow")
269
+ panel_content.append("📄 ", style="dim")
270
+ panel_content.append(file_path, style="bold white")
271
+
272
+ # Use the common approval function
273
+ confirmed, user_feedback = get_user_approval(
274
+ title="File Operation",
275
+ content=panel_content,
276
+ preview=preview,
277
+ border_style="dim white",
278
+ )
279
+
280
+ return confirmed, user_feedback
281
+
282
+ finally:
283
+ if confirmation_lock_acquired:
284
+ _FILE_CONFIRMATION_LOCK.release()
285
+
286
+
287
+ def handle_edit_file_permission(
288
+ context: Any,
289
+ file_path: str,
290
+ operation_type: str,
291
+ operation_data: Any,
292
+ message_group: str | None = None,
293
+ ) -> bool:
294
+ """Handle permission for edit_file operations with automatic preview generation.
295
+
296
+ Args:
297
+ context: The operation context
298
+ file_path: Path to the file being operated on
299
+ operation_type: Type of edit operation ('write', 'replace', 'delete_snippet')
300
+ operation_data: Operation-specific data (content, replacements, snippet, etc.)
301
+ message_group: Optional message group
302
+
303
+ Returns:
304
+ True if permission granted, False if denied
305
+ """
306
+ preview = None
307
+
308
+ if operation_type == "write":
309
+ content = operation_data.get("content", "")
310
+ overwrite = operation_data.get("overwrite", False)
311
+ preview = _preview_write_to_file(file_path, content, overwrite)
312
+ operation_desc = "write to"
313
+ elif operation_type == "replace":
314
+ replacements = operation_data.get("replacements", [])
315
+ preview = _preview_replace_in_file(file_path, replacements)
316
+ operation_desc = "replace text in"
317
+ elif operation_type == "delete_snippet":
318
+ snippet = operation_data.get("delete_snippet", "")
319
+ preview = _preview_delete_snippet(file_path, snippet)
320
+ operation_desc = "delete snippet from"
321
+ else:
322
+ operation_desc = f"perform {operation_type} operation on"
323
+
324
+ confirmed, user_feedback = prompt_for_file_permission(
325
+ file_path, operation_desc, preview, message_group
326
+ )
327
+ # Store feedback in thread-local storage so the tool can access it
328
+ _set_user_feedback(user_feedback)
329
+ return confirmed
330
+
331
+
332
+ def handle_delete_file_permission(
333
+ context: Any,
334
+ file_path: str,
335
+ message_group: str | None = None,
336
+ ) -> bool:
337
+ """Handle permission for delete_file operations with automatic preview generation.
338
+
339
+ Args:
340
+ context: The operation context
341
+ file_path: Path to the file being deleted
342
+ message_group: Optional message group
343
+
344
+ Returns:
345
+ True if permission granted, False if denied
346
+ """
347
+ preview = _preview_delete_file(file_path)
348
+ confirmed, user_feedback = prompt_for_file_permission(
349
+ file_path, "delete", preview, message_group
350
+ )
351
+ # Store feedback in thread-local storage so the tool can access it
352
+ _set_user_feedback(user_feedback)
353
+ return confirmed
354
+
355
+
356
+ def handle_file_permission(
357
+ context: Any,
358
+ file_path: str,
359
+ operation: str,
360
+ preview: str | None = None,
361
+ message_group: str | None = None,
362
+ operation_data: Any = None,
363
+ ) -> bool:
364
+ """Callback handler for file permission checks.
365
+
366
+ This function is called by file operations to check for user permission.
367
+ It returns True if the operation should proceed, False if it should be cancelled.
368
+
369
+ Args:
370
+ context: The operation context
371
+ file_path: Path to the file being operated on
372
+ operation: Description of the operation
373
+ preview: Optional preview of changes (deprecated - use operation_data instead)
374
+ message_group: Optional message group
375
+ operation_data: Operation-specific data for preview generation
376
+
377
+ Returns:
378
+ True if permission granted, False if denied
379
+ """
380
+ # Generate preview from operation_data if provided
381
+ if operation_data is not None:
382
+ preview = _generate_preview_from_operation_data(
383
+ file_path, operation, operation_data
384
+ )
385
+
386
+ confirmed, user_feedback = prompt_for_file_permission(
387
+ file_path, operation, preview, message_group
388
+ )
389
+ # Store feedback in thread-local storage so the tool can access it
390
+ _set_user_feedback(user_feedback)
391
+ return confirmed
392
+
393
+
394
+ def _generate_preview_from_operation_data(
395
+ file_path: str, operation: str, operation_data: Any
396
+ ) -> str | None:
397
+ """Generate preview diff from operation data.
398
+
399
+ Args:
400
+ file_path: Path to the file
401
+ operation: Type of operation
402
+ operation_data: Operation-specific data
403
+
404
+ Returns:
405
+ Preview diff or None if generation fails
406
+ """
407
+ try:
408
+ if operation == "delete":
409
+ return _preview_delete_file(file_path)
410
+ elif operation == "write":
411
+ content = operation_data.get("content", "")
412
+ overwrite = operation_data.get("overwrite", False)
413
+ return _preview_write_to_file(file_path, content, overwrite)
414
+ elif operation == "delete snippet from":
415
+ snippet = operation_data.get("snippet", "")
416
+ return _preview_delete_snippet(file_path, snippet)
417
+ elif operation == "replace text in":
418
+ replacements = operation_data.get("replacements", [])
419
+ return _preview_replace_in_file(file_path, replacements)
420
+ elif operation == "edit_file":
421
+ # Handle edit_file operations
422
+ if "delete_snippet" in operation_data:
423
+ return _preview_delete_snippet(
424
+ file_path, operation_data["delete_snippet"]
425
+ )
426
+ elif "replacements" in operation_data:
427
+ return _preview_replace_in_file(
428
+ file_path, operation_data["replacements"]
429
+ )
430
+ elif "content" in operation_data:
431
+ content = operation_data.get("content", "")
432
+ overwrite = operation_data.get("overwrite", False)
433
+ return _preview_write_to_file(file_path, content, overwrite)
434
+
435
+ return None
436
+ except Exception:
437
+ return None
438
+
439
+
440
+ def get_permission_handler_help() -> str:
441
+ """Return help information for the file permission handler."""
442
+ return """File Permission Handler Plugin:
443
+ - Unified permission prompts for all file operations
444
+ - YOLO mode support for automatic approval
445
+ - Thread-safe confirmation system
446
+ - Consistent user experience across file operations
447
+ - Detailed preview support with diff highlighting
448
+ - Automatic preview generation from operation data"""
449
+
450
+
451
+ def get_file_permission_prompt_additions() -> str:
452
+ """Return file permission handling prompt additions for agents."""
453
+ if get_yolo_mode():
454
+ return ""
455
+
456
+ return """
457
+ ## User Approval System
458
+
459
+ When file operations are rejected, the response includes a `user_feedback` field:
460
+ - If `user_feedback` has text: implement their suggestion and retry the operation.
461
+ - If `user_feedback` is empty: stop and ask the user what they want instead.
462
+ - Never retry the exact same rejected operation without changes.
463
+ """
464
+
465
+
466
+ # Register the callback for file permission handling
467
+ register_callback("file_permission", handle_file_permission)
468
+
469
+ # Register the prompt hook for file permission instructions
470
+ register_callback("load_prompt", get_file_permission_prompt_additions)
@@ -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")