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.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +2 -0
- code_puppy/agents/agent_c_reviewer.py +59 -6
- code_puppy/agents/agent_code_puppy.py +7 -1
- code_puppy/agents/agent_code_reviewer.py +12 -2
- code_puppy/agents/agent_cpp_reviewer.py +73 -6
- code_puppy/agents/agent_creator_agent.py +45 -4
- code_puppy/agents/agent_golang_reviewer.py +92 -3
- code_puppy/agents/agent_javascript_reviewer.py +101 -8
- code_puppy/agents/agent_manager.py +81 -4
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -0
- code_puppy/agents/agent_python_reviewer.py +28 -6
- code_puppy/agents/agent_qa_expert.py +98 -6
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_security_auditor.py +113 -3
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +106 -7
- code_puppy/agents/base_agent.py +802 -176
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +142 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +10 -5
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +176 -738
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +0 -3
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +15 -26
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +2 -2
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +16 -10
- code_puppy/command_line/mcp/start_all_command.py +18 -6
- code_puppy/command_line/mcp/start_command.py +47 -25
- code_puppy/command_line/mcp/status_command.py +4 -5
- code_puppy/command_line/mcp/stop_all_command.py +7 -1
- code_puppy/command_line/mcp/stop_command.py +8 -4
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/wizard_utils.py +20 -16
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +75 -25
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +463 -63
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +898 -112
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +210 -148
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -698
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/async_lifecycle.py +35 -4
- code_puppy/mcp_/blocking_startup.py +70 -43
- code_puppy/mcp_/captured_stdio_server.py +2 -2
- code_puppy/mcp_/config_wizard.py +4 -4
- code_puppy/mcp_/dashboard.py +15 -6
- code_puppy/mcp_/managed_server.py +65 -38
- code_puppy/mcp_/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +6 -6
- code_puppy/mcp_/server_registry_catalog.py +24 -5
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +21 -5
- code_puppy/messaging/spinner/console_spinner.py +86 -51
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +634 -83
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +66 -68
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +9 -12
- code_puppy/session_storage.py +2 -1
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +41 -13
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +536 -52
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +19 -23
- code_puppy/tools/browser/browser_interactions.py +41 -48
- code_puppy/tools/browser/browser_locators.py +36 -38
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +16 -16
- code_puppy/tools/browser/browser_screenshot.py +79 -143
- code_puppy/tools/browser/browser_scripts.py +32 -42
- code_puppy/tools/browser/browser_workflows.py +44 -27
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +930 -147
- code_puppy/tools/common.py +1113 -5
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +226 -154
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/messaging/spinner/textual_spinner.py +0 -106
- code_puppy/tools/browser/camoufox_manager.py +0 -216
- code_puppy/tools/browser/vqa_agent.py +0 -70
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -1105
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -551
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -185
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -17
- code_puppy/tui/screens/autosave_picker.py +0 -175
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -306
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy/tui_state.py +0 -55
- code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
- code_puppy-0.0.214.dist-info/RECORD +0 -131
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
- {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")
|