autoforge-ai 0.1.0
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.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal Router
|
|
3
|
+
===============
|
|
4
|
+
|
|
5
|
+
REST and WebSocket endpoints for interactive terminal I/O with PTY support.
|
|
6
|
+
Provides real-time bidirectional communication with terminal sessions.
|
|
7
|
+
Supports multiple terminals per project with create, list, rename, delete operations.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from ..services.terminal_manager import (
|
|
20
|
+
create_terminal,
|
|
21
|
+
delete_terminal,
|
|
22
|
+
get_terminal_info,
|
|
23
|
+
get_terminal_session,
|
|
24
|
+
list_terminals,
|
|
25
|
+
rename_terminal,
|
|
26
|
+
stop_terminal_session,
|
|
27
|
+
)
|
|
28
|
+
from ..utils.project_helpers import get_project_path as _get_project_path
|
|
29
|
+
from ..utils.validation import is_valid_project_name as validate_project_name
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
router = APIRouter(prefix="/api/terminal", tags=["terminal"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TerminalCloseCode:
|
|
37
|
+
"""WebSocket close codes for terminal endpoint."""
|
|
38
|
+
|
|
39
|
+
INVALID_PROJECT_NAME = 4000
|
|
40
|
+
PROJECT_NOT_FOUND = 4004
|
|
41
|
+
FAILED_TO_START = 4500
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_terminal_id(terminal_id: str) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Validate terminal ID format.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
terminal_id: The terminal ID to validate
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if valid, False otherwise
|
|
53
|
+
"""
|
|
54
|
+
return bool(re.match(r"^[a-zA-Z0-9]{1,16}$", terminal_id))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Pydantic models for request/response bodies
|
|
58
|
+
class CreateTerminalRequest(BaseModel):
|
|
59
|
+
"""Request body for creating a terminal."""
|
|
60
|
+
|
|
61
|
+
name: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RenameTerminalRequest(BaseModel):
|
|
65
|
+
"""Request body for renaming a terminal."""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TerminalInfoResponse(BaseModel):
|
|
71
|
+
"""Response model for terminal info."""
|
|
72
|
+
|
|
73
|
+
id: str
|
|
74
|
+
name: str
|
|
75
|
+
created_at: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# REST Endpoints
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.get("/{project_name}")
|
|
82
|
+
async def list_project_terminals(project_name: str) -> list[TerminalInfoResponse]:
|
|
83
|
+
"""
|
|
84
|
+
List all terminals for a project.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
project_name: Name of the project
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of terminal info objects
|
|
91
|
+
"""
|
|
92
|
+
if not validate_project_name(project_name):
|
|
93
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
94
|
+
|
|
95
|
+
project_dir = _get_project_path(project_name)
|
|
96
|
+
if not project_dir:
|
|
97
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
98
|
+
|
|
99
|
+
terminals = list_terminals(project_name)
|
|
100
|
+
|
|
101
|
+
# If no terminals exist, create a default one
|
|
102
|
+
if not terminals:
|
|
103
|
+
info = create_terminal(project_name)
|
|
104
|
+
terminals = [info]
|
|
105
|
+
|
|
106
|
+
return [
|
|
107
|
+
TerminalInfoResponse(id=t.id, name=t.name, created_at=t.created_at) for t in terminals
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.post("/{project_name}")
|
|
112
|
+
async def create_project_terminal(
|
|
113
|
+
project_name: str, request: CreateTerminalRequest
|
|
114
|
+
) -> TerminalInfoResponse:
|
|
115
|
+
"""
|
|
116
|
+
Create a new terminal for a project.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
project_name: Name of the project
|
|
120
|
+
request: Request body with optional terminal name
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The created terminal info
|
|
124
|
+
"""
|
|
125
|
+
if not validate_project_name(project_name):
|
|
126
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
127
|
+
|
|
128
|
+
project_dir = _get_project_path(project_name)
|
|
129
|
+
if not project_dir:
|
|
130
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
131
|
+
|
|
132
|
+
info = create_terminal(project_name, request.name)
|
|
133
|
+
return TerminalInfoResponse(id=info.id, name=info.name, created_at=info.created_at)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@router.patch("/{project_name}/{terminal_id}")
|
|
137
|
+
async def rename_project_terminal(
|
|
138
|
+
project_name: str, terminal_id: str, request: RenameTerminalRequest
|
|
139
|
+
) -> TerminalInfoResponse:
|
|
140
|
+
"""
|
|
141
|
+
Rename a terminal.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
project_name: Name of the project
|
|
145
|
+
terminal_id: ID of the terminal to rename
|
|
146
|
+
request: Request body with new name
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The updated terminal info
|
|
150
|
+
"""
|
|
151
|
+
if not validate_project_name(project_name):
|
|
152
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
153
|
+
|
|
154
|
+
if not validate_terminal_id(terminal_id):
|
|
155
|
+
raise HTTPException(status_code=400, detail="Invalid terminal ID")
|
|
156
|
+
|
|
157
|
+
project_dir = _get_project_path(project_name)
|
|
158
|
+
if not project_dir:
|
|
159
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
160
|
+
|
|
161
|
+
if not rename_terminal(project_name, terminal_id, request.name):
|
|
162
|
+
raise HTTPException(status_code=404, detail="Terminal not found")
|
|
163
|
+
|
|
164
|
+
info = get_terminal_info(project_name, terminal_id)
|
|
165
|
+
if not info:
|
|
166
|
+
raise HTTPException(status_code=404, detail="Terminal not found")
|
|
167
|
+
|
|
168
|
+
return TerminalInfoResponse(id=info.id, name=info.name, created_at=info.created_at)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.delete("/{project_name}/{terminal_id}")
|
|
172
|
+
async def delete_project_terminal(project_name: str, terminal_id: str) -> dict:
|
|
173
|
+
"""
|
|
174
|
+
Delete a terminal and stop its session.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
project_name: Name of the project
|
|
178
|
+
terminal_id: ID of the terminal to delete
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Success message
|
|
182
|
+
"""
|
|
183
|
+
if not validate_project_name(project_name):
|
|
184
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
185
|
+
|
|
186
|
+
if not validate_terminal_id(terminal_id):
|
|
187
|
+
raise HTTPException(status_code=400, detail="Invalid terminal ID")
|
|
188
|
+
|
|
189
|
+
project_dir = _get_project_path(project_name)
|
|
190
|
+
if not project_dir:
|
|
191
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
192
|
+
|
|
193
|
+
# Stop the session if it's running
|
|
194
|
+
await stop_terminal_session(project_name, terminal_id)
|
|
195
|
+
|
|
196
|
+
# Delete the terminal metadata
|
|
197
|
+
if not delete_terminal(project_name, terminal_id):
|
|
198
|
+
raise HTTPException(status_code=404, detail="Terminal not found")
|
|
199
|
+
|
|
200
|
+
return {"message": "Terminal deleted"}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# WebSocket Endpoint
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router.websocket("/ws/{project_name}/{terminal_id}")
|
|
207
|
+
async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_id: str) -> None:
|
|
208
|
+
"""
|
|
209
|
+
WebSocket endpoint for interactive terminal I/O.
|
|
210
|
+
|
|
211
|
+
Message protocol:
|
|
212
|
+
|
|
213
|
+
Client -> Server:
|
|
214
|
+
- {"type": "input", "data": "<base64-encoded-bytes>"} - Keyboard input
|
|
215
|
+
- {"type": "resize", "cols": 80, "rows": 24} - Terminal resize
|
|
216
|
+
- {"type": "ping"} - Keep-alive ping
|
|
217
|
+
|
|
218
|
+
Server -> Client:
|
|
219
|
+
- {"type": "output", "data": "<base64-encoded-bytes>"} - PTY output
|
|
220
|
+
- {"type": "exit", "code": 0} - Shell process exited
|
|
221
|
+
- {"type": "pong"} - Keep-alive response
|
|
222
|
+
- {"type": "error", "message": "..."} - Error message
|
|
223
|
+
"""
|
|
224
|
+
# Validate project name
|
|
225
|
+
if not validate_project_name(project_name):
|
|
226
|
+
await websocket.close(
|
|
227
|
+
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
|
|
228
|
+
)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# Validate terminal ID
|
|
232
|
+
if not validate_terminal_id(terminal_id):
|
|
233
|
+
await websocket.close(
|
|
234
|
+
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Look up project directory from registry
|
|
239
|
+
project_dir = _get_project_path(project_name)
|
|
240
|
+
if not project_dir:
|
|
241
|
+
await websocket.close(
|
|
242
|
+
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
|
243
|
+
reason="Project not found in registry",
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
if not project_dir.exists():
|
|
248
|
+
await websocket.close(
|
|
249
|
+
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
|
250
|
+
reason="Project directory not found",
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
# Verify terminal exists in metadata
|
|
255
|
+
terminal_info = get_terminal_info(project_name, terminal_id)
|
|
256
|
+
if not terminal_info:
|
|
257
|
+
await websocket.close(
|
|
258
|
+
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
|
259
|
+
reason="Terminal not found",
|
|
260
|
+
)
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
await websocket.accept()
|
|
264
|
+
|
|
265
|
+
# Get or create terminal session for this project/terminal
|
|
266
|
+
session = get_terminal_session(project_name, project_dir, terminal_id)
|
|
267
|
+
|
|
268
|
+
# Queue for output data to send to client
|
|
269
|
+
output_queue: asyncio.Queue[bytes] = asyncio.Queue()
|
|
270
|
+
|
|
271
|
+
# Callback to receive terminal output and queue it for sending
|
|
272
|
+
def on_output(data: bytes) -> None:
|
|
273
|
+
"""Queue terminal output for async sending to WebSocket."""
|
|
274
|
+
try:
|
|
275
|
+
output_queue.put_nowait(data)
|
|
276
|
+
except asyncio.QueueFull:
|
|
277
|
+
logger.warning(f"Output queue full for {project_name}, dropping data")
|
|
278
|
+
|
|
279
|
+
# Register the output callback
|
|
280
|
+
session.add_output_callback(on_output)
|
|
281
|
+
|
|
282
|
+
# Track if we need to wait for initial resize before starting
|
|
283
|
+
# This ensures the PTY is created with correct dimensions from the start
|
|
284
|
+
needs_initial_resize = not session.is_active
|
|
285
|
+
|
|
286
|
+
# Task to send queued output to WebSocket
|
|
287
|
+
async def send_output_task() -> None:
|
|
288
|
+
"""Continuously send queued output to the WebSocket client."""
|
|
289
|
+
try:
|
|
290
|
+
while True:
|
|
291
|
+
# Wait for output data
|
|
292
|
+
data = await output_queue.get()
|
|
293
|
+
|
|
294
|
+
# Encode as base64 and send
|
|
295
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
296
|
+
await websocket.send_json({"type": "output", "data": encoded})
|
|
297
|
+
|
|
298
|
+
except asyncio.CancelledError:
|
|
299
|
+
raise
|
|
300
|
+
except WebSocketDisconnect:
|
|
301
|
+
raise
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.warning(f"Error sending output for {project_name}: {e}")
|
|
304
|
+
raise
|
|
305
|
+
|
|
306
|
+
# Task to monitor if the terminal session exits
|
|
307
|
+
async def monitor_exit_task() -> None:
|
|
308
|
+
"""Monitor the terminal session and notify client on exit."""
|
|
309
|
+
try:
|
|
310
|
+
# Wait for session to become active first (deferred start)
|
|
311
|
+
while not session.is_active:
|
|
312
|
+
await asyncio.sleep(0.1)
|
|
313
|
+
|
|
314
|
+
# Now monitor until it becomes inactive
|
|
315
|
+
while session.is_active:
|
|
316
|
+
await asyncio.sleep(0.5)
|
|
317
|
+
|
|
318
|
+
# Session ended - send exit message
|
|
319
|
+
# Note: We don't have access to actual exit code from PTY
|
|
320
|
+
await websocket.send_json({"type": "exit", "code": 0})
|
|
321
|
+
|
|
322
|
+
except asyncio.CancelledError:
|
|
323
|
+
raise
|
|
324
|
+
except WebSocketDisconnect:
|
|
325
|
+
raise
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.warning(f"Error in exit monitor for {project_name}: {e}")
|
|
328
|
+
|
|
329
|
+
# Start background tasks
|
|
330
|
+
output_task = asyncio.create_task(send_output_task())
|
|
331
|
+
exit_task = asyncio.create_task(monitor_exit_task())
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
while True:
|
|
335
|
+
try:
|
|
336
|
+
# Receive message from client
|
|
337
|
+
data = await websocket.receive_text()
|
|
338
|
+
message = json.loads(data)
|
|
339
|
+
msg_type = message.get("type")
|
|
340
|
+
|
|
341
|
+
if msg_type == "ping":
|
|
342
|
+
await websocket.send_json({"type": "pong"})
|
|
343
|
+
|
|
344
|
+
elif msg_type == "input":
|
|
345
|
+
# Only allow input after terminal is started
|
|
346
|
+
if not session.is_active:
|
|
347
|
+
await websocket.send_json(
|
|
348
|
+
{"type": "error", "message": "Terminal not ready - send resize first"}
|
|
349
|
+
)
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
# Decode base64 input and write to PTY
|
|
353
|
+
encoded_data = message.get("data", "")
|
|
354
|
+
# Add size limit to prevent DoS
|
|
355
|
+
if len(encoded_data) > 65536: # 64KB limit for base64 encoded data
|
|
356
|
+
await websocket.send_json({"type": "error", "message": "Input too large"})
|
|
357
|
+
continue
|
|
358
|
+
if encoded_data:
|
|
359
|
+
try:
|
|
360
|
+
decoded = base64.b64decode(encoded_data)
|
|
361
|
+
except (ValueError, TypeError) as e:
|
|
362
|
+
logger.warning(f"Failed to decode base64 input: {e}")
|
|
363
|
+
await websocket.send_json(
|
|
364
|
+
{"type": "error", "message": "Invalid base64 data"}
|
|
365
|
+
)
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
session.write(decoded)
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.warning(f"Failed to write to terminal: {e}")
|
|
372
|
+
await websocket.send_json(
|
|
373
|
+
{"type": "error", "message": "Failed to write to terminal"}
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
elif msg_type == "resize":
|
|
377
|
+
# Resize the terminal
|
|
378
|
+
cols = message.get("cols", 80)
|
|
379
|
+
rows = message.get("rows", 24)
|
|
380
|
+
|
|
381
|
+
# Validate dimensions
|
|
382
|
+
if isinstance(cols, int) and isinstance(rows, int):
|
|
383
|
+
cols = max(10, min(500, cols))
|
|
384
|
+
rows = max(5, min(200, rows))
|
|
385
|
+
|
|
386
|
+
# If this is the first resize and session not started, start with these dimensions
|
|
387
|
+
# This ensures the PTY is created with correct size from the beginning
|
|
388
|
+
if needs_initial_resize and not session.is_active:
|
|
389
|
+
started = await session.start(cols=cols, rows=rows)
|
|
390
|
+
if not started:
|
|
391
|
+
session.remove_output_callback(on_output)
|
|
392
|
+
try:
|
|
393
|
+
await websocket.send_json(
|
|
394
|
+
{"type": "error", "message": "Failed to start terminal session"}
|
|
395
|
+
)
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
await websocket.close(
|
|
399
|
+
code=TerminalCloseCode.FAILED_TO_START, reason="Failed to start terminal"
|
|
400
|
+
)
|
|
401
|
+
return
|
|
402
|
+
# Mark that we no longer need initial resize
|
|
403
|
+
needs_initial_resize = False
|
|
404
|
+
else:
|
|
405
|
+
session.resize(cols, rows)
|
|
406
|
+
else:
|
|
407
|
+
await websocket.send_json({"type": "error", "message": "Invalid resize dimensions"})
|
|
408
|
+
|
|
409
|
+
else:
|
|
410
|
+
await websocket.send_json({"type": "error", "message": f"Unknown message type: {msg_type}"})
|
|
411
|
+
|
|
412
|
+
except json.JSONDecodeError:
|
|
413
|
+
await websocket.send_json({"type": "error", "message": "Invalid JSON"})
|
|
414
|
+
|
|
415
|
+
except WebSocketDisconnect:
|
|
416
|
+
logger.info(f"Terminal WebSocket disconnected for {project_name}/{terminal_id}")
|
|
417
|
+
|
|
418
|
+
except Exception as e:
|
|
419
|
+
logger.exception(f"Terminal WebSocket error for {project_name}/{terminal_id}")
|
|
420
|
+
try:
|
|
421
|
+
await websocket.send_json({"type": "error", "message": f"Server error: {str(e)}"})
|
|
422
|
+
except Exception:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
finally:
|
|
426
|
+
# Cancel background tasks
|
|
427
|
+
output_task.cancel()
|
|
428
|
+
exit_task.cancel()
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
await output_task
|
|
432
|
+
except asyncio.CancelledError:
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
await exit_task
|
|
437
|
+
except asyncio.CancelledError:
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
# Remove the output callback
|
|
441
|
+
session.remove_output_callback(on_output)
|
|
442
|
+
|
|
443
|
+
# Only stop session if no other clients are connected
|
|
444
|
+
with session._callbacks_lock:
|
|
445
|
+
remaining_callbacks = len(session._output_callbacks)
|
|
446
|
+
|
|
447
|
+
if remaining_callbacks == 0:
|
|
448
|
+
await session.stop()
|
|
449
|
+
logger.info(f"Terminal session stopped for {project_name}/{terminal_id} (last client disconnected)")
|
|
450
|
+
else:
|
|
451
|
+
logger.info(
|
|
452
|
+
f"Client disconnected from {project_name}/{terminal_id}, {remaining_callbacks} clients remaining"
|
|
453
|
+
)
|