code-puppy 0.0.169__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 +8 -8
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +9 -2
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +48 -9
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +146 -199
- 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 +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +1713 -1
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/json_agent.py +12 -1
- 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 +174 -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 +395 -0
- 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 +233 -627
- 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 +1 -4
- 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 +16 -27
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +3 -3
- 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 +17 -11
- code_puppy/command_line/mcp/start_all_command.py +22 -13
- code_puppy/command_line/mcp/start_command.py +50 -31
- code_puppy/command_line/mcp/status_command.py +6 -7
- code_puppy/command_line/mcp/stop_all_command.py +11 -8
- code_puppy/command_line/mcp/stop_command.py +11 -10
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/utils.py +1 -1
- code_puppy/command_line/mcp/wizard_utils.py +22 -18
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +89 -30
- 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 +626 -75
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +1181 -51
- 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 +220 -104
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -594
- code_puppy/{mcp → mcp_}/__init__.py +17 -0
- code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
- code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
- code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
- code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
- code_puppy/{mcp → mcp_}/dashboard.py +15 -6
- code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
- code_puppy/{mcp → mcp_}/managed_server.py +66 -39
- code_puppy/{mcp → mcp_}/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/{mcp → mcp_}/registry.py +6 -6
- code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
- 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 +33 -5
- code_puppy/messaging/spinner/console_spinner.py +92 -52
- code_puppy/messaging/spinner/spinner_base.py +29 -0
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +686 -80
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +86 -104
- 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 +51 -0
- 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 +10 -15
- code_puppy/session_storage.py +294 -0
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +52 -14
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +139 -6
- code_puppy/tools/agent_tools.py +548 -49
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- 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 +941 -153
- code_puppy/tools/common.py +1146 -6
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +352 -266
- 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.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
- code_puppy/agent.py +0 -231
- code_puppy/agents/agent_orchestrator.json +0 -26
- code_puppy/agents/runtime_manager.py +0 -272
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/command_line/meta_command_handler.py +0 -153
- code_puppy/message_history_processor.py +0 -490
- code_puppy/messaging/spinner/textual_spinner.py +0 -101
- code_puppy/state_management.py +0 -200
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -986
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -550
- 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 -182
- 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 -15
- 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 -290
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
- code_puppy-0.0.169.dist-info/RECORD +0 -112
- /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
- /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
- /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
- /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
- /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
- /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""PTY Manager for terminal emulation with cross-platform support.
|
|
2
|
+
|
|
3
|
+
Provides pseudo-terminal (PTY) functionality for interactive shell sessions
|
|
4
|
+
via WebSocket connections. Supports Unix (pty module) and Windows (pywinpty).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import struct
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Callable, Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Platform detection
|
|
19
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
20
|
+
|
|
21
|
+
# Conditional imports based on platform
|
|
22
|
+
if IS_WINDOWS:
|
|
23
|
+
try:
|
|
24
|
+
import winpty # type: ignore
|
|
25
|
+
|
|
26
|
+
HAS_WINPTY = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
HAS_WINPTY = False
|
|
29
|
+
winpty = None
|
|
30
|
+
else:
|
|
31
|
+
import fcntl
|
|
32
|
+
import pty
|
|
33
|
+
import termios
|
|
34
|
+
|
|
35
|
+
HAS_WINPTY = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class PTYSession:
|
|
40
|
+
"""Represents an active PTY session."""
|
|
41
|
+
|
|
42
|
+
session_id: str
|
|
43
|
+
master_fd: Optional[int] = None # Unix only
|
|
44
|
+
slave_fd: Optional[int] = None # Unix only
|
|
45
|
+
pid: Optional[int] = None # Unix only
|
|
46
|
+
winpty_process: Any = None # Windows only
|
|
47
|
+
cols: int = 80
|
|
48
|
+
rows: int = 24
|
|
49
|
+
on_output: Optional[Callable[[bytes], None]] = None
|
|
50
|
+
_reader_task: Optional[asyncio.Task] = None # type: ignore
|
|
51
|
+
_running: bool = field(default=False, init=False)
|
|
52
|
+
|
|
53
|
+
def is_alive(self) -> bool:
|
|
54
|
+
"""Check if the PTY session is still active."""
|
|
55
|
+
if IS_WINDOWS:
|
|
56
|
+
return self.winpty_process is not None and self.winpty_process.isalive()
|
|
57
|
+
else:
|
|
58
|
+
if self.pid is None:
|
|
59
|
+
return False
|
|
60
|
+
try:
|
|
61
|
+
os.waitpid(self.pid, os.WNOHANG)
|
|
62
|
+
return True
|
|
63
|
+
except ChildProcessError:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PTYManager:
|
|
68
|
+
"""Manages PTY sessions for terminal emulation.
|
|
69
|
+
|
|
70
|
+
Provides cross-platform terminal emulation with support for:
|
|
71
|
+
- Unix systems via the pty module
|
|
72
|
+
- Windows via pywinpty (optional dependency)
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
manager = PTYManager()
|
|
76
|
+
session = await manager.create_session(
|
|
77
|
+
session_id="my-terminal",
|
|
78
|
+
on_output=lambda data: print(data.decode())
|
|
79
|
+
)
|
|
80
|
+
await manager.write(session.session_id, b"ls -la\n")
|
|
81
|
+
await manager.close_session(session.session_id)
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self._sessions: dict[str, PTYSession] = {}
|
|
86
|
+
self._lock = asyncio.Lock()
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def sessions(self) -> dict[str, PTYSession]:
|
|
90
|
+
"""Get all active sessions."""
|
|
91
|
+
return self._sessions.copy()
|
|
92
|
+
|
|
93
|
+
async def create_session(
|
|
94
|
+
self,
|
|
95
|
+
session_id: str,
|
|
96
|
+
cols: int = 80,
|
|
97
|
+
rows: int = 24,
|
|
98
|
+
on_output: Optional[Callable[[bytes], None]] = None,
|
|
99
|
+
shell: Optional[str] = None,
|
|
100
|
+
) -> PTYSession:
|
|
101
|
+
"""Create a new PTY session.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
session_id: Unique identifier for the session
|
|
105
|
+
cols: Terminal width in columns
|
|
106
|
+
rows: Terminal height in rows
|
|
107
|
+
on_output: Callback for terminal output
|
|
108
|
+
shell: Shell to spawn (defaults to user's shell or /bin/bash)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
PTYSession: The created session
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
RuntimeError: If session creation fails
|
|
115
|
+
"""
|
|
116
|
+
async with self._lock:
|
|
117
|
+
if session_id in self._sessions:
|
|
118
|
+
logger.warning(f"Session {session_id} already exists, closing old one")
|
|
119
|
+
await self._close_session_internal(session_id)
|
|
120
|
+
|
|
121
|
+
if IS_WINDOWS:
|
|
122
|
+
session = await self._create_windows_session(
|
|
123
|
+
session_id, cols, rows, on_output, shell
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
session = await self._create_unix_session(
|
|
127
|
+
session_id, cols, rows, on_output, shell
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
self._sessions[session_id] = session
|
|
131
|
+
logger.info(f"Created PTY session: {session_id}")
|
|
132
|
+
return session
|
|
133
|
+
|
|
134
|
+
async def _create_unix_session(
|
|
135
|
+
self,
|
|
136
|
+
session_id: str,
|
|
137
|
+
cols: int,
|
|
138
|
+
rows: int,
|
|
139
|
+
on_output: Optional[Callable[[bytes], None]],
|
|
140
|
+
shell: Optional[str],
|
|
141
|
+
) -> PTYSession:
|
|
142
|
+
"""Create a PTY session on Unix systems."""
|
|
143
|
+
shell = shell or os.environ.get("SHELL", "/bin/bash")
|
|
144
|
+
|
|
145
|
+
# Fork a new process with a PTY
|
|
146
|
+
pid, master_fd = pty.fork()
|
|
147
|
+
|
|
148
|
+
if pid == 0:
|
|
149
|
+
# Child process - exec the shell
|
|
150
|
+
os.execlp(shell, shell, "-i") # noqa: S606
|
|
151
|
+
else:
|
|
152
|
+
# Parent process
|
|
153
|
+
# Set terminal size
|
|
154
|
+
self._set_unix_winsize(master_fd, rows, cols)
|
|
155
|
+
|
|
156
|
+
# Make master_fd non-blocking
|
|
157
|
+
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
|
158
|
+
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
159
|
+
|
|
160
|
+
session = PTYSession(
|
|
161
|
+
session_id=session_id,
|
|
162
|
+
master_fd=master_fd,
|
|
163
|
+
pid=pid,
|
|
164
|
+
cols=cols,
|
|
165
|
+
rows=rows,
|
|
166
|
+
on_output=on_output,
|
|
167
|
+
)
|
|
168
|
+
session._running = True
|
|
169
|
+
|
|
170
|
+
# Start reader task
|
|
171
|
+
session._reader_task = asyncio.create_task(self._unix_reader_loop(session))
|
|
172
|
+
|
|
173
|
+
return session
|
|
174
|
+
|
|
175
|
+
async def _create_windows_session(
|
|
176
|
+
self,
|
|
177
|
+
session_id: str,
|
|
178
|
+
cols: int,
|
|
179
|
+
rows: int,
|
|
180
|
+
on_output: Optional[Callable[[bytes], None]],
|
|
181
|
+
shell: Optional[str],
|
|
182
|
+
) -> PTYSession:
|
|
183
|
+
"""Create a PTY session on Windows systems."""
|
|
184
|
+
if not HAS_WINPTY:
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
"pywinpty is required for Windows terminal support. "
|
|
187
|
+
"Install it with: pip install pywinpty"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
shell = shell or os.environ.get("COMSPEC", "cmd.exe")
|
|
191
|
+
|
|
192
|
+
# Create winpty process
|
|
193
|
+
winpty_process = winpty.PtyProcess.spawn(
|
|
194
|
+
shell,
|
|
195
|
+
dimensions=(rows, cols),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
session = PTYSession(
|
|
199
|
+
session_id=session_id,
|
|
200
|
+
winpty_process=winpty_process,
|
|
201
|
+
cols=cols,
|
|
202
|
+
rows=rows,
|
|
203
|
+
on_output=on_output,
|
|
204
|
+
)
|
|
205
|
+
session._running = True
|
|
206
|
+
|
|
207
|
+
# Start reader task
|
|
208
|
+
session._reader_task = asyncio.create_task(self._windows_reader_loop(session))
|
|
209
|
+
|
|
210
|
+
return session
|
|
211
|
+
|
|
212
|
+
def _set_unix_winsize(self, fd: int, rows: int, cols: int) -> None:
|
|
213
|
+
"""Set the terminal window size on Unix."""
|
|
214
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
215
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
216
|
+
|
|
217
|
+
async def _unix_reader_loop(self, session: PTYSession) -> None:
|
|
218
|
+
"""Read output from Unix PTY and forward to callback."""
|
|
219
|
+
loop = asyncio.get_event_loop()
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
while session._running and session.master_fd is not None:
|
|
223
|
+
try:
|
|
224
|
+
data = await loop.run_in_executor(
|
|
225
|
+
None, self._read_unix_pty, session.master_fd
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if data is None:
|
|
229
|
+
# No data available, wait a bit
|
|
230
|
+
await asyncio.sleep(0.01)
|
|
231
|
+
continue
|
|
232
|
+
elif data == b"":
|
|
233
|
+
# EOF - process terminated
|
|
234
|
+
break
|
|
235
|
+
elif session.on_output:
|
|
236
|
+
session.on_output(data)
|
|
237
|
+
|
|
238
|
+
except asyncio.CancelledError:
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.error(f"Unix reader loop error: {e}")
|
|
243
|
+
finally:
|
|
244
|
+
session._running = False
|
|
245
|
+
|
|
246
|
+
def _read_unix_pty(self, fd: int) -> bytes | None:
|
|
247
|
+
"""Read from Unix PTY file descriptor.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
bytes: Data read from PTY
|
|
251
|
+
None: No data available (would block)
|
|
252
|
+
b'': EOF (process terminated)
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
data = os.read(fd, 4096)
|
|
256
|
+
return data
|
|
257
|
+
except BlockingIOError:
|
|
258
|
+
return None
|
|
259
|
+
except OSError:
|
|
260
|
+
return b""
|
|
261
|
+
|
|
262
|
+
async def _windows_reader_loop(self, session: PTYSession) -> None:
|
|
263
|
+
"""Read output from Windows PTY and forward to callback."""
|
|
264
|
+
loop = asyncio.get_event_loop()
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
while (
|
|
268
|
+
session._running
|
|
269
|
+
and session.winpty_process is not None
|
|
270
|
+
and session.winpty_process.isalive()
|
|
271
|
+
):
|
|
272
|
+
try:
|
|
273
|
+
data = await loop.run_in_executor(
|
|
274
|
+
None, session.winpty_process.read, 4096
|
|
275
|
+
)
|
|
276
|
+
if data and session.on_output:
|
|
277
|
+
session.on_output(
|
|
278
|
+
data.encode() if isinstance(data, str) else data
|
|
279
|
+
)
|
|
280
|
+
except EOFError:
|
|
281
|
+
break
|
|
282
|
+
except asyncio.CancelledError:
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
await asyncio.sleep(0.01)
|
|
286
|
+
|
|
287
|
+
except Exception as e:
|
|
288
|
+
logger.error(f"Windows reader loop error: {e}")
|
|
289
|
+
finally:
|
|
290
|
+
session._running = False
|
|
291
|
+
|
|
292
|
+
async def write(self, session_id: str, data: bytes) -> bool:
|
|
293
|
+
"""Write data to a PTY session.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
session_id: The session to write to
|
|
297
|
+
data: Data to write
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
bool: True if write succeeded
|
|
301
|
+
"""
|
|
302
|
+
session = self._sessions.get(session_id)
|
|
303
|
+
if not session:
|
|
304
|
+
logger.warning(f"Session {session_id} not found")
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
if IS_WINDOWS:
|
|
309
|
+
if session.winpty_process:
|
|
310
|
+
session.winpty_process.write(
|
|
311
|
+
data.decode() if isinstance(data, bytes) else data
|
|
312
|
+
)
|
|
313
|
+
return True
|
|
314
|
+
else:
|
|
315
|
+
if session.master_fd is not None:
|
|
316
|
+
os.write(session.master_fd, data)
|
|
317
|
+
return True
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.error(f"Write error for session {session_id}: {e}")
|
|
320
|
+
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
async def resize(self, session_id: str, cols: int, rows: int) -> bool:
|
|
324
|
+
"""Resize a PTY session.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
session_id: The session to resize
|
|
328
|
+
cols: New width in columns
|
|
329
|
+
rows: New height in rows
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
bool: True if resize succeeded
|
|
333
|
+
"""
|
|
334
|
+
session = self._sessions.get(session_id)
|
|
335
|
+
if not session:
|
|
336
|
+
logger.warning(f"Session {session_id} not found")
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
if IS_WINDOWS:
|
|
341
|
+
if session.winpty_process:
|
|
342
|
+
session.winpty_process.setwinsize(rows, cols)
|
|
343
|
+
else:
|
|
344
|
+
if session.master_fd is not None:
|
|
345
|
+
self._set_unix_winsize(session.master_fd, rows, cols)
|
|
346
|
+
|
|
347
|
+
session.cols = cols
|
|
348
|
+
session.rows = rows
|
|
349
|
+
logger.debug(f"Resized session {session_id} to {cols}x{rows}")
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.error(f"Resize error for session {session_id}: {e}")
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
async def close_session(self, session_id: str) -> bool:
|
|
357
|
+
"""Close a PTY session.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
session_id: The session to close
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
bool: True if session was closed
|
|
364
|
+
"""
|
|
365
|
+
async with self._lock:
|
|
366
|
+
return await self._close_session_internal(session_id)
|
|
367
|
+
|
|
368
|
+
async def _close_session_internal(self, session_id: str) -> bool:
|
|
369
|
+
"""Internal session close without lock."""
|
|
370
|
+
session = self._sessions.pop(session_id, None)
|
|
371
|
+
if not session:
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
session._running = False
|
|
375
|
+
|
|
376
|
+
# Cancel reader task
|
|
377
|
+
if session._reader_task:
|
|
378
|
+
session._reader_task.cancel()
|
|
379
|
+
try:
|
|
380
|
+
await session._reader_task
|
|
381
|
+
except asyncio.CancelledError:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
# Clean up platform-specific resources
|
|
385
|
+
if IS_WINDOWS:
|
|
386
|
+
if session.winpty_process:
|
|
387
|
+
try:
|
|
388
|
+
session.winpty_process.terminate()
|
|
389
|
+
except Exception as e:
|
|
390
|
+
logger.debug(f"Error terminating winpty: {e}")
|
|
391
|
+
else:
|
|
392
|
+
# Close file descriptors
|
|
393
|
+
if session.master_fd is not None:
|
|
394
|
+
try:
|
|
395
|
+
os.close(session.master_fd)
|
|
396
|
+
except OSError:
|
|
397
|
+
pass
|
|
398
|
+
|
|
399
|
+
# Terminate child process
|
|
400
|
+
if session.pid is not None:
|
|
401
|
+
try:
|
|
402
|
+
os.kill(session.pid, signal.SIGTERM)
|
|
403
|
+
os.waitpid(session.pid, 0)
|
|
404
|
+
except (OSError, ChildProcessError):
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
logger.info(f"Closed PTY session: {session_id}")
|
|
408
|
+
return True
|
|
409
|
+
|
|
410
|
+
async def close_all(self) -> None:
|
|
411
|
+
"""Close all PTY sessions."""
|
|
412
|
+
session_ids = list(self._sessions.keys())
|
|
413
|
+
for session_id in session_ids:
|
|
414
|
+
await self.close_session(session_id)
|
|
415
|
+
logger.info("Closed all PTY sessions")
|
|
416
|
+
|
|
417
|
+
def get_session(self, session_id: str) -> Optional[PTYSession]:
|
|
418
|
+
"""Get a session by ID.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
session_id: The session ID
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
PTYSession or None if not found
|
|
425
|
+
"""
|
|
426
|
+
return self._sessions.get(session_id)
|
|
427
|
+
|
|
428
|
+
def list_sessions(self) -> list[str]:
|
|
429
|
+
"""List all active session IDs.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
List of session IDs
|
|
433
|
+
"""
|
|
434
|
+
return list(self._sessions.keys())
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# Global PTY manager instance
|
|
438
|
+
_pty_manager: Optional[PTYManager] = None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def get_pty_manager() -> PTYManager:
|
|
442
|
+
"""Get or create the global PTY manager instance."""
|
|
443
|
+
global _pty_manager
|
|
444
|
+
if _pty_manager is None:
|
|
445
|
+
_pty_manager = PTYManager()
|
|
446
|
+
return _pty_manager
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""API routers for Code Puppy REST endpoints.
|
|
2
|
+
|
|
3
|
+
This package contains the FastAPI router modules for different API domains:
|
|
4
|
+
- config: Configuration management endpoints
|
|
5
|
+
- commands: Command execution endpoints
|
|
6
|
+
- sessions: Session management endpoints
|
|
7
|
+
- agents: Agent-related endpoints
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from code_puppy.api.routers import agents, commands, config, sessions
|
|
11
|
+
|
|
12
|
+
__all__ = ["config", "commands", "sessions", "agents"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Agents API endpoints for agent management.
|
|
2
|
+
|
|
3
|
+
This router provides REST endpoints for:
|
|
4
|
+
- Listing all available agents with their metadata
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/")
|
|
15
|
+
async def list_agents() -> List[Dict[str, Any]]:
|
|
16
|
+
"""List all available agents.
|
|
17
|
+
|
|
18
|
+
Returns a list of all agents registered in the system,
|
|
19
|
+
including their name, display name, and description.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
List[Dict[str, Any]]: List of agent information dictionaries.
|
|
23
|
+
"""
|
|
24
|
+
from code_puppy.agents import get_agent_descriptions, get_available_agents
|
|
25
|
+
|
|
26
|
+
agents_dict = get_available_agents()
|
|
27
|
+
descriptions = get_agent_descriptions()
|
|
28
|
+
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
"name": name,
|
|
32
|
+
"display_name": display_name,
|
|
33
|
+
"description": descriptions.get(name, "No description"),
|
|
34
|
+
}
|
|
35
|
+
for name, display_name in agents_dict.items()
|
|
36
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Commands API endpoints for slash command execution and autocomplete.
|
|
2
|
+
|
|
3
|
+
This router provides REST endpoints for:
|
|
4
|
+
- Listing all available slash commands
|
|
5
|
+
- Getting info about specific commands
|
|
6
|
+
- Executing slash commands
|
|
7
|
+
- Autocomplete suggestions for partial commands
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from typing import Any, List, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
# Thread pool for blocking command execution
|
|
18
|
+
_executor = ThreadPoolExecutor(max_workers=4)
|
|
19
|
+
|
|
20
|
+
# Timeout for command execution (seconds)
|
|
21
|
+
COMMAND_TIMEOUT = 30.0
|
|
22
|
+
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# Pydantic Models
|
|
28
|
+
# =============================================================================
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CommandInfo(BaseModel):
|
|
32
|
+
"""Information about a registered command."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
description: str
|
|
36
|
+
usage: str
|
|
37
|
+
aliases: List[str] = []
|
|
38
|
+
category: str = "core"
|
|
39
|
+
detailed_help: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CommandExecuteRequest(BaseModel):
|
|
43
|
+
"""Request to execute a slash command."""
|
|
44
|
+
|
|
45
|
+
command: str # Full command string, e.g., "/set model=gpt-4o"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CommandExecuteResponse(BaseModel):
|
|
49
|
+
"""Response from executing a slash command."""
|
|
50
|
+
|
|
51
|
+
success: bool
|
|
52
|
+
result: Any = None
|
|
53
|
+
error: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AutocompleteRequest(BaseModel):
|
|
57
|
+
"""Request for command autocomplete."""
|
|
58
|
+
|
|
59
|
+
partial: str # Partial command string, e.g., "/se" or "/set mo"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AutocompleteResponse(BaseModel):
|
|
63
|
+
"""Response with autocomplete suggestions."""
|
|
64
|
+
|
|
65
|
+
suggestions: List[str]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# =============================================================================
|
|
69
|
+
# Endpoints
|
|
70
|
+
# =============================================================================
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.get("/")
|
|
74
|
+
async def list_commands() -> List[CommandInfo]:
|
|
75
|
+
"""List all available slash commands.
|
|
76
|
+
|
|
77
|
+
Returns a sorted list of all unique commands (no alias duplicates),
|
|
78
|
+
with their metadata including name, description, usage, aliases,
|
|
79
|
+
category, and detailed help.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List[CommandInfo]: Sorted list of command information.
|
|
83
|
+
"""
|
|
84
|
+
from code_puppy.command_line.command_registry import get_unique_commands
|
|
85
|
+
|
|
86
|
+
commands = []
|
|
87
|
+
for cmd in get_unique_commands():
|
|
88
|
+
commands.append(
|
|
89
|
+
CommandInfo(
|
|
90
|
+
name=cmd.name,
|
|
91
|
+
description=cmd.description,
|
|
92
|
+
usage=cmd.usage,
|
|
93
|
+
aliases=cmd.aliases,
|
|
94
|
+
category=cmd.category,
|
|
95
|
+
detailed_help=cmd.detailed_help,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return sorted(commands, key=lambda c: c.name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.get("/{name}")
|
|
102
|
+
async def get_command_info(name: str) -> CommandInfo:
|
|
103
|
+
"""Get detailed info about a specific command.
|
|
104
|
+
|
|
105
|
+
Looks up a command by name or alias (case-insensitive).
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
name: Command name or alias (without leading /).
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
CommandInfo: Full command information.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
HTTPException: 404 if command not found.
|
|
115
|
+
"""
|
|
116
|
+
from code_puppy.command_line.command_registry import get_command
|
|
117
|
+
|
|
118
|
+
cmd = get_command(name)
|
|
119
|
+
if not cmd:
|
|
120
|
+
raise HTTPException(404, f"Command '/{name}' not found")
|
|
121
|
+
|
|
122
|
+
return CommandInfo(
|
|
123
|
+
name=cmd.name,
|
|
124
|
+
description=cmd.description,
|
|
125
|
+
usage=cmd.usage,
|
|
126
|
+
aliases=cmd.aliases,
|
|
127
|
+
category=cmd.category,
|
|
128
|
+
detailed_help=cmd.detailed_help,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.post("/execute")
|
|
133
|
+
async def execute_command(request: CommandExecuteRequest) -> CommandExecuteResponse:
|
|
134
|
+
"""Execute a slash command.
|
|
135
|
+
|
|
136
|
+
Takes a command string (with or without leading /) and executes it
|
|
137
|
+
using the command handler. Runs in a thread pool to avoid blocking
|
|
138
|
+
the event loop, with a timeout to prevent hangs.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
request: CommandExecuteRequest with the command to execute.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
CommandExecuteResponse: Result of command execution.
|
|
145
|
+
"""
|
|
146
|
+
from code_puppy.command_line.command_handler import handle_command
|
|
147
|
+
|
|
148
|
+
command = request.command
|
|
149
|
+
if not command.startswith("/"):
|
|
150
|
+
command = "/" + command
|
|
151
|
+
|
|
152
|
+
loop = asyncio.get_running_loop()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
# Run blocking command in thread pool with timeout
|
|
156
|
+
result = await asyncio.wait_for(
|
|
157
|
+
loop.run_in_executor(_executor, handle_command, command),
|
|
158
|
+
timeout=COMMAND_TIMEOUT,
|
|
159
|
+
)
|
|
160
|
+
return CommandExecuteResponse(success=True, result=result)
|
|
161
|
+
except asyncio.TimeoutError:
|
|
162
|
+
return CommandExecuteResponse(
|
|
163
|
+
success=False, error=f"Command timed out after {COMMAND_TIMEOUT}s"
|
|
164
|
+
)
|
|
165
|
+
except Exception as e:
|
|
166
|
+
return CommandExecuteResponse(success=False, error=str(e))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@router.post("/autocomplete")
|
|
170
|
+
async def autocomplete_command(request: AutocompleteRequest) -> AutocompleteResponse:
|
|
171
|
+
"""Get autocomplete suggestions for a partial command.
|
|
172
|
+
|
|
173
|
+
Provides intelligent autocomplete based on partial input:
|
|
174
|
+
- Empty input: returns all command names
|
|
175
|
+
- Partial command name: returns matching commands and aliases
|
|
176
|
+
- Complete command with args: returns usage hint
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
request: AutocompleteRequest with partial command string.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
AutocompleteResponse: List of autocomplete suggestions.
|
|
183
|
+
"""
|
|
184
|
+
from code_puppy.command_line.command_registry import (
|
|
185
|
+
get_command,
|
|
186
|
+
get_unique_commands,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
partial = request.partial.lstrip("/")
|
|
190
|
+
|
|
191
|
+
# If empty, return all command names
|
|
192
|
+
if not partial:
|
|
193
|
+
suggestions = [f"/{cmd.name}" for cmd in get_unique_commands()]
|
|
194
|
+
return AutocompleteResponse(suggestions=sorted(suggestions))
|
|
195
|
+
|
|
196
|
+
# Split into command name and args
|
|
197
|
+
parts = partial.split(maxsplit=1)
|
|
198
|
+
cmd_partial = parts[0].lower()
|
|
199
|
+
|
|
200
|
+
# If just the command name (no space yet), suggest matching commands
|
|
201
|
+
if len(parts) == 1:
|
|
202
|
+
suggestions = []
|
|
203
|
+
for cmd in get_unique_commands():
|
|
204
|
+
if cmd.name.startswith(cmd_partial):
|
|
205
|
+
suggestions.append(f"/{cmd.name}")
|
|
206
|
+
for alias in cmd.aliases:
|
|
207
|
+
if alias.startswith(cmd_partial):
|
|
208
|
+
suggestions.append(f"/{alias}")
|
|
209
|
+
return AutocompleteResponse(suggestions=sorted(set(suggestions)))
|
|
210
|
+
|
|
211
|
+
# Command name complete, suggest based on command type
|
|
212
|
+
# (For now, just return the command usage as a hint)
|
|
213
|
+
cmd = get_command(cmd_partial)
|
|
214
|
+
if cmd:
|
|
215
|
+
return AutocompleteResponse(suggestions=[cmd.usage])
|
|
216
|
+
|
|
217
|
+
return AutocompleteResponse(suggestions=[])
|