code-puppy 0.0.341__py3-none-any.whl → 0.0.361__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 (86) hide show
  1. code_puppy/agents/__init__.py +2 -0
  2. code_puppy/agents/agent_manager.py +49 -0
  3. code_puppy/agents/agent_pack_leader.py +383 -0
  4. code_puppy/agents/agent_qa_kitten.py +12 -7
  5. code_puppy/agents/agent_terminal_qa.py +323 -0
  6. code_puppy/agents/base_agent.py +34 -252
  7. code_puppy/agents/event_stream_handler.py +350 -0
  8. code_puppy/agents/pack/__init__.py +34 -0
  9. code_puppy/agents/pack/bloodhound.py +304 -0
  10. code_puppy/agents/pack/husky.py +321 -0
  11. code_puppy/agents/pack/retriever.py +393 -0
  12. code_puppy/agents/pack/shepherd.py +348 -0
  13. code_puppy/agents/pack/terrier.py +287 -0
  14. code_puppy/agents/pack/watchdog.py +367 -0
  15. code_puppy/agents/subagent_stream_handler.py +276 -0
  16. code_puppy/api/__init__.py +13 -0
  17. code_puppy/api/app.py +169 -0
  18. code_puppy/api/main.py +21 -0
  19. code_puppy/api/pty_manager.py +446 -0
  20. code_puppy/api/routers/__init__.py +12 -0
  21. code_puppy/api/routers/agents.py +36 -0
  22. code_puppy/api/routers/commands.py +217 -0
  23. code_puppy/api/routers/config.py +74 -0
  24. code_puppy/api/routers/sessions.py +232 -0
  25. code_puppy/api/templates/terminal.html +361 -0
  26. code_puppy/api/websocket.py +154 -0
  27. code_puppy/callbacks.py +73 -0
  28. code_puppy/claude_cache_client.py +249 -34
  29. code_puppy/cli_runner.py +4 -3
  30. code_puppy/command_line/add_model_menu.py +8 -9
  31. code_puppy/command_line/core_commands.py +85 -0
  32. code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
  33. code_puppy/command_line/mcp/custom_server_form.py +54 -19
  34. code_puppy/command_line/mcp/custom_server_installer.py +8 -9
  35. code_puppy/command_line/mcp/handler.py +0 -2
  36. code_puppy/command_line/mcp/help_command.py +1 -5
  37. code_puppy/command_line/mcp/start_command.py +36 -18
  38. code_puppy/command_line/onboarding_slides.py +0 -1
  39. code_puppy/command_line/prompt_toolkit_completion.py +16 -10
  40. code_puppy/command_line/utils.py +54 -0
  41. code_puppy/config.py +66 -62
  42. code_puppy/mcp_/async_lifecycle.py +35 -4
  43. code_puppy/mcp_/managed_server.py +49 -20
  44. code_puppy/mcp_/manager.py +81 -52
  45. code_puppy/messaging/__init__.py +15 -0
  46. code_puppy/messaging/message_queue.py +11 -23
  47. code_puppy/messaging/messages.py +27 -0
  48. code_puppy/messaging/queue_console.py +1 -1
  49. code_puppy/messaging/rich_renderer.py +36 -1
  50. code_puppy/messaging/spinner/__init__.py +20 -2
  51. code_puppy/messaging/subagent_console.py +461 -0
  52. code_puppy/model_utils.py +54 -0
  53. code_puppy/plugins/antigravity_oauth/antigravity_model.py +90 -19
  54. code_puppy/plugins/antigravity_oauth/transport.py +1 -0
  55. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  56. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  57. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  58. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  59. code_puppy/status_display.py +6 -2
  60. code_puppy/tools/__init__.py +37 -1
  61. code_puppy/tools/agent_tools.py +139 -36
  62. code_puppy/tools/browser/__init__.py +37 -0
  63. code_puppy/tools/browser/browser_control.py +6 -6
  64. code_puppy/tools/browser/browser_interactions.py +21 -20
  65. code_puppy/tools/browser/browser_locators.py +9 -9
  66. code_puppy/tools/browser/browser_navigation.py +7 -7
  67. code_puppy/tools/browser/browser_screenshot.py +78 -140
  68. code_puppy/tools/browser/browser_scripts.py +15 -13
  69. code_puppy/tools/browser/camoufox_manager.py +226 -64
  70. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  71. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  72. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  73. code_puppy/tools/browser/terminal_tools.py +525 -0
  74. code_puppy/tools/command_runner.py +292 -101
  75. code_puppy/tools/common.py +176 -1
  76. code_puppy/tools/display.py +84 -0
  77. code_puppy/tools/subagent_context.py +158 -0
  78. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/METADATA +13 -11
  79. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/RECORD +84 -53
  80. code_puppy/command_line/mcp/add_command.py +0 -170
  81. code_puppy/tools/browser/vqa_agent.py +0 -90
  82. {code_puppy-0.0.341.data → code_puppy-0.0.361.data}/data/code_puppy/models.json +0 -0
  83. {code_puppy-0.0.341.data → code_puppy-0.0.361.data}/data/code_puppy/models_dev_api.json +0 -0
  84. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/WHEEL +0 -0
  85. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/entry_points.txt +0 -0
  86. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/licenses/LICENSE +0 -0
code_puppy/api/app.py ADDED
@@ -0,0 +1,169 @@
1
+ """FastAPI application factory for Code Puppy API."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from typing import AsyncGenerator
8
+
9
+ from fastapi import FastAPI, Request
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
12
+ from starlette.middleware.base import BaseHTTPMiddleware
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Default request timeout (seconds) - fail fast!
17
+ REQUEST_TIMEOUT = 30.0
18
+
19
+
20
+ class TimeoutMiddleware(BaseHTTPMiddleware):
21
+ """Middleware to enforce request timeouts and prevent hanging requests."""
22
+
23
+ def __init__(self, app, timeout: float = REQUEST_TIMEOUT):
24
+ super().__init__(app)
25
+ self.timeout = timeout
26
+
27
+ async def dispatch(self, request: Request, call_next):
28
+ # Skip timeout for WebSocket upgrades and streaming endpoints
29
+ if request.headers.get(
30
+ "upgrade", ""
31
+ ).lower() == "websocket" or request.url.path.startswith("/ws/"):
32
+ return await call_next(request)
33
+
34
+ try:
35
+ return await asyncio.wait_for(
36
+ call_next(request),
37
+ timeout=self.timeout,
38
+ )
39
+ except asyncio.TimeoutError:
40
+ return JSONResponse(
41
+ status_code=504,
42
+ content={
43
+ "detail": f"Request timed out after {self.timeout}s",
44
+ "error": "timeout",
45
+ },
46
+ )
47
+
48
+
49
+ @asynccontextmanager
50
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
51
+ """Lifespan context manager for startup and shutdown events.
52
+
53
+ Handles graceful cleanup of resources when the server shuts down.
54
+ """
55
+ # Startup: nothing special needed yet, but this is where you'd do it
56
+ logger.info("🐶 Code Puppy API starting up...")
57
+ yield
58
+ # Shutdown: clean up all the things!
59
+ logger.info("🐶 Code Puppy API shutting down, cleaning up...")
60
+
61
+ # 1. Close all PTY sessions
62
+ try:
63
+ from code_puppy.api.pty_manager import get_pty_manager
64
+
65
+ pty_manager = get_pty_manager()
66
+ await pty_manager.close_all()
67
+ logger.info("✓ All PTY sessions closed")
68
+ except Exception as e:
69
+ logger.error(f"Error closing PTY sessions: {e}")
70
+
71
+ # 2. Remove PID file so /api status knows we're gone
72
+ try:
73
+ from code_puppy.config import STATE_DIR
74
+
75
+ pid_file = Path(STATE_DIR) / "api_server.pid"
76
+ if pid_file.exists():
77
+ pid_file.unlink()
78
+ logger.info("✓ PID file removed")
79
+ except Exception as e:
80
+ logger.error(f"Error removing PID file: {e}")
81
+
82
+
83
+ def create_app() -> FastAPI:
84
+ """Create and configure the FastAPI application."""
85
+ app = FastAPI(
86
+ lifespan=lifespan,
87
+ title="Code Puppy API",
88
+ description="REST API and Interactive Terminal for Code Puppy",
89
+ version="1.0.0",
90
+ docs_url="/docs",
91
+ redoc_url="/redoc",
92
+ )
93
+
94
+ # Timeout middleware - added first so it wraps everything
95
+ app.add_middleware(TimeoutMiddleware, timeout=REQUEST_TIMEOUT)
96
+
97
+ # CORS middleware for frontend access
98
+ app.add_middleware(
99
+ CORSMiddleware,
100
+ allow_origins=["*"], # Local/trusted
101
+ allow_credentials=True,
102
+ allow_methods=["*"],
103
+ allow_headers=["*"],
104
+ )
105
+
106
+ # Include routers
107
+ from code_puppy.api.routers import agents, commands, config, sessions
108
+
109
+ app.include_router(config.router, prefix="/api/config", tags=["config"])
110
+ app.include_router(commands.router, prefix="/api/commands", tags=["commands"])
111
+ app.include_router(sessions.router, prefix="/api/sessions", tags=["sessions"])
112
+ app.include_router(agents.router, prefix="/api/agents", tags=["agents"])
113
+
114
+ # WebSocket endpoints (events + terminal)
115
+ from code_puppy.api.websocket import setup_websocket
116
+
117
+ setup_websocket(app)
118
+
119
+ # Templates directory
120
+ templates_dir = Path(__file__).parent / "templates"
121
+
122
+ @app.get("/")
123
+ async def root():
124
+ """Landing page with links to terminal and docs."""
125
+ return HTMLResponse(
126
+ content="""
127
+ <!DOCTYPE html>
128
+ <html>
129
+ <head>
130
+ <title>Code Puppy 🐶</title>
131
+ <script src="https://cdn.tailwindcss.com"></script>
132
+ </head>
133
+ <body class="bg-gray-900 text-white min-h-screen flex items-center justify-center">
134
+ <div class="text-center">
135
+ <h1 class="text-6xl mb-4">🐶</h1>
136
+ <h2 class="text-3xl font-bold mb-8">Code Puppy</h2>
137
+ <div class="space-x-4">
138
+ <a href="/terminal" class="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg text-lg font-semibold">
139
+ Open Terminal
140
+ </a>
141
+ <a href="/docs" class="px-6 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-lg">
142
+ API Docs
143
+ </a>
144
+ </div>
145
+ <p class="mt-8 text-gray-400">
146
+ WebSocket: ws://localhost:8765/ws/terminal
147
+ </p>
148
+ </div>
149
+ </body>
150
+ </html>
151
+ """
152
+ )
153
+
154
+ @app.get("/terminal")
155
+ async def terminal_page():
156
+ """Serve the interactive terminal page."""
157
+ html_file = templates_dir / "terminal.html"
158
+ if html_file.exists():
159
+ return FileResponse(html_file, media_type="text/html")
160
+ return HTMLResponse(
161
+ content="<h1>Terminal template not found</h1>",
162
+ status_code=404,
163
+ )
164
+
165
+ @app.get("/health")
166
+ async def health():
167
+ return {"status": "healthy"}
168
+
169
+ return app
code_puppy/api/main.py ADDED
@@ -0,0 +1,21 @@
1
+ """Entry point for running the FastAPI server."""
2
+
3
+ import uvicorn
4
+
5
+ from code_puppy.api.app import create_app
6
+
7
+ app = create_app()
8
+
9
+
10
+ def main(host: str = "127.0.0.1", port: int = 8765) -> None:
11
+ """Run the FastAPI server.
12
+
13
+ Args:
14
+ host: The host address to bind to. Defaults to localhost.
15
+ port: The port number to listen on. Defaults to 8765.
16
+ """
17
+ uvicorn.run(app, host=host, port=port)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()
@@ -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
+ ]