code-puppy 0.0.214__py3-none-any.whl → 0.0.366__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +2 -0
  3. code_puppy/agents/agent_c_reviewer.py +59 -6
  4. code_puppy/agents/agent_code_puppy.py +7 -1
  5. code_puppy/agents/agent_code_reviewer.py +12 -2
  6. code_puppy/agents/agent_cpp_reviewer.py +73 -6
  7. code_puppy/agents/agent_creator_agent.py +45 -4
  8. code_puppy/agents/agent_golang_reviewer.py +92 -3
  9. code_puppy/agents/agent_javascript_reviewer.py +101 -8
  10. code_puppy/agents/agent_manager.py +81 -4
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +28 -6
  15. code_puppy/agents/agent_qa_expert.py +98 -6
  16. code_puppy/agents/agent_qa_kitten.py +12 -7
  17. code_puppy/agents/agent_security_auditor.py +113 -3
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +106 -7
  20. code_puppy/agents/base_agent.py +802 -176
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/pack/__init__.py +34 -0
  23. code_puppy/agents/pack/bloodhound.py +304 -0
  24. code_puppy/agents/pack/husky.py +321 -0
  25. code_puppy/agents/pack/retriever.py +393 -0
  26. code_puppy/agents/pack/shepherd.py +348 -0
  27. code_puppy/agents/pack/terrier.py +287 -0
  28. code_puppy/agents/pack/watchdog.py +367 -0
  29. code_puppy/agents/prompt_reviewer.py +145 -0
  30. code_puppy/agents/subagent_stream_handler.py +276 -0
  31. code_puppy/api/__init__.py +13 -0
  32. code_puppy/api/app.py +169 -0
  33. code_puppy/api/main.py +21 -0
  34. code_puppy/api/pty_manager.py +446 -0
  35. code_puppy/api/routers/__init__.py +12 -0
  36. code_puppy/api/routers/agents.py +36 -0
  37. code_puppy/api/routers/commands.py +217 -0
  38. code_puppy/api/routers/config.py +74 -0
  39. code_puppy/api/routers/sessions.py +232 -0
  40. code_puppy/api/templates/terminal.html +361 -0
  41. code_puppy/api/websocket.py +154 -0
  42. code_puppy/callbacks.py +142 -4
  43. code_puppy/chatgpt_codex_client.py +283 -0
  44. code_puppy/claude_cache_client.py +586 -0
  45. code_puppy/cli_runner.py +916 -0
  46. code_puppy/command_line/add_model_menu.py +1079 -0
  47. code_puppy/command_line/agent_menu.py +395 -0
  48. code_puppy/command_line/attachments.py +10 -5
  49. code_puppy/command_line/autosave_menu.py +605 -0
  50. code_puppy/command_line/clipboard.py +527 -0
  51. code_puppy/command_line/colors_menu.py +520 -0
  52. code_puppy/command_line/command_handler.py +176 -738
  53. code_puppy/command_line/command_registry.py +150 -0
  54. code_puppy/command_line/config_commands.py +715 -0
  55. code_puppy/command_line/core_commands.py +792 -0
  56. code_puppy/command_line/diff_menu.py +863 -0
  57. code_puppy/command_line/load_context_completion.py +15 -22
  58. code_puppy/command_line/mcp/base.py +0 -3
  59. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  60. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  61. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  62. code_puppy/command_line/mcp/edit_command.py +148 -0
  63. code_puppy/command_line/mcp/handler.py +9 -4
  64. code_puppy/command_line/mcp/help_command.py +6 -5
  65. code_puppy/command_line/mcp/install_command.py +15 -26
  66. code_puppy/command_line/mcp/install_menu.py +685 -0
  67. code_puppy/command_line/mcp/list_command.py +2 -2
  68. code_puppy/command_line/mcp/logs_command.py +174 -65
  69. code_puppy/command_line/mcp/remove_command.py +2 -2
  70. code_puppy/command_line/mcp/restart_command.py +12 -4
  71. code_puppy/command_line/mcp/search_command.py +16 -10
  72. code_puppy/command_line/mcp/start_all_command.py +18 -6
  73. code_puppy/command_line/mcp/start_command.py +47 -25
  74. code_puppy/command_line/mcp/status_command.py +4 -5
  75. code_puppy/command_line/mcp/stop_all_command.py +7 -1
  76. code_puppy/command_line/mcp/stop_command.py +8 -4
  77. code_puppy/command_line/mcp/test_command.py +2 -2
  78. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  79. code_puppy/command_line/mcp_completion.py +174 -0
  80. code_puppy/command_line/model_picker_completion.py +75 -25
  81. code_puppy/command_line/model_settings_menu.py +884 -0
  82. code_puppy/command_line/motd.py +14 -8
  83. code_puppy/command_line/onboarding_slides.py +179 -0
  84. code_puppy/command_line/onboarding_wizard.py +340 -0
  85. code_puppy/command_line/pin_command_completion.py +329 -0
  86. code_puppy/command_line/prompt_toolkit_completion.py +463 -63
  87. code_puppy/command_line/session_commands.py +296 -0
  88. code_puppy/command_line/utils.py +54 -0
  89. code_puppy/config.py +898 -112
  90. code_puppy/error_logging.py +118 -0
  91. code_puppy/gemini_code_assist.py +385 -0
  92. code_puppy/gemini_model.py +602 -0
  93. code_puppy/http_utils.py +210 -148
  94. code_puppy/keymap.py +128 -0
  95. code_puppy/main.py +5 -698
  96. code_puppy/mcp_/__init__.py +17 -0
  97. code_puppy/mcp_/async_lifecycle.py +35 -4
  98. code_puppy/mcp_/blocking_startup.py +70 -43
  99. code_puppy/mcp_/captured_stdio_server.py +2 -2
  100. code_puppy/mcp_/config_wizard.py +4 -4
  101. code_puppy/mcp_/dashboard.py +15 -6
  102. code_puppy/mcp_/managed_server.py +65 -38
  103. code_puppy/mcp_/manager.py +146 -52
  104. code_puppy/mcp_/mcp_logs.py +224 -0
  105. code_puppy/mcp_/registry.py +6 -6
  106. code_puppy/mcp_/server_registry_catalog.py +24 -5
  107. code_puppy/messaging/__init__.py +199 -2
  108. code_puppy/messaging/bus.py +610 -0
  109. code_puppy/messaging/commands.py +167 -0
  110. code_puppy/messaging/markdown_patches.py +57 -0
  111. code_puppy/messaging/message_queue.py +17 -48
  112. code_puppy/messaging/messages.py +500 -0
  113. code_puppy/messaging/queue_console.py +1 -24
  114. code_puppy/messaging/renderers.py +43 -146
  115. code_puppy/messaging/rich_renderer.py +1027 -0
  116. code_puppy/messaging/spinner/__init__.py +21 -5
  117. code_puppy/messaging/spinner/console_spinner.py +86 -51
  118. code_puppy/messaging/subagent_console.py +461 -0
  119. code_puppy/model_factory.py +634 -83
  120. code_puppy/model_utils.py +167 -0
  121. code_puppy/models.json +66 -68
  122. code_puppy/models_dev_api.json +1 -0
  123. code_puppy/models_dev_parser.py +592 -0
  124. code_puppy/plugins/__init__.py +164 -10
  125. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  126. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  127. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  128. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  129. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  130. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  131. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  132. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  133. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  134. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  135. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  136. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  137. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  138. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  139. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  140. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  141. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  142. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  143. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  144. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  145. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  146. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  147. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  148. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  149. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  150. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  151. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  152. code_puppy/plugins/example_custom_command/README.md +280 -0
  153. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  154. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  155. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  156. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  157. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  158. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  159. code_puppy/plugins/oauth_puppy_html.py +228 -0
  160. code_puppy/plugins/shell_safety/__init__.py +6 -0
  161. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  162. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  163. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  164. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  165. code_puppy/prompts/codex_system_prompt.md +310 -0
  166. code_puppy/pydantic_patches.py +131 -0
  167. code_puppy/reopenable_async_client.py +8 -8
  168. code_puppy/round_robin_model.py +9 -12
  169. code_puppy/session_storage.py +2 -1
  170. code_puppy/status_display.py +21 -4
  171. code_puppy/summarization_agent.py +41 -13
  172. code_puppy/terminal_utils.py +418 -0
  173. code_puppy/tools/__init__.py +37 -1
  174. code_puppy/tools/agent_tools.py +536 -52
  175. code_puppy/tools/browser/__init__.py +37 -0
  176. code_puppy/tools/browser/browser_control.py +19 -23
  177. code_puppy/tools/browser/browser_interactions.py +41 -48
  178. code_puppy/tools/browser/browser_locators.py +36 -38
  179. code_puppy/tools/browser/browser_manager.py +316 -0
  180. code_puppy/tools/browser/browser_navigation.py +16 -16
  181. code_puppy/tools/browser/browser_screenshot.py +79 -143
  182. code_puppy/tools/browser/browser_scripts.py +32 -42
  183. code_puppy/tools/browser/browser_workflows.py +44 -27
  184. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  185. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  186. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  187. code_puppy/tools/browser/terminal_tools.py +525 -0
  188. code_puppy/tools/command_runner.py +930 -147
  189. code_puppy/tools/common.py +1113 -5
  190. code_puppy/tools/display.py +84 -0
  191. code_puppy/tools/file_modifications.py +288 -89
  192. code_puppy/tools/file_operations.py +226 -154
  193. code_puppy/tools/subagent_context.py +158 -0
  194. code_puppy/uvx_detection.py +242 -0
  195. code_puppy/version_checker.py +30 -11
  196. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  197. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  198. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
  199. code_puppy-0.0.366.dist-info/RECORD +217 -0
  200. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  201. code_puppy/command_line/mcp/add_command.py +0 -183
  202. code_puppy/messaging/spinner/textual_spinner.py +0 -106
  203. code_puppy/tools/browser/camoufox_manager.py +0 -216
  204. code_puppy/tools/browser/vqa_agent.py +0 -70
  205. code_puppy/tui/__init__.py +0 -10
  206. code_puppy/tui/app.py +0 -1105
  207. code_puppy/tui/components/__init__.py +0 -21
  208. code_puppy/tui/components/chat_view.py +0 -551
  209. code_puppy/tui/components/command_history_modal.py +0 -218
  210. code_puppy/tui/components/copy_button.py +0 -139
  211. code_puppy/tui/components/custom_widgets.py +0 -63
  212. code_puppy/tui/components/human_input_modal.py +0 -175
  213. code_puppy/tui/components/input_area.py +0 -167
  214. code_puppy/tui/components/sidebar.py +0 -309
  215. code_puppy/tui/components/status_bar.py +0 -185
  216. code_puppy/tui/messages.py +0 -27
  217. code_puppy/tui/models/__init__.py +0 -8
  218. code_puppy/tui/models/chat_message.py +0 -25
  219. code_puppy/tui/models/command_history.py +0 -89
  220. code_puppy/tui/models/enums.py +0 -24
  221. code_puppy/tui/screens/__init__.py +0 -17
  222. code_puppy/tui/screens/autosave_picker.py +0 -175
  223. code_puppy/tui/screens/help.py +0 -130
  224. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  225. code_puppy/tui/screens/settings.py +0 -306
  226. code_puppy/tui/screens/tools.py +0 -74
  227. code_puppy/tui_state.py +0 -55
  228. code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
  229. code_puppy-0.0.214.dist-info/RECORD +0 -131
  230. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
  231. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,523 @@
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
+ modified = (
166
+ "\n".join(orig_lines[:start])
167
+ + "\n"
168
+ + new_snippet.rstrip("\n")
169
+ + "\n"
170
+ + "\n".join(orig_lines[end:])
171
+ )
172
+
173
+ if modified == original:
174
+ return None
175
+
176
+ diff_text = "".join(
177
+ difflib.unified_diff(
178
+ original.splitlines(keepends=True),
179
+ modified.splitlines(keepends=True),
180
+ fromfile=f"a/{os.path.basename(file_path)}",
181
+ tofile=f"b/{os.path.basename(file_path)}",
182
+ n=get_diff_context_lines(),
183
+ )
184
+ )
185
+ return diff_text
186
+ except Exception:
187
+ return None
188
+
189
+
190
+ def _preview_delete_file(file_path: str) -> str | None:
191
+ """Generate a preview diff for deleting a file without modifying it."""
192
+ try:
193
+ file_path = os.path.abspath(file_path)
194
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
195
+ return None
196
+
197
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
198
+ original = f.read()
199
+
200
+ # Sanitize any surrogate characters
201
+ try:
202
+ original = original.encode("utf-8", errors="surrogatepass").decode(
203
+ "utf-8", errors="replace"
204
+ )
205
+ except (UnicodeEncodeError, UnicodeDecodeError):
206
+ pass
207
+
208
+ diff_text = "".join(
209
+ difflib.unified_diff(
210
+ original.splitlines(keepends=True),
211
+ [],
212
+ fromfile=f"a/{os.path.basename(file_path)}",
213
+ tofile=f"b/{os.path.basename(file_path)}",
214
+ n=get_diff_context_lines(),
215
+ )
216
+ )
217
+ return diff_text
218
+ except Exception:
219
+ return None
220
+
221
+
222
+ def prompt_for_file_permission(
223
+ file_path: str,
224
+ operation: str,
225
+ preview: str | None = None,
226
+ message_group: str | None = None,
227
+ ) -> tuple[bool, str | None]:
228
+ """Prompt the user for permission to perform a file operation.
229
+
230
+ This function provides a unified permission prompt system for all file operations.
231
+
232
+ Args:
233
+ file_path: Path to the file being modified.
234
+ operation: Description of the operation (e.g., "edit", "delete", "create").
235
+ preview: Optional preview of changes (diff or content preview).
236
+ message_group: Optional message group for organizing output.
237
+
238
+ Returns:
239
+ Tuple of (confirmed: bool, user_feedback: str | None)
240
+ - confirmed: True if permission is granted, False otherwise
241
+ - user_feedback: Optional feedback message from user to send back to the model
242
+ """
243
+ yolo_mode = get_yolo_mode()
244
+
245
+ # Skip confirmation only if in yolo mode (removed TTY check for better compatibility)
246
+ if yolo_mode:
247
+ return True, None
248
+
249
+ # Try to acquire the lock to prevent multiple simultaneous prompts
250
+ confirmation_lock_acquired = _FILE_CONFIRMATION_LOCK.acquire(blocking=False)
251
+ if not confirmation_lock_acquired:
252
+ emit_warning(
253
+ "Another file operation is currently awaiting confirmation",
254
+ message_group=message_group,
255
+ )
256
+ return False, None
257
+
258
+ try:
259
+ # Build panel content
260
+ panel_content = RichText()
261
+ panel_content.append("🔒 Requesting permission to ", style="bold yellow")
262
+ panel_content.append(operation, style="bold cyan")
263
+ panel_content.append(":\n", style="bold yellow")
264
+ panel_content.append("📄 ", style="dim")
265
+ panel_content.append(file_path, style="bold white")
266
+
267
+ # Use the common approval function
268
+ confirmed, user_feedback = get_user_approval(
269
+ title="File Operation",
270
+ content=panel_content,
271
+ preview=preview,
272
+ border_style="dim white",
273
+ )
274
+
275
+ return confirmed, user_feedback
276
+
277
+ finally:
278
+ if confirmation_lock_acquired:
279
+ _FILE_CONFIRMATION_LOCK.release()
280
+
281
+
282
+ def handle_edit_file_permission(
283
+ context: Any,
284
+ file_path: str,
285
+ operation_type: str,
286
+ operation_data: Any,
287
+ message_group: str | None = None,
288
+ ) -> bool:
289
+ """Handle permission for edit_file operations with automatic preview generation.
290
+
291
+ Args:
292
+ context: The operation context
293
+ file_path: Path to the file being operated on
294
+ operation_type: Type of edit operation ('write', 'replace', 'delete_snippet')
295
+ operation_data: Operation-specific data (content, replacements, snippet, etc.)
296
+ message_group: Optional message group
297
+
298
+ Returns:
299
+ True if permission granted, False if denied
300
+ """
301
+ preview = None
302
+
303
+ if operation_type == "write":
304
+ content = operation_data.get("content", "")
305
+ overwrite = operation_data.get("overwrite", False)
306
+ preview = _preview_write_to_file(file_path, content, overwrite)
307
+ operation_desc = "write to"
308
+ elif operation_type == "replace":
309
+ replacements = operation_data.get("replacements", [])
310
+ preview = _preview_replace_in_file(file_path, replacements)
311
+ operation_desc = "replace text in"
312
+ elif operation_type == "delete_snippet":
313
+ snippet = operation_data.get("delete_snippet", "")
314
+ preview = _preview_delete_snippet(file_path, snippet)
315
+ operation_desc = "delete snippet from"
316
+ else:
317
+ operation_desc = f"perform {operation_type} operation on"
318
+
319
+ confirmed, user_feedback = prompt_for_file_permission(
320
+ file_path, operation_desc, preview, message_group
321
+ )
322
+ # Store feedback in thread-local storage so the tool can access it
323
+ _set_user_feedback(user_feedback)
324
+ return confirmed
325
+
326
+
327
+ def handle_delete_file_permission(
328
+ context: Any,
329
+ file_path: str,
330
+ message_group: str | None = None,
331
+ ) -> bool:
332
+ """Handle permission for delete_file operations with automatic preview generation.
333
+
334
+ Args:
335
+ context: The operation context
336
+ file_path: Path to the file being deleted
337
+ message_group: Optional message group
338
+
339
+ Returns:
340
+ True if permission granted, False if denied
341
+ """
342
+ preview = _preview_delete_file(file_path)
343
+ confirmed, user_feedback = prompt_for_file_permission(
344
+ file_path, "delete", preview, message_group
345
+ )
346
+ # Store feedback in thread-local storage so the tool can access it
347
+ _set_user_feedback(user_feedback)
348
+ return confirmed
349
+
350
+
351
+ def handle_file_permission(
352
+ context: Any,
353
+ file_path: str,
354
+ operation: str,
355
+ preview: str | None = None,
356
+ message_group: str | None = None,
357
+ operation_data: Any = None,
358
+ ) -> bool:
359
+ """Callback handler for file permission checks.
360
+
361
+ This function is called by file operations to check for user permission.
362
+ It returns True if the operation should proceed, False if it should be cancelled.
363
+
364
+ Args:
365
+ context: The operation context
366
+ file_path: Path to the file being operated on
367
+ operation: Description of the operation
368
+ preview: Optional preview of changes (deprecated - use operation_data instead)
369
+ message_group: Optional message group
370
+ operation_data: Operation-specific data for preview generation
371
+
372
+ Returns:
373
+ True if permission granted, False if denied
374
+ """
375
+ # Generate preview from operation_data if provided
376
+ if operation_data is not None:
377
+ preview = _generate_preview_from_operation_data(
378
+ file_path, operation, operation_data
379
+ )
380
+
381
+ confirmed, user_feedback = prompt_for_file_permission(
382
+ file_path, operation, preview, message_group
383
+ )
384
+ # Store feedback in thread-local storage so the tool can access it
385
+ _set_user_feedback(user_feedback)
386
+ return confirmed
387
+
388
+
389
+ def _generate_preview_from_operation_data(
390
+ file_path: str, operation: str, operation_data: Any
391
+ ) -> str | None:
392
+ """Generate preview diff from operation data.
393
+
394
+ Args:
395
+ file_path: Path to the file
396
+ operation: Type of operation
397
+ operation_data: Operation-specific data
398
+
399
+ Returns:
400
+ Preview diff or None if generation fails
401
+ """
402
+ try:
403
+ if operation == "delete":
404
+ return _preview_delete_file(file_path)
405
+ elif operation == "write":
406
+ content = operation_data.get("content", "")
407
+ overwrite = operation_data.get("overwrite", False)
408
+ return _preview_write_to_file(file_path, content, overwrite)
409
+ elif operation == "delete snippet from":
410
+ snippet = operation_data.get("snippet", "")
411
+ return _preview_delete_snippet(file_path, snippet)
412
+ elif operation == "replace text in":
413
+ replacements = operation_data.get("replacements", [])
414
+ return _preview_replace_in_file(file_path, replacements)
415
+ elif operation == "edit_file":
416
+ # Handle edit_file operations
417
+ if "delete_snippet" in operation_data:
418
+ return _preview_delete_snippet(
419
+ file_path, operation_data["delete_snippet"]
420
+ )
421
+ elif "replacements" in operation_data:
422
+ return _preview_replace_in_file(
423
+ file_path, operation_data["replacements"]
424
+ )
425
+ elif "content" in operation_data:
426
+ content = operation_data.get("content", "")
427
+ overwrite = operation_data.get("overwrite", False)
428
+ return _preview_write_to_file(file_path, content, overwrite)
429
+
430
+ return None
431
+ except Exception:
432
+ return None
433
+
434
+
435
+ def get_permission_handler_help() -> str:
436
+ """Return help information for the file permission handler."""
437
+ return """File Permission Handler Plugin:
438
+ - Unified permission prompts for all file operations
439
+ - YOLO mode support for automatic approval
440
+ - Thread-safe confirmation system
441
+ - Consistent user experience across file operations
442
+ - Detailed preview support with diff highlighting
443
+ - Automatic preview generation from operation data"""
444
+
445
+
446
+ def get_file_permission_prompt_additions() -> str:
447
+ """Return file permission handling prompt additions for agents.
448
+
449
+ This function provides the file permission rejection handling
450
+ instructions that can be dynamically injected into agent prompts
451
+ via the prompt hook system.
452
+
453
+ Only returns instructions when yolo_mode is off (False).
454
+ """
455
+ # Only inject permission handling instructions when yolo mode is off
456
+ if get_yolo_mode():
457
+ return "" # Return empty string when yolo mode is enabled
458
+
459
+ return """
460
+ ## 💬 USER FEEDBACK SYSTEM
461
+
462
+ **How User Approval Works:**
463
+
464
+ When you attempt file operations or shell commands, the user sees a beautiful prompt with three options:
465
+ 1. **Press Enter or 'y'** → Approve (proceed with the operation as-is)
466
+ 2. **Type 'n'** → Reject silently (cancel without feedback)
467
+ 3. **Type any other text** → **Reject WITH feedback** (cancel and tell you what to do instead)
468
+
469
+ **Understanding User Feedback:**
470
+
471
+ When you receive a rejection response with `user_feedback` field populated:
472
+ - The user is **rejecting your current approach**
473
+ - They are **telling you what they want instead**
474
+ - The feedback is in the `user_feedback` field or included in the error message
475
+
476
+ Example tool response:
477
+ ```
478
+ {
479
+ "success": false,
480
+ "user_rejection": true,
481
+ "user_feedback": "Add error handling and use async/await",
482
+ "message": "USER REJECTED: The user explicitly rejected these file changes. User feedback: Add error handling and use async/await"
483
+ }
484
+ ```
485
+
486
+ **WHEN YOU RECEIVE USER FEEDBACK, YOU MUST:**
487
+
488
+ 1. **🛑 STOP the current approach** - Do NOT retry the same operation
489
+ 2. **📝 READ the feedback carefully** - The user is telling you what they want
490
+ 3. **✅ IMPLEMENT their suggestion** - Modify your approach based on their feedback
491
+ 4. **🔄 TRY AGAIN with the changes** - Apply the feedback and attempt the operation again
492
+
493
+ **Example Flow:**
494
+ ```
495
+ You: *attempts to create function without error handling*
496
+ User: "Add try/catch error handling" → REJECTS with feedback
497
+ You: *modifies code to include try/catch*
498
+ You: *attempts operation again with improved code*
499
+ User: *approves*
500
+ ```
501
+
502
+ **WHEN FEEDBACK IS EMPTY (silent rejection):**
503
+
504
+ If `user_feedback` is None/empty, the user rejected without guidance:
505
+ - **STOP immediately**
506
+ - **ASK the user** what they want instead
507
+ - **WAIT for explicit direction**
508
+
509
+ **KEY POINTS:**
510
+ - Feedback is **guidance**, not criticism - use it to improve!
511
+ - The user wants the operation done **their way**
512
+ - Implement the feedback and **try again**
513
+ - Don't ask permission again - **just do it better**
514
+
515
+ This system lets users guide you interactively! 🐶✨
516
+ """
517
+
518
+
519
+ # Register the callback for file permission handling
520
+ register_callback("file_permission", handle_file_permission)
521
+
522
+ # Register the prompt hook for file permission instructions
523
+ 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")