openhands-agent-server 1.8.2__tar.gz

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 (43) hide show
  1. openhands_agent_server-1.8.2/PKG-INFO +15 -0
  2. openhands_agent_server-1.8.2/openhands/agent_server/__init__.py +0 -0
  3. openhands_agent_server-1.8.2/openhands/agent_server/__main__.py +118 -0
  4. openhands_agent_server-1.8.2/openhands/agent_server/api.py +331 -0
  5. openhands_agent_server-1.8.2/openhands/agent_server/bash_router.py +105 -0
  6. openhands_agent_server-1.8.2/openhands/agent_server/bash_service.py +379 -0
  7. openhands_agent_server-1.8.2/openhands/agent_server/config.py +187 -0
  8. openhands_agent_server-1.8.2/openhands/agent_server/conversation_router.py +321 -0
  9. openhands_agent_server-1.8.2/openhands/agent_server/conversation_service.py +692 -0
  10. openhands_agent_server-1.8.2/openhands/agent_server/dependencies.py +72 -0
  11. openhands_agent_server-1.8.2/openhands/agent_server/desktop_router.py +47 -0
  12. openhands_agent_server-1.8.2/openhands/agent_server/desktop_service.py +212 -0
  13. openhands_agent_server-1.8.2/openhands/agent_server/docker/Dockerfile +244 -0
  14. openhands_agent_server-1.8.2/openhands/agent_server/docker/build.py +825 -0
  15. openhands_agent_server-1.8.2/openhands/agent_server/docker/wallpaper.svg +22 -0
  16. openhands_agent_server-1.8.2/openhands/agent_server/env_parser.py +460 -0
  17. openhands_agent_server-1.8.2/openhands/agent_server/event_router.py +204 -0
  18. openhands_agent_server-1.8.2/openhands/agent_server/event_service.py +648 -0
  19. openhands_agent_server-1.8.2/openhands/agent_server/file_router.py +121 -0
  20. openhands_agent_server-1.8.2/openhands/agent_server/git_router.py +34 -0
  21. openhands_agent_server-1.8.2/openhands/agent_server/logging_config.py +56 -0
  22. openhands_agent_server-1.8.2/openhands/agent_server/middleware.py +32 -0
  23. openhands_agent_server-1.8.2/openhands/agent_server/models.py +307 -0
  24. openhands_agent_server-1.8.2/openhands/agent_server/openapi.py +21 -0
  25. openhands_agent_server-1.8.2/openhands/agent_server/pub_sub.py +80 -0
  26. openhands_agent_server-1.8.2/openhands/agent_server/py.typed +0 -0
  27. openhands_agent_server-1.8.2/openhands/agent_server/server_details_router.py +43 -0
  28. openhands_agent_server-1.8.2/openhands/agent_server/sockets.py +173 -0
  29. openhands_agent_server-1.8.2/openhands/agent_server/tool_preload_service.py +76 -0
  30. openhands_agent_server-1.8.2/openhands/agent_server/tool_router.py +22 -0
  31. openhands_agent_server-1.8.2/openhands/agent_server/utils.py +63 -0
  32. openhands_agent_server-1.8.2/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +22 -0
  33. openhands_agent_server-1.8.2/openhands/agent_server/vscode_extensions/openhands-settings/package.json +12 -0
  34. openhands_agent_server-1.8.2/openhands/agent_server/vscode_router.py +70 -0
  35. openhands_agent_server-1.8.2/openhands/agent_server/vscode_service.py +232 -0
  36. openhands_agent_server-1.8.2/openhands_agent_server.egg-info/PKG-INFO +15 -0
  37. openhands_agent_server-1.8.2/openhands_agent_server.egg-info/SOURCES.txt +75 -0
  38. openhands_agent_server-1.8.2/openhands_agent_server.egg-info/dependency_links.txt +1 -0
  39. openhands_agent_server-1.8.2/openhands_agent_server.egg-info/entry_points.txt +2 -0
  40. openhands_agent_server-1.8.2/openhands_agent_server.egg-info/requires.txt +10 -0
  41. openhands_agent_server-1.8.2/openhands_agent_server.egg-info/top_level.txt +1 -0
  42. openhands_agent_server-1.8.2/pyproject.toml +43 -0
  43. openhands_agent_server-1.8.2/setup.cfg +4 -0
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: openhands-agent-server
3
+ Version: 1.8.2
4
+ Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: aiosqlite>=0.19
7
+ Requires-Dist: alembic>=1.13
8
+ Requires-Dist: docker<8,>=7.1
9
+ Requires-Dist: fastapi>=0.104
10
+ Requires-Dist: openhands-sdk
11
+ Requires-Dist: pydantic>=2
12
+ Requires-Dist: sqlalchemy>=2
13
+ Requires-Dist: uvicorn>=0.31.1
14
+ Requires-Dist: websockets>=12
15
+ Requires-Dist: wsproto>=1.2.0
@@ -0,0 +1,118 @@
1
+ import argparse
2
+ import atexit
3
+ import faulthandler
4
+ import signal
5
+ from types import FrameType
6
+
7
+ import uvicorn
8
+ from uvicorn import Config
9
+
10
+ from openhands.agent_server.logging_config import LOGGING_CONFIG
11
+ from openhands.sdk.logger import DEBUG, get_logger
12
+
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class LoggingServer(uvicorn.Server):
18
+ """Custom uvicorn Server that logs signal handling events.
19
+
20
+ This subclass overrides handle_exit to add structured logging when
21
+ termination signals are received, ensuring visibility into why the
22
+ server is shutting down.
23
+ """
24
+
25
+ def handle_exit(self, sig: int, frame: FrameType | None) -> None:
26
+ """Handle exit signals with logging before delegating to parent."""
27
+ sig_name = signal.Signals(sig).name
28
+ logger.info(
29
+ "Received signal %s (%d), shutting down...",
30
+ sig_name,
31
+ sig,
32
+ )
33
+ super().handle_exit(sig, frame)
34
+
35
+
36
+ def _setup_crash_diagnostics() -> None:
37
+ """Enable crash diagnostics for debugging unexpected terminations.
38
+
39
+ Note: faulthandler outputs tracebacks to stderr in plain text format,
40
+ not through the structured JSON logger. This is unavoidable because
41
+ during a segfault, Python's normal logging infrastructure is not
42
+ available. The plain text traceback is still valuable for debugging.
43
+ """
44
+ faulthandler.enable()
45
+
46
+ # Register atexit handler to log normal exits
47
+ @atexit.register
48
+ def _log_exit() -> None:
49
+ logger.info("Process exiting via atexit handler")
50
+
51
+
52
+ def main() -> None:
53
+ # Set up crash diagnostics early, before any other initialization
54
+ _setup_crash_diagnostics()
55
+
56
+ parser = argparse.ArgumentParser(description="OpenHands Agent Server App")
57
+ parser.add_argument(
58
+ "--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
59
+ )
60
+ parser.add_argument(
61
+ "--port", type=int, default=8000, help="Port to bind to (default: 8000)"
62
+ )
63
+ parser.add_argument(
64
+ "--reload",
65
+ dest="reload",
66
+ default=False,
67
+ action="store_true",
68
+ help="Enable auto-reload (disabled by default)",
69
+ )
70
+
71
+ args = parser.parse_args()
72
+
73
+ print(f"🙌 Starting OpenHands Agent Server on {args.host}:{args.port}")
74
+ print(f"📖 API docs will be available at http://{args.host}:{args.port}/docs")
75
+ print(f"🔄 Auto-reload: {'enabled' if args.reload else 'disabled'}")
76
+
77
+ # Show debug mode status
78
+ if DEBUG:
79
+ print("🐛 DEBUG mode: ENABLED (stack traces will be shown)")
80
+ else:
81
+ print("🔒 DEBUG mode: DISABLED")
82
+ print()
83
+
84
+ # Configure uvicorn logging based on DEBUG environment variable
85
+ log_level = "debug" if DEBUG else "info"
86
+
87
+ # Create uvicorn config
88
+ config = Config(
89
+ "openhands.agent_server.api:api",
90
+ host=args.host,
91
+ port=args.port,
92
+ reload=args.reload,
93
+ reload_includes=[
94
+ "openhands-agent-server",
95
+ "openhands-sdk",
96
+ "openhands-tools",
97
+ ],
98
+ log_level=log_level,
99
+ log_config=LOGGING_CONFIG,
100
+ ws="wsproto", # Use wsproto instead of deprecated websockets implementation
101
+ )
102
+
103
+ # Use custom LoggingServer to capture signal handling events
104
+ server = LoggingServer(config)
105
+
106
+ try:
107
+ server.run()
108
+ except Exception:
109
+ logger.error("Server crashed with unexpected exception", exc_info=True)
110
+ raise
111
+ except BaseException as e:
112
+ # Catch SystemExit, KeyboardInterrupt, etc. - these are normal termination paths
113
+ logger.info("Server terminated: %s: %s", type(e).__name__, e)
114
+ raise
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,331 @@
1
+ import asyncio
2
+ import traceback
3
+ from collections.abc import AsyncIterator
4
+ from contextlib import asynccontextmanager
5
+
6
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException
7
+ from fastapi.responses import JSONResponse, RedirectResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+ from starlette.requests import Request
10
+
11
+ from openhands.agent_server.bash_router import bash_router
12
+ from openhands.agent_server.config import (
13
+ Config,
14
+ get_default_config,
15
+ )
16
+ from openhands.agent_server.conversation_router import conversation_router
17
+ from openhands.agent_server.conversation_service import (
18
+ get_default_conversation_service,
19
+ )
20
+ from openhands.agent_server.dependencies import create_session_api_key_dependency
21
+ from openhands.agent_server.desktop_router import desktop_router
22
+ from openhands.agent_server.desktop_service import get_desktop_service
23
+ from openhands.agent_server.event_router import event_router
24
+ from openhands.agent_server.file_router import file_router
25
+ from openhands.agent_server.git_router import git_router
26
+ from openhands.agent_server.middleware import LocalhostCORSMiddleware
27
+ from openhands.agent_server.server_details_router import (
28
+ get_server_info,
29
+ server_details_router,
30
+ )
31
+ from openhands.agent_server.sockets import sockets_router
32
+ from openhands.agent_server.tool_preload_service import get_tool_preload_service
33
+ from openhands.agent_server.tool_router import tool_router
34
+ from openhands.agent_server.vscode_router import vscode_router
35
+ from openhands.agent_server.vscode_service import get_vscode_service
36
+ from openhands.sdk.logger import DEBUG, get_logger
37
+
38
+
39
+ logger = get_logger(__name__)
40
+
41
+
42
+ @asynccontextmanager
43
+ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
44
+ service = get_default_conversation_service()
45
+ vscode_service = get_vscode_service()
46
+ desktop_service = get_desktop_service()
47
+ tool_preload_service = get_tool_preload_service()
48
+
49
+ # Define async functions for starting each service
50
+ async def start_vscode_service():
51
+ if vscode_service is not None:
52
+ vscode_started = await vscode_service.start()
53
+ if vscode_started:
54
+ logger.info("VSCode service started successfully")
55
+ else:
56
+ logger.warning(
57
+ "VSCode service failed to start, continuing without VSCode"
58
+ )
59
+ else:
60
+ logger.info("VSCode service is disabled")
61
+
62
+ async def start_desktop_service():
63
+ if desktop_service is not None:
64
+ desktop_started = await desktop_service.start()
65
+ if desktop_started:
66
+ logger.info("Desktop service started successfully")
67
+ else:
68
+ logger.warning(
69
+ "Desktop service failed to start, continuing without desktop"
70
+ )
71
+ else:
72
+ logger.info("Desktop service is disabled")
73
+
74
+ async def start_tool_preload_service():
75
+ if tool_preload_service is not None:
76
+ tool_preload_started = await tool_preload_service.start()
77
+ if tool_preload_started:
78
+ logger.info("Tool preload service started successfully")
79
+ else:
80
+ logger.warning("Tool preload service failed to start - skipping")
81
+ else:
82
+ logger.info("Tool preload service is disabled")
83
+
84
+ # Start all services concurrently
85
+ await asyncio.gather(
86
+ start_vscode_service(),
87
+ start_desktop_service(),
88
+ start_tool_preload_service(),
89
+ return_exceptions=True,
90
+ )
91
+
92
+ async with service:
93
+ # Store the initialized service in app state for dependency injection
94
+ api.state.conversation_service = service
95
+ try:
96
+ yield
97
+ finally:
98
+ # Define async functions for stopping each service
99
+ async def stop_vscode_service():
100
+ if vscode_service is not None:
101
+ await vscode_service.stop()
102
+
103
+ async def stop_desktop_service():
104
+ if desktop_service is not None:
105
+ await desktop_service.stop()
106
+
107
+ async def stop_tool_preload_service():
108
+ if tool_preload_service is not None:
109
+ await tool_preload_service.stop()
110
+
111
+ # Stop all services concurrently
112
+ await asyncio.gather(
113
+ stop_vscode_service(),
114
+ stop_desktop_service(),
115
+ stop_tool_preload_service(),
116
+ return_exceptions=True,
117
+ )
118
+
119
+
120
+ def _create_fastapi_instance() -> FastAPI:
121
+ """Create the basic FastAPI application instance.
122
+
123
+ Returns:
124
+ Basic FastAPI application with title, description, and lifespan.
125
+ """
126
+ return FastAPI(
127
+ title="OpenHands Agent Server",
128
+ description=(
129
+ "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
130
+ ),
131
+ lifespan=api_lifespan,
132
+ )
133
+
134
+
135
+ def _find_http_exception(exc: BaseExceptionGroup) -> HTTPException | None:
136
+ """Helper function to find HTTPException in ExceptionGroup.
137
+
138
+ Args:
139
+ exc: BaseExceptionGroup to search for HTTPException.
140
+
141
+ Returns:
142
+ HTTPException if found, None otherwise.
143
+ """
144
+ for inner_exc in exc.exceptions:
145
+ if isinstance(inner_exc, HTTPException):
146
+ return inner_exc
147
+ # Recursively search nested ExceptionGroups
148
+ if isinstance(inner_exc, BaseExceptionGroup):
149
+ found = _find_http_exception(inner_exc)
150
+ if found:
151
+ return found
152
+ return None
153
+
154
+
155
+ def _add_api_routes(app: FastAPI, config: Config) -> None:
156
+ """Add all API routes to the FastAPI application.
157
+
158
+ Args:
159
+ app: FastAPI application instance to add routes to.
160
+ """
161
+ app.include_router(server_details_router)
162
+
163
+ dependencies = []
164
+ if config.session_api_keys:
165
+ dependencies.append(Depends(create_session_api_key_dependency(config)))
166
+
167
+ api_router = APIRouter(prefix="/api", dependencies=dependencies)
168
+ api_router.include_router(event_router)
169
+ api_router.include_router(conversation_router)
170
+ api_router.include_router(tool_router)
171
+ api_router.include_router(bash_router)
172
+ api_router.include_router(git_router)
173
+ api_router.include_router(file_router)
174
+ api_router.include_router(vscode_router)
175
+ api_router.include_router(desktop_router)
176
+ app.include_router(api_router)
177
+ app.include_router(sockets_router)
178
+
179
+
180
+ def _setup_static_files(app: FastAPI, config: Config) -> None:
181
+ """Set up static file serving and root redirect if configured.
182
+
183
+ Args:
184
+ app: FastAPI application instance.
185
+ config: Configuration object containing static files settings.
186
+ """
187
+ # Only proceed if static files are configured and directory exists
188
+ if not (
189
+ config.static_files_path
190
+ and config.static_files_path.exists()
191
+ and config.static_files_path.is_dir()
192
+ ):
193
+ # Map the root path to server info if there are no static files
194
+ app.get("/")(get_server_info)
195
+ return
196
+
197
+ # Mount static files directory
198
+ app.mount(
199
+ "/static",
200
+ StaticFiles(directory=str(config.static_files_path)),
201
+ name="static",
202
+ )
203
+
204
+ # Add root redirect to static files
205
+ @app.get("/", tags=["Server Details"])
206
+ async def root_redirect():
207
+ """Redirect root endpoint to static files directory."""
208
+ # Check if index.html exists in the static directory
209
+ # We know static_files_path is not None here due to the outer condition
210
+ assert config.static_files_path is not None
211
+ index_path = config.static_files_path / "index.html"
212
+ if index_path.exists():
213
+ return RedirectResponse(url="/static/index.html", status_code=302)
214
+ else:
215
+ return RedirectResponse(url="/static/", status_code=302)
216
+
217
+
218
+ def _add_exception_handlers(api: FastAPI) -> None:
219
+ """Add exception handlers to the FastAPI application."""
220
+
221
+ @api.exception_handler(Exception)
222
+ async def _unhandled_exception_handler(
223
+ request: Request, exc: Exception
224
+ ) -> JSONResponse:
225
+ """Handle unhandled exceptions."""
226
+ # Always log that we're in the exception handler for debugging
227
+ logger.debug(
228
+ "Exception handler called for %s %s with %s: %s",
229
+ request.method,
230
+ request.url.path,
231
+ type(exc).__name__,
232
+ str(exc),
233
+ )
234
+
235
+ content = {
236
+ "detail": "Internal Server Error",
237
+ "exception": str(exc),
238
+ }
239
+ # In DEBUG mode, include stack trace in response
240
+ if DEBUG:
241
+ content["traceback"] = traceback.format_exc()
242
+ # Check if this is an HTTPException that should be handled directly
243
+ if isinstance(exc, HTTPException):
244
+ return await _http_exception_handler(request, exc)
245
+
246
+ # Check if this is a BaseExceptionGroup with HTTPExceptions
247
+ if isinstance(exc, BaseExceptionGroup):
248
+ http_exc = _find_http_exception(exc)
249
+ if http_exc:
250
+ return await _http_exception_handler(request, http_exc)
251
+ # If no HTTPException found, treat as unhandled exception
252
+ logger.error(
253
+ "Unhandled ExceptionGroup on %s %s",
254
+ request.method,
255
+ request.url.path,
256
+ exc_info=(type(exc), exc, exc.__traceback__),
257
+ )
258
+ return JSONResponse(status_code=500, content=content)
259
+
260
+ # Logs full stack trace for any unhandled error that FastAPI would
261
+ # turn into a 500
262
+ logger.error(
263
+ "Unhandled exception on %s %s",
264
+ request.method,
265
+ request.url.path,
266
+ exc_info=(type(exc), exc, exc.__traceback__),
267
+ )
268
+ return JSONResponse(status_code=500, content=content)
269
+
270
+ @api.exception_handler(HTTPException)
271
+ async def _http_exception_handler(
272
+ request: Request, exc: HTTPException
273
+ ) -> JSONResponse:
274
+ """Handle HTTPExceptions with appropriate logging."""
275
+ # Log 4xx errors at info level (expected client errors like auth failures)
276
+ if 400 <= exc.status_code < 500:
277
+ logger.info(
278
+ "HTTPException %d on %s %s: %s",
279
+ exc.status_code,
280
+ request.method,
281
+ request.url.path,
282
+ exc.detail,
283
+ )
284
+ # Log 5xx errors at error level with full traceback (server errors)
285
+ elif exc.status_code >= 500:
286
+ logger.error(
287
+ "HTTPException %d on %s %s: %s",
288
+ exc.status_code,
289
+ request.method,
290
+ request.url.path,
291
+ exc.detail,
292
+ exc_info=(type(exc), exc, exc.__traceback__),
293
+ )
294
+ content = {
295
+ "detail": "Internal Server Error",
296
+ "exception": str(exc),
297
+ }
298
+ if DEBUG:
299
+ content["traceback"] = traceback.format_exc()
300
+ # Don't leak internal details to clients for 5xx errors in production
301
+ return JSONResponse(
302
+ status_code=exc.status_code,
303
+ content=content,
304
+ )
305
+
306
+ # Return clean JSON response for all non-5xx HTTP exceptions
307
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
308
+
309
+
310
+ def create_app(config: Config | None = None) -> FastAPI:
311
+ """Create and configure the FastAPI application.
312
+
313
+ Args:
314
+ config: Configuration object. If None, uses default config.
315
+
316
+ Returns:
317
+ Configured FastAPI application.
318
+ """
319
+ if config is None:
320
+ config = get_default_config()
321
+ app = _create_fastapi_instance()
322
+ _add_api_routes(app, config)
323
+ _setup_static_files(app, config)
324
+ app.add_middleware(LocalhostCORSMiddleware, allow_origins=config.allow_cors_origins)
325
+ _add_exception_handlers(app)
326
+
327
+ return app
328
+
329
+
330
+ # Create the default app instance
331
+ api = create_app()
@@ -0,0 +1,105 @@
1
+ """Bash router for OpenHands SDK."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Annotated, Literal, cast
6
+ from uuid import UUID
7
+
8
+ from fastapi import (
9
+ APIRouter,
10
+ HTTPException,
11
+ Query,
12
+ status,
13
+ )
14
+
15
+ from openhands.agent_server.bash_service import get_default_bash_event_service
16
+ from openhands.agent_server.models import (
17
+ BashCommand,
18
+ BashEventBase,
19
+ BashEventPage,
20
+ BashEventSortOrder,
21
+ BashOutput,
22
+ ExecuteBashRequest,
23
+ )
24
+
25
+
26
+ bash_router = APIRouter(prefix="/bash", tags=["Bash"])
27
+ bash_event_service = get_default_bash_event_service()
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # bash event routes
32
+ @bash_router.get("/bash_events/search")
33
+ async def search_bash_events(
34
+ kind__eq: Literal["BashCommand", "BashOutput"] | None = None,
35
+ command_id__eq: UUID | None = None,
36
+ timestamp__gte: datetime | None = None,
37
+ timestamp__lt: datetime | None = None,
38
+ sort_order: BashEventSortOrder = BashEventSortOrder.TIMESTAMP,
39
+ page_id: Annotated[
40
+ str | None,
41
+ Query(title="Optional next_page_id from the previously returned page"),
42
+ ] = None,
43
+ limit: Annotated[
44
+ int,
45
+ Query(title="The max number of results in the page", gt=0, lte=100),
46
+ ] = 100,
47
+ ) -> BashEventPage:
48
+ """Search / List bash event events"""
49
+ assert limit > 0
50
+ assert limit <= 100
51
+
52
+ return await bash_event_service.search_bash_events(
53
+ kind__eq=kind__eq,
54
+ command_id__eq=command_id__eq,
55
+ timestamp__gte=timestamp__gte,
56
+ timestamp__lt=timestamp__lt,
57
+ sort_order=sort_order,
58
+ page_id=page_id,
59
+ limit=limit,
60
+ )
61
+
62
+
63
+ @bash_router.get(
64
+ "/bash_events/{event_id}", responses={404: {"description": "Item not found"}}
65
+ )
66
+ async def get_bash_event(event_id: str) -> BashEventBase:
67
+ """Get a bash event event given an id"""
68
+ event = await bash_event_service.get_bash_event(event_id)
69
+ if event is None:
70
+ raise HTTPException(status.HTTP_404_NOT_FOUND)
71
+ return event
72
+
73
+
74
+ @bash_router.get("/bash_events/")
75
+ async def batch_get_bash_events(
76
+ event_ids: list[str],
77
+ ) -> list[BashEventBase | None]:
78
+ """Get a batch of bash event events given their ids, returning null for any
79
+ missing item."""
80
+ events = await bash_event_service.batch_get_bash_events(event_ids)
81
+ return events
82
+
83
+
84
+ @bash_router.post("/start_bash_command")
85
+ async def start_bash_command(request: ExecuteBashRequest) -> BashCommand:
86
+ """Execute a bash command in the background"""
87
+ command, _ = await bash_event_service.start_bash_command(request)
88
+ return command
89
+
90
+
91
+ @bash_router.post("/execute_bash_command")
92
+ async def execute_bash_command(request: ExecuteBashRequest) -> BashOutput:
93
+ """Execute a bash command and wait for a result"""
94
+ command, task = await bash_event_service.start_bash_command(request)
95
+ await task
96
+ page = await bash_event_service.search_bash_events(command_id__eq=command.id)
97
+ result = cast(BashOutput, page.items[-1])
98
+ return result
99
+
100
+
101
+ @bash_router.delete("/bash_events")
102
+ async def clear_all_bash_events() -> dict[str, int]:
103
+ """Clear all bash events from storage"""
104
+ count = await bash_event_service.clear_all_events()
105
+ return {"cleared_count": count}