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.
Files changed (84) hide show
  1. package/.claude/commands/check-code.md +32 -0
  2. package/.claude/commands/checkpoint.md +40 -0
  3. package/.claude/commands/create-spec.md +613 -0
  4. package/.claude/commands/expand-project.md +234 -0
  5. package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
  6. package/.claude/commands/review-pr.md +75 -0
  7. package/.claude/templates/app_spec.template.txt +331 -0
  8. package/.claude/templates/coding_prompt.template.md +265 -0
  9. package/.claude/templates/initializer_prompt.template.md +354 -0
  10. package/.claude/templates/testing_prompt.template.md +146 -0
  11. package/.env.example +64 -0
  12. package/LICENSE.md +676 -0
  13. package/README.md +423 -0
  14. package/agent.py +444 -0
  15. package/api/__init__.py +10 -0
  16. package/api/database.py +536 -0
  17. package/api/dependency_resolver.py +449 -0
  18. package/api/migration.py +156 -0
  19. package/auth.py +83 -0
  20. package/autoforge_paths.py +315 -0
  21. package/autonomous_agent_demo.py +293 -0
  22. package/bin/autoforge.js +3 -0
  23. package/client.py +607 -0
  24. package/env_constants.py +27 -0
  25. package/examples/OPTIMIZE_CONFIG.md +230 -0
  26. package/examples/README.md +531 -0
  27. package/examples/org_config.yaml +172 -0
  28. package/examples/project_allowed_commands.yaml +139 -0
  29. package/lib/cli.js +791 -0
  30. package/mcp_server/__init__.py +1 -0
  31. package/mcp_server/feature_mcp.py +988 -0
  32. package/package.json +53 -0
  33. package/parallel_orchestrator.py +1800 -0
  34. package/progress.py +247 -0
  35. package/prompts.py +427 -0
  36. package/pyproject.toml +17 -0
  37. package/rate_limit_utils.py +132 -0
  38. package/registry.py +614 -0
  39. package/requirements-prod.txt +14 -0
  40. package/security.py +959 -0
  41. package/server/__init__.py +17 -0
  42. package/server/main.py +261 -0
  43. package/server/routers/__init__.py +32 -0
  44. package/server/routers/agent.py +177 -0
  45. package/server/routers/assistant_chat.py +327 -0
  46. package/server/routers/devserver.py +309 -0
  47. package/server/routers/expand_project.py +239 -0
  48. package/server/routers/features.py +746 -0
  49. package/server/routers/filesystem.py +514 -0
  50. package/server/routers/projects.py +524 -0
  51. package/server/routers/schedules.py +356 -0
  52. package/server/routers/settings.py +127 -0
  53. package/server/routers/spec_creation.py +357 -0
  54. package/server/routers/terminal.py +453 -0
  55. package/server/schemas.py +593 -0
  56. package/server/services/__init__.py +36 -0
  57. package/server/services/assistant_chat_session.py +496 -0
  58. package/server/services/assistant_database.py +304 -0
  59. package/server/services/chat_constants.py +57 -0
  60. package/server/services/dev_server_manager.py +557 -0
  61. package/server/services/expand_chat_session.py +399 -0
  62. package/server/services/process_manager.py +657 -0
  63. package/server/services/project_config.py +475 -0
  64. package/server/services/scheduler_service.py +683 -0
  65. package/server/services/spec_chat_session.py +502 -0
  66. package/server/services/terminal_manager.py +756 -0
  67. package/server/utils/__init__.py +1 -0
  68. package/server/utils/process_utils.py +134 -0
  69. package/server/utils/project_helpers.py +32 -0
  70. package/server/utils/validation.py +54 -0
  71. package/server/websocket.py +903 -0
  72. package/start.py +456 -0
  73. package/ui/dist/assets/index-8W_wmZzz.js +168 -0
  74. package/ui/dist/assets/index-B47Ubhox.css +1 -0
  75. package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
  76. package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
  77. package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
  78. package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
  79. package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
  80. package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
  81. package/ui/dist/index.html +23 -0
  82. package/ui/dist/ollama.png +0 -0
  83. package/ui/dist/vite.svg +6 -0
  84. 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
+ )