code-puppy 0.0.348__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.
- code_puppy/agents/__init__.py +2 -0
- code_puppy/agents/agent_manager.py +49 -0
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/base_agent.py +17 -4
- code_puppy/agents/event_stream_handler.py +101 -8
- 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/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 +73 -0
- code_puppy/claude_cache_client.py +249 -34
- code_puppy/command_line/core_commands.py +85 -0
- code_puppy/config.py +66 -62
- code_puppy/messaging/__init__.py +15 -0
- code_puppy/messaging/messages.py +27 -0
- code_puppy/messaging/queue_console.py +1 -1
- code_puppy/messaging/rich_renderer.py +36 -1
- code_puppy/messaging/spinner/__init__.py +20 -2
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_utils.py +54 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +90 -19
- code_puppy/plugins/antigravity_oauth/transport.py +1 -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/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/status_display.py +6 -2
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +83 -33
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +6 -6
- code_puppy/tools/browser/browser_interactions.py +21 -20
- code_puppy/tools/browser/browser_locators.py +9 -9
- code_puppy/tools/browser/browser_navigation.py +7 -7
- code_puppy/tools/browser/browser_screenshot.py +78 -140
- code_puppy/tools/browser/browser_scripts.py +15 -13
- code_puppy/tools/browser/camoufox_manager.py +226 -64
- 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 +292 -101
- code_puppy/tools/common.py +176 -1
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/subagent_context.py +158 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/METADATA +13 -11
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/RECORD +69 -38
- code_puppy/tools/browser/vqa_agent.py +0 -90
- {code_puppy-0.0.348.data → code_puppy-0.0.361.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.348.data → code_puppy-0.0.361.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.348.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
|
+
]
|