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,756 @@
1
+ """
2
+ Terminal Manager
3
+ ================
4
+
5
+ Manages PTY terminal sessions per project with cross-platform support.
6
+ Uses winpty (ConPTY) on Windows and built-in pty module on Unix.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import threading
15
+ import uuid
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Callable, Set
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class TerminalInfo:
26
+ """Metadata for a terminal instance."""
27
+
28
+ id: str
29
+ name: str
30
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
31
+
32
+
33
+ # Platform detection
34
+ IS_WINDOWS = platform.system() == "Windows"
35
+
36
+ # Conditional imports for PTY support
37
+ # Note: Type checking is disabled for cross-platform PTY modules since mypy
38
+ # cannot properly handle conditional imports for platform-specific APIs.
39
+ if IS_WINDOWS:
40
+ try:
41
+ from winpty import PtyProcess as WinPtyProcess
42
+
43
+ WINPTY_AVAILABLE = True
44
+ except ImportError:
45
+ WinPtyProcess = None
46
+ WINPTY_AVAILABLE = False
47
+ logger.warning(
48
+ "winpty package not installed. Terminal sessions will not be available on Windows. "
49
+ "Install with: pip install pywinpty"
50
+ )
51
+ else:
52
+ # Unix systems use built-in pty module
53
+ import fcntl
54
+ import pty
55
+ import select
56
+ import signal
57
+ import struct
58
+ import termios
59
+
60
+ WINPTY_AVAILABLE = False # Not applicable on Unix
61
+
62
+
63
+ def _get_shell() -> str:
64
+ """
65
+ Get the appropriate shell for the current platform.
66
+
67
+ Returns:
68
+ Path to shell executable
69
+ """
70
+ if IS_WINDOWS:
71
+ # Prefer PowerShell, fall back to cmd.exe
72
+ powershell = shutil.which("powershell.exe")
73
+ if powershell:
74
+ return powershell
75
+ cmd = shutil.which("cmd.exe")
76
+ if cmd:
77
+ return cmd
78
+ # Last resort fallback
79
+ return "cmd.exe"
80
+ else:
81
+ # Unix: Use $SHELL environment variable or fall back to /bin/bash
82
+ shell = os.environ.get("SHELL")
83
+ if shell and shutil.which(shell):
84
+ return shell
85
+ # Fall back to common shells
86
+ for fallback in ["/bin/bash", "/bin/sh"]:
87
+ if os.path.exists(fallback):
88
+ return fallback
89
+ return "/bin/sh"
90
+
91
+
92
+ class TerminalSession:
93
+ """
94
+ Manages a single PTY terminal session for a project.
95
+
96
+ Provides cross-platform PTY support with async output streaming
97
+ and multiple output callbacks for WebSocket clients.
98
+ """
99
+
100
+ def __init__(self, project_name: str, project_dir: Path):
101
+ """
102
+ Initialize the terminal session.
103
+
104
+ Args:
105
+ project_name: Name of the project
106
+ project_dir: Absolute path to the project directory (used as cwd)
107
+ """
108
+ self.project_name = project_name
109
+ self.project_dir = project_dir
110
+
111
+ # PTY process references (platform-specific)
112
+ self._pty_process: "WinPtyProcess | None" = None # Windows winpty
113
+ self._master_fd: int | None = None # Unix master file descriptor
114
+ self._child_pid: int | None = None # Unix child process PID
115
+
116
+ # State tracking
117
+ self._is_active = False
118
+ self._output_task: asyncio.Task | None = None
119
+
120
+ # Output callbacks with thread-safe access
121
+ self._output_callbacks: Set[Callable[[bytes], None]] = set()
122
+ self._callbacks_lock = threading.Lock()
123
+
124
+ @property
125
+ def is_active(self) -> bool:
126
+ """Check if the terminal session is currently active."""
127
+ return self._is_active
128
+
129
+ @property
130
+ def pid(self) -> int | None:
131
+ """Get the PID of the PTY child process."""
132
+ if IS_WINDOWS:
133
+ if self._pty_process is not None:
134
+ try:
135
+ pid = self._pty_process.pid
136
+ return int(pid) if pid is not None else None
137
+ except Exception:
138
+ return None
139
+ return None
140
+ else:
141
+ return self._child_pid
142
+
143
+ def add_output_callback(self, callback: Callable[[bytes], None]) -> None:
144
+ """
145
+ Add a callback to receive terminal output.
146
+
147
+ Args:
148
+ callback: Function that receives raw bytes from the PTY
149
+ """
150
+ with self._callbacks_lock:
151
+ self._output_callbacks.add(callback)
152
+
153
+ def remove_output_callback(self, callback: Callable[[bytes], None]) -> None:
154
+ """
155
+ Remove an output callback.
156
+
157
+ Args:
158
+ callback: The callback to remove
159
+ """
160
+ with self._callbacks_lock:
161
+ self._output_callbacks.discard(callback)
162
+
163
+ def _broadcast_output(self, data: bytes) -> None:
164
+ """Broadcast output data to all registered callbacks."""
165
+ with self._callbacks_lock:
166
+ callbacks = list(self._output_callbacks)
167
+
168
+ for callback in callbacks:
169
+ try:
170
+ callback(data)
171
+ except Exception as e:
172
+ logger.warning(f"Output callback error: {e}")
173
+
174
+ async def start(self, cols: int = 80, rows: int = 24) -> bool:
175
+ """
176
+ Start the PTY terminal session.
177
+
178
+ Args:
179
+ cols: Terminal width in columns
180
+ rows: Terminal height in rows
181
+
182
+ Returns:
183
+ True if started successfully, False otherwise
184
+ """
185
+ if self._is_active:
186
+ logger.warning(f"Terminal session already active for {self.project_name}")
187
+ return False
188
+
189
+ # Validate project directory
190
+ if not self.project_dir.exists():
191
+ logger.error(f"Project directory does not exist: {self.project_dir}")
192
+ return False
193
+ if not self.project_dir.is_dir():
194
+ logger.error(f"Project path is not a directory: {self.project_dir}")
195
+ return False
196
+
197
+ shell = _get_shell()
198
+ cwd = str(self.project_dir.resolve())
199
+
200
+ try:
201
+ if IS_WINDOWS:
202
+ return await self._start_windows(shell, cwd, cols, rows)
203
+ else:
204
+ return await self._start_unix(shell, cwd, cols, rows)
205
+ except Exception as e:
206
+ logger.exception(f"Failed to start terminal for {self.project_name}: {e}")
207
+ return False
208
+
209
+ async def _start_windows(self, shell: str, cwd: str, cols: int, rows: int) -> bool:
210
+ """Start PTY on Windows using winpty."""
211
+ if not WINPTY_AVAILABLE:
212
+ logger.error("Cannot start terminal: winpty package not available")
213
+ # This error will be caught and sent to the client
214
+ raise RuntimeError(
215
+ "Terminal requires pywinpty on Windows. Install with: pip install pywinpty"
216
+ )
217
+
218
+ try:
219
+ # WinPtyProcess.spawn expects the shell command
220
+ self._pty_process = WinPtyProcess.spawn(
221
+ shell,
222
+ cwd=cwd,
223
+ dimensions=(rows, cols),
224
+ )
225
+ self._is_active = True
226
+
227
+ # Start output reading task
228
+ self._output_task = asyncio.create_task(self._read_output_windows())
229
+
230
+ logger.info(f"Terminal started for {self.project_name} (PID: {self.pid}, shell: {shell})")
231
+ return True
232
+
233
+ except Exception as e:
234
+ logger.exception(f"Failed to start Windows PTY: {e}")
235
+ self._pty_process = None
236
+ return False
237
+
238
+ async def _start_unix(self, shell: str, cwd: str, cols: int, rows: int) -> bool:
239
+ """Start PTY on Unix using built-in pty module."""
240
+ # Note: This entire method uses Unix-specific APIs that don't exist on Windows.
241
+ # Type checking is disabled for these platform-specific calls.
242
+ try:
243
+ # Fork a new pseudo-terminal
244
+ pid, master_fd = pty.fork() # type: ignore[attr-defined]
245
+
246
+ if pid == 0:
247
+ # Child process - exec the shell
248
+ os.chdir(cwd)
249
+ # Set terminal size (Unix-specific modules imported at top-level)
250
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
251
+ fcntl.ioctl(0, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined]
252
+
253
+ # Execute the shell
254
+ os.execvp(shell, [shell])
255
+ os._exit(1) # Fallback if execvp returns (shouldn't happen)
256
+ else:
257
+ # Parent process
258
+ self._master_fd = master_fd
259
+ self._child_pid = pid
260
+ self._is_active = True
261
+
262
+ # Set terminal size on master (Unix-specific modules imported at top-level)
263
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
264
+ fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined]
265
+
266
+ # Start output reading task
267
+ self._output_task = asyncio.create_task(self._read_output_unix())
268
+
269
+ logger.info(f"Terminal started for {self.project_name} (PID: {pid}, shell: {shell})")
270
+ return True
271
+
272
+ except Exception as e:
273
+ logger.exception(f"Failed to start Unix PTY: {e}")
274
+ self._master_fd = None
275
+ self._child_pid = None
276
+ return False
277
+
278
+ async def _read_output_windows(self) -> None:
279
+ """Read output from Windows PTY and broadcast to callbacks."""
280
+ pty = self._pty_process
281
+ if pty is None:
282
+ return
283
+
284
+ loop = asyncio.get_running_loop()
285
+
286
+ def read_data():
287
+ """Read data from PTY, capturing pty reference to avoid race condition."""
288
+ try:
289
+ if pty.isalive():
290
+ return pty.read(4096)
291
+ except Exception:
292
+ pass
293
+ return b""
294
+
295
+ try:
296
+ while self._is_active and self._pty_process is not None:
297
+ try:
298
+ # Use run_in_executor for non-blocking read
299
+ # winpty read() is blocking, so we need to run it in executor
300
+ data = await loop.run_in_executor(None, read_data)
301
+
302
+ if data:
303
+ # winpty may return string, convert to bytes if needed
304
+ if isinstance(data, str):
305
+ data = data.encode("utf-8", errors="replace")
306
+ self._broadcast_output(data)
307
+ else:
308
+ # Check if process is still alive
309
+ if self._pty_process is None or not self._pty_process.isalive():
310
+ break
311
+ # Small delay to prevent busy loop
312
+ await asyncio.sleep(0.01)
313
+
314
+ except asyncio.CancelledError:
315
+ raise
316
+ except Exception as e:
317
+ if self._is_active:
318
+ logger.warning(f"Windows PTY read error: {e}")
319
+ break
320
+
321
+ except asyncio.CancelledError:
322
+ pass
323
+ finally:
324
+ if self._is_active:
325
+ self._is_active = False
326
+ logger.info(f"Terminal output stream ended for {self.project_name}")
327
+
328
+ async def _read_output_unix(self) -> None:
329
+ """Read output from Unix PTY and broadcast to callbacks."""
330
+ if self._master_fd is None:
331
+ return
332
+
333
+ loop = asyncio.get_running_loop()
334
+
335
+ try:
336
+ while self._is_active and self._master_fd is not None:
337
+ try:
338
+ # Use run_in_executor with select for non-blocking read
339
+ def read_with_select():
340
+ if self._master_fd is None:
341
+ return b""
342
+ try:
343
+ # Wait up to 100ms for data
344
+ readable, _, _ = select.select([self._master_fd], [], [], 0.1)
345
+ if readable:
346
+ return os.read(self._master_fd, 4096)
347
+ return b""
348
+ except (OSError, ValueError):
349
+ return b""
350
+
351
+ data = await loop.run_in_executor(None, read_with_select)
352
+
353
+ if data:
354
+ self._broadcast_output(data)
355
+ elif not self._check_child_alive():
356
+ break
357
+
358
+ except asyncio.CancelledError:
359
+ raise
360
+ except Exception as e:
361
+ if self._is_active:
362
+ logger.warning(f"Unix PTY read error: {e}")
363
+ break
364
+
365
+ except asyncio.CancelledError:
366
+ pass
367
+ finally:
368
+ if self._is_active:
369
+ self._is_active = False
370
+ logger.info(f"Terminal output stream ended for {self.project_name}")
371
+ # Reap zombie if not already reaped
372
+ if self._child_pid is not None:
373
+ try:
374
+ os.waitpid(self._child_pid, os.WNOHANG) # type: ignore[attr-defined] # Unix-only method, guarded by runtime platform selection
375
+ except ChildProcessError:
376
+ pass
377
+ except Exception:
378
+ pass
379
+
380
+ def _check_child_alive(self) -> bool:
381
+ """Check if the Unix child process is still alive."""
382
+ if self._child_pid is None:
383
+ return False
384
+ try:
385
+ # Use signal 0 to check if process exists without reaping it.
386
+ # This avoids race conditions with os.waitpid which can reap the process.
387
+ os.kill(self._child_pid, 0)
388
+ return True
389
+ except OSError:
390
+ return False
391
+
392
+ def write(self, data: bytes) -> None:
393
+ """
394
+ Write input data to the PTY.
395
+
396
+ Args:
397
+ data: Raw bytes to write to the terminal
398
+ """
399
+ if not self._is_active:
400
+ logger.warning(f"Cannot write to inactive terminal for {self.project_name}")
401
+ return
402
+
403
+ try:
404
+ if IS_WINDOWS:
405
+ if self._pty_process is not None:
406
+ # winpty expects string input
407
+ text = data.decode("utf-8", errors="replace")
408
+ self._pty_process.write(text)
409
+ else:
410
+ if self._master_fd is not None:
411
+ os.write(self._master_fd, data)
412
+ except Exception as e:
413
+ logger.warning(f"Failed to write to PTY: {e}")
414
+
415
+ def resize(self, cols: int, rows: int) -> None:
416
+ """
417
+ Resize the terminal.
418
+
419
+ Args:
420
+ cols: New terminal width in columns
421
+ rows: New terminal height in rows
422
+ """
423
+ if not self._is_active:
424
+ return
425
+
426
+ try:
427
+ if IS_WINDOWS:
428
+ if self._pty_process is not None:
429
+ self._pty_process.setwinsize(rows, cols)
430
+ else:
431
+ if self._master_fd is not None:
432
+ # Unix-specific modules imported at top-level
433
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
434
+ fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined]
435
+
436
+ logger.debug(f"Terminal resized for {self.project_name}: {cols}x{rows}")
437
+ except Exception as e:
438
+ logger.warning(f"Failed to resize terminal: {e}")
439
+
440
+ async def stop(self) -> None:
441
+ """Stop the terminal session and clean up resources."""
442
+ if not self._is_active:
443
+ return
444
+
445
+ self._is_active = False
446
+
447
+ # Cancel output reading task
448
+ if self._output_task is not None:
449
+ self._output_task.cancel()
450
+ try:
451
+ await self._output_task
452
+ except asyncio.CancelledError:
453
+ pass
454
+ self._output_task = None
455
+
456
+ try:
457
+ if IS_WINDOWS:
458
+ await self._stop_windows()
459
+ else:
460
+ await self._stop_unix()
461
+ except Exception as e:
462
+ logger.warning(f"Error stopping terminal: {e}")
463
+
464
+ logger.info(f"Terminal stopped for {self.project_name}")
465
+
466
+ async def _stop_windows(self) -> None:
467
+ """Stop Windows PTY process."""
468
+ if self._pty_process is None:
469
+ return
470
+
471
+ try:
472
+ if self._pty_process.isalive():
473
+ self._pty_process.terminate()
474
+ # Give it a moment to terminate
475
+ await asyncio.sleep(0.1)
476
+ if self._pty_process.isalive():
477
+ self._pty_process.kill()
478
+ except Exception as e:
479
+ logger.warning(f"Error terminating Windows PTY: {e}")
480
+ finally:
481
+ self._pty_process = None
482
+
483
+ async def _stop_unix(self) -> None:
484
+ """Stop Unix PTY process."""
485
+ # Note: This method uses Unix-specific signal handling (signal imported at top-level)
486
+
487
+ # Close master file descriptor
488
+ if self._master_fd is not None:
489
+ try:
490
+ os.close(self._master_fd)
491
+ except OSError:
492
+ pass
493
+ self._master_fd = None
494
+
495
+ # Terminate child process
496
+ if self._child_pid is not None:
497
+ try:
498
+ os.kill(self._child_pid, signal.SIGTERM)
499
+ # Wait briefly for graceful shutdown
500
+ await asyncio.sleep(0.1)
501
+ # Check if still running and force kill if needed
502
+ try:
503
+ os.kill(self._child_pid, 0) # Check if process exists
504
+ # SIGKILL is Unix-specific (Windows would use SIGTERM)
505
+ os.kill(self._child_pid, signal.SIGKILL) # type: ignore[attr-defined]
506
+ except ProcessLookupError:
507
+ pass # Already terminated
508
+ # Reap the child process to prevent zombie
509
+ try:
510
+ os.waitpid(self._child_pid, 0)
511
+ except ChildProcessError:
512
+ pass
513
+ except ProcessLookupError:
514
+ pass # Already terminated
515
+ except Exception as e:
516
+ logger.warning(f"Error terminating Unix PTY child: {e}")
517
+ finally:
518
+ self._child_pid = None
519
+
520
+
521
+ # Global registry of terminal sessions per project with thread safety
522
+ # Structure: Dict[project_name, Dict[terminal_id, TerminalSession]]
523
+ _sessions: dict[str, dict[str, TerminalSession]] = {}
524
+ _sessions_lock = threading.Lock()
525
+
526
+ # Terminal metadata registry (in-memory, resets on server restart)
527
+ # Structure: Dict[project_name, List[TerminalInfo]]
528
+ _terminal_metadata: dict[str, list[TerminalInfo]] = {}
529
+ _metadata_lock = threading.Lock()
530
+
531
+
532
+ def create_terminal(project_name: str, name: str | None = None) -> TerminalInfo:
533
+ """
534
+ Create a new terminal entry for a project.
535
+
536
+ Args:
537
+ project_name: Name of the project
538
+ name: Optional terminal name (auto-generated if not provided)
539
+
540
+ Returns:
541
+ TerminalInfo for the new terminal
542
+ """
543
+ with _metadata_lock:
544
+ if project_name not in _terminal_metadata:
545
+ _terminal_metadata[project_name] = []
546
+
547
+ terminals = _terminal_metadata[project_name]
548
+
549
+ # Auto-generate name if not provided
550
+ if name is None:
551
+ existing_nums = []
552
+ for t in terminals:
553
+ if t.name.startswith("Terminal "):
554
+ try:
555
+ num = int(t.name.replace("Terminal ", ""))
556
+ existing_nums.append(num)
557
+ except ValueError:
558
+ pass
559
+ next_num = max(existing_nums, default=0) + 1
560
+ name = f"Terminal {next_num}"
561
+
562
+ terminal_id = str(uuid.uuid4())[:8]
563
+ info = TerminalInfo(id=terminal_id, name=name)
564
+ terminals.append(info)
565
+
566
+ logger.info(f"Created terminal '{name}' (ID: {terminal_id}) for project {project_name}")
567
+ return info
568
+
569
+
570
+ def list_terminals(project_name: str) -> list[TerminalInfo]:
571
+ """
572
+ List all terminals for a project.
573
+
574
+ Args:
575
+ project_name: Name of the project
576
+
577
+ Returns:
578
+ List of TerminalInfo for the project
579
+ """
580
+ with _metadata_lock:
581
+ return list(_terminal_metadata.get(project_name, []))
582
+
583
+
584
+ def rename_terminal(project_name: str, terminal_id: str, new_name: str) -> bool:
585
+ """
586
+ Rename a terminal.
587
+
588
+ Args:
589
+ project_name: Name of the project
590
+ terminal_id: ID of the terminal to rename
591
+ new_name: New name for the terminal
592
+
593
+ Returns:
594
+ True if renamed successfully, False if terminal not found
595
+ """
596
+ with _metadata_lock:
597
+ terminals = _terminal_metadata.get(project_name, [])
598
+ for terminal in terminals:
599
+ if terminal.id == terminal_id:
600
+ old_name = terminal.name
601
+ terminal.name = new_name
602
+ logger.info(
603
+ f"Renamed terminal '{old_name}' to '{new_name}' "
604
+ f"(ID: {terminal_id}) for project {project_name}"
605
+ )
606
+ return True
607
+ return False
608
+
609
+
610
+ def delete_terminal(project_name: str, terminal_id: str) -> bool:
611
+ """
612
+ Delete a terminal and stop its session if active.
613
+
614
+ Args:
615
+ project_name: Name of the project
616
+ terminal_id: ID of the terminal to delete
617
+
618
+ Returns:
619
+ True if deleted, False if not found
620
+ """
621
+ # Remove from metadata
622
+ with _metadata_lock:
623
+ terminals = _terminal_metadata.get(project_name, [])
624
+ for i, terminal in enumerate(terminals):
625
+ if terminal.id == terminal_id:
626
+ terminals.pop(i)
627
+ logger.info(
628
+ f"Deleted terminal '{terminal.name}' (ID: {terminal_id}) "
629
+ f"for project {project_name}"
630
+ )
631
+ break
632
+ else:
633
+ return False
634
+
635
+ # Remove session if exists (will be stopped async by caller)
636
+ with _sessions_lock:
637
+ project_sessions = _sessions.get(project_name, {})
638
+ if terminal_id in project_sessions:
639
+ del project_sessions[terminal_id]
640
+
641
+ return True
642
+
643
+
644
+ def get_terminal_session(
645
+ project_name: str, project_dir: Path, terminal_id: str | None = None
646
+ ) -> TerminalSession:
647
+ """
648
+ Get or create a terminal session for a project (thread-safe).
649
+
650
+ Args:
651
+ project_name: Name of the project
652
+ project_dir: Absolute path to the project directory
653
+ terminal_id: ID of the terminal (creates default if not provided)
654
+
655
+ Returns:
656
+ TerminalSession instance for the project/terminal
657
+ """
658
+ # Ensure terminal metadata exists
659
+ if terminal_id is None:
660
+ # Create default terminal if none exists
661
+ terminals = list_terminals(project_name)
662
+ if not terminals:
663
+ info = create_terminal(project_name)
664
+ terminal_id = info.id
665
+ else:
666
+ terminal_id = terminals[0].id
667
+
668
+ with _sessions_lock:
669
+ if project_name not in _sessions:
670
+ _sessions[project_name] = {}
671
+
672
+ project_sessions = _sessions[project_name]
673
+ if terminal_id not in project_sessions:
674
+ project_sessions[terminal_id] = TerminalSession(project_name, project_dir)
675
+
676
+ return project_sessions[terminal_id]
677
+
678
+
679
+ def remove_terminal_session(project_name: str, terminal_id: str) -> TerminalSession | None:
680
+ """
681
+ Remove a terminal session from the registry.
682
+
683
+ Args:
684
+ project_name: Name of the project
685
+ terminal_id: ID of the terminal
686
+
687
+ Returns:
688
+ The removed session, or None if not found
689
+ """
690
+ with _sessions_lock:
691
+ project_sessions = _sessions.get(project_name, {})
692
+ return project_sessions.pop(terminal_id, None)
693
+
694
+
695
+ def get_terminal_info(project_name: str, terminal_id: str) -> TerminalInfo | None:
696
+ """
697
+ Get terminal info by ID.
698
+
699
+ Args:
700
+ project_name: Name of the project
701
+ terminal_id: ID of the terminal
702
+
703
+ Returns:
704
+ TerminalInfo if found, None otherwise
705
+ """
706
+ with _metadata_lock:
707
+ terminals = _terminal_metadata.get(project_name, [])
708
+ for terminal in terminals:
709
+ if terminal.id == terminal_id:
710
+ return terminal
711
+ return None
712
+
713
+
714
+ async def stop_terminal_session(project_name: str, terminal_id: str) -> bool:
715
+ """
716
+ Stop a specific terminal session.
717
+
718
+ Args:
719
+ project_name: Name of the project
720
+ terminal_id: ID of the terminal
721
+
722
+ Returns:
723
+ True if stopped, False if not found
724
+ """
725
+ session = remove_terminal_session(project_name, terminal_id)
726
+ if session and session.is_active:
727
+ await session.stop()
728
+ return True
729
+ return False
730
+
731
+
732
+ async def cleanup_all_terminals() -> None:
733
+ """
734
+ Stop all active terminal sessions.
735
+
736
+ Called on server shutdown to ensure all PTY processes are terminated.
737
+ """
738
+ with _sessions_lock:
739
+ all_sessions: list[TerminalSession] = []
740
+ for project_sessions in _sessions.values():
741
+ all_sessions.extend(project_sessions.values())
742
+
743
+ for session in all_sessions:
744
+ try:
745
+ if session.is_active:
746
+ await session.stop()
747
+ except Exception as e:
748
+ logger.warning(f"Error stopping terminal for {session.project_name}: {e}")
749
+
750
+ with _sessions_lock:
751
+ _sessions.clear()
752
+
753
+ with _metadata_lock:
754
+ _terminal_metadata.clear()
755
+
756
+ logger.info("All terminal sessions cleaned up")