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,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=[])