loki-mode 6.45.1 → 6.50.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 (29) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/dashboard/__init__.py +1 -1
  4. package/docs/INSTALLATION.md +1 -1
  5. package/mcp/__init__.py +1 -1
  6. package/package.json +1 -1
  7. package/web-app/dist/assets/{Badge-ClncXa1x.js → Badge--0P706gJ.js} +1 -1
  8. package/web-app/dist/assets/{Button-CUOgrX10.js → Button-CiWrzR-b.js} +1 -1
  9. package/web-app/dist/assets/{Card-2UWDXe0P.js → Card-kK4qakmW.js} +1 -1
  10. package/web-app/dist/assets/{HomePage-C4WxoEKI.js → HomePage-8Iht82oS.js} +1 -1
  11. package/web-app/dist/assets/LoginPage-QKE0uBAy.js +1 -0
  12. package/web-app/dist/assets/NotFoundPage-CyiH17vK.js +1 -0
  13. package/web-app/dist/assets/ProjectPage-9CEnUXvW.css +32 -0
  14. package/web-app/dist/assets/ProjectPage-Bv_bjjwT.js +184 -0
  15. package/web-app/dist/assets/{ProjectsPage-CIDkRHyZ.js → ProjectsPage-DaB2tqOQ.js} +1 -1
  16. package/web-app/dist/assets/{SettingsPage-5AxjoTTg.js → SettingsPage-gZBenmpt.js} +1 -1
  17. package/web-app/dist/assets/{TemplatesPage-DPlaAtAk.js → TemplatesPage-BGHgxn_a.js} +1 -1
  18. package/web-app/dist/assets/TerminalOutput-C7sYzcHM.js +51 -0
  19. package/web-app/dist/assets/arrow-left-C56w2CVD.js +6 -0
  20. package/web-app/dist/assets/{clock-DpWpY1Zx.js → clock-CybOBArs.js} +1 -1
  21. package/web-app/dist/assets/{external-link-KmF9dPsz.js → external-link-DN8r7gOC.js} +1 -1
  22. package/web-app/dist/assets/index-UNfgZjJl.css +1 -0
  23. package/web-app/dist/assets/index-g4lAt51o.js +186 -0
  24. package/web-app/dist/index.html +2 -2
  25. package/web-app/server.py +1340 -47
  26. package/web-app/dist/assets/ProjectPage-Dy7ONtf_.js +0 -162
  27. package/web-app/dist/assets/TerminalOutput-Do2PhilR.js +0 -31
  28. package/web-app/dist/assets/index-ACgjqVp2.js +0 -136
  29. package/web-app/dist/assets/index-BZpAV5M8.css +0 -1
package/web-app/server.py CHANGED
@@ -9,7 +9,6 @@ Runs on port 57375 (dashboard uses 57374).
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
- import inspect
13
12
  import json
14
13
  import os
15
14
  import re
@@ -19,14 +18,27 @@ import sys
19
18
  import time
20
19
  import uuid
21
20
  from pathlib import Path
22
- from typing import Optional
21
+ from typing import Dict, Optional
23
22
 
24
- from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect
23
+ try:
24
+ import pexpect
25
+ HAS_PEXPECT = True
26
+ except ImportError:
27
+ HAS_PEXPECT = False
28
+
29
+ import logging
30
+ import threading
31
+
32
+ from datetime import datetime
33
+ from fastapi import Body, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
25
34
  from fastapi.middleware.cors import CORSMiddleware
26
- from fastapi.responses import FileResponse, JSONResponse
35
+ from fastapi.responses import FileResponse, JSONResponse, Response
36
+ from starlette.responses import StreamingResponse
27
37
  from fastapi.staticfiles import StaticFiles
28
38
  from pydantic import BaseModel
29
39
 
40
+ logger = logging.getLogger("purple-lab")
41
+
30
42
  # ---------------------------------------------------------------------------
31
43
  # Configuration
32
44
  # ---------------------------------------------------------------------------
@@ -143,6 +155,9 @@ def _kill_tracked_child_processes() -> None:
143
155
 
144
156
  session = SessionState()
145
157
 
158
+ # Terminal PTY instances keyed by session_id
159
+ _terminal_ptys: Dict[str, "pexpect.spawn"] = {}
160
+
146
161
  # Track PIDs of sessions started by Purple Lab (not by external loki CLI)
147
162
  _PURPLE_LAB_PIDS_FILE = SCRIPT_DIR.parent / ".loki" / "purple-lab" / "child-pids.json"
148
163
 
@@ -249,6 +264,490 @@ class SecretRequest(BaseModel):
249
264
  key: str
250
265
  value: str
251
266
 
267
+
268
+ class DevServerStartRequest(BaseModel):
269
+ command: Optional[str] = None
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # File Watcher (watchdog-based, broadcasts changes via WebSocket)
274
+ # ---------------------------------------------------------------------------
275
+
276
+ try:
277
+ from watchdog.observers import Observer
278
+ from watchdog.events import FileSystemEventHandler, FileSystemEvent
279
+ HAS_WATCHDOG = True
280
+ except ImportError:
281
+ HAS_WATCHDOG = False
282
+ Observer = None # type: ignore[misc,assignment]
283
+ FileSystemEventHandler = object # type: ignore[misc,assignment]
284
+
285
+ # Patterns to ignore when watching for file changes
286
+ _WATCH_IGNORE_DIRS = {".loki", "node_modules", ".git", "__pycache__", ".next", ".nuxt", "dist", "build", ".cache"}
287
+ _WATCH_IGNORE_EXTENSIONS = {".pyc", ".pyo", ".swp", ".swo", ".swn", ".tmp", ".DS_Store"}
288
+
289
+
290
+ class FileChangeHandler(FileSystemEventHandler): # type: ignore[misc]
291
+ """Collects file system events and broadcasts them after a debounce window."""
292
+
293
+ def __init__(self, project_dir: str, broadcast_fn, loop: asyncio.AbstractEventLoop):
294
+ super().__init__()
295
+ self.project_dir = project_dir
296
+ self.broadcast_fn = broadcast_fn
297
+ self.loop = loop
298
+ self._lock = threading.Lock()
299
+ self._pending: list[dict] = []
300
+ self._debounce_handle: Optional[asyncio.TimerHandle] = None
301
+
302
+ def _should_ignore(self, path: str) -> bool:
303
+ """Return True if this path should be ignored."""
304
+ parts = Path(path).parts
305
+ for part in parts:
306
+ if part in _WATCH_IGNORE_DIRS:
307
+ return True
308
+ name = os.path.basename(path)
309
+ if name in _WATCH_IGNORE_EXTENSIONS:
310
+ return True
311
+ _, ext = os.path.splitext(name)
312
+ if ext in _WATCH_IGNORE_EXTENSIONS:
313
+ return True
314
+ return False
315
+
316
+ def on_any_event(self, event) -> None: # type: ignore[override]
317
+ if event.is_directory and event.event_type not in ("created", "deleted"):
318
+ return
319
+ src = getattr(event, "src_path", "")
320
+ if not src or self._should_ignore(src):
321
+ return
322
+
323
+ with self._lock:
324
+ self._pending.append({
325
+ "path": src,
326
+ "event_type": event.event_type,
327
+ })
328
+
329
+ # Schedule debounced broadcast on the asyncio event loop
330
+ try:
331
+ self.loop.call_soon_threadsafe(self._schedule_broadcast)
332
+ except RuntimeError:
333
+ pass # loop closed
334
+
335
+ def _schedule_broadcast(self) -> None:
336
+ """Schedule the broadcast after 200ms of quiet."""
337
+ if self._debounce_handle is not None:
338
+ self._debounce_handle.cancel()
339
+ self._debounce_handle = self.loop.call_later(0.2, self._fire_broadcast)
340
+
341
+ def _fire_broadcast(self) -> None:
342
+ """Collect pending changes and broadcast them."""
343
+ with self._lock:
344
+ changes = self._pending[:]
345
+ self._pending.clear()
346
+ self._debounce_handle = None
347
+
348
+ if not changes:
349
+ return
350
+
351
+ # Deduplicate by path, keeping the last event type
352
+ seen: dict[str, str] = {}
353
+ for c in changes:
354
+ seen[c["path"]] = c["event_type"]
355
+
356
+ raw_paths = list(seen.keys())
357
+ event_types = [seen[p] for p in raw_paths]
358
+
359
+ # Strip project dir prefix to get relative paths for the frontend
360
+ prefix = self.project_dir.rstrip("/") + "/"
361
+ paths = [p[len(prefix):] if p.startswith(prefix) else p for p in raw_paths]
362
+
363
+ asyncio.ensure_future(self.broadcast_fn({
364
+ "type": "file_changed",
365
+ "data": {"paths": paths, "event_types": event_types},
366
+ }))
367
+
368
+
369
+ class FileWatcher:
370
+ """Manages watchdog observers for project directories."""
371
+
372
+ def __init__(self) -> None:
373
+ self._observers: Dict[str, "Observer"] = {} # type: ignore[type-arg]
374
+
375
+ def start(self, key: str, project_dir: str, broadcast_fn, loop: asyncio.AbstractEventLoop) -> bool:
376
+ """Start watching a project directory. Returns True if started."""
377
+ if not HAS_WATCHDOG:
378
+ logger.info("watchdog not installed -- file watcher disabled")
379
+ return False
380
+
381
+ if key in self._observers:
382
+ self.stop(key)
383
+
384
+ if not os.path.isdir(project_dir):
385
+ return False
386
+
387
+ handler = FileChangeHandler(project_dir, broadcast_fn, loop)
388
+ observer = Observer()
389
+ observer.schedule(handler, project_dir, recursive=True)
390
+ observer.daemon = True
391
+ observer.start()
392
+ self._observers[key] = observer
393
+ logger.info("File watcher started for %s", project_dir)
394
+ return True
395
+
396
+ def stop(self, key: str) -> None:
397
+ """Stop watching."""
398
+ observer = self._observers.pop(key, None)
399
+ if observer is not None:
400
+ observer.stop()
401
+ observer.join(timeout=3)
402
+ logger.info("File watcher stopped for key=%s", key)
403
+
404
+ def stop_all(self) -> None:
405
+ """Stop all watchers."""
406
+ for key in list(self._observers):
407
+ self.stop(key)
408
+
409
+
410
+ file_watcher = FileWatcher()
411
+
412
+
413
+ # ---------------------------------------------------------------------------
414
+ # Dev Server Manager
415
+ # ---------------------------------------------------------------------------
416
+
417
+
418
+ class DevServerManager:
419
+ """Manages dev server processes per session."""
420
+
421
+ _PORT_PATTERNS = [
422
+ # Vite: " Local: http://localhost:5173/"
423
+ re.compile(r"Local:\s+https?://localhost:(\d+)"),
424
+ # Next.js: " - Local: http://localhost:3000"
425
+ re.compile(r"-\s+Local:\s+https?://localhost:(\d+)"),
426
+ # Django: "Starting development server at http://127.0.0.1:8000/"
427
+ re.compile(r"server\s+at\s+https?://127\.0\.0\.1:(\d+)", re.IGNORECASE),
428
+ # Flask: " * Running on http://127.0.0.1:5000"
429
+ re.compile(r"Running\s+on\s+https?://127\.0\.0\.1:(\d+)"),
430
+ # Go: "Listening on :8080"
431
+ re.compile(r"(?:listening|serving)\s+on\s+:(\d+)", re.IGNORECASE),
432
+ # Generic "listening on port 3000" or "on port 3000"
433
+ re.compile(r"listening\s+on\s+(?:port\s+)?(\d+)", re.IGNORECASE),
434
+ re.compile(r"on\s+port\s+(\d+)", re.IGNORECASE),
435
+ # "port 3000" standalone
436
+ re.compile(r"port\s+(\d+)", re.IGNORECASE),
437
+ # Vite ready message: "ready in 300ms -- http://localhost:5173/"
438
+ re.compile(r"ready\s+in\s+\d+m?s.*localhost:(\d+)"),
439
+ # Generic URL patterns (last resort -- broad matches)
440
+ re.compile(r"https?://0\.0\.0\.0:(\d+)"),
441
+ re.compile(r"https?://127\.0\.0\.1:(\d+)"),
442
+ re.compile(r"https?://localhost:(\d+)"),
443
+ ]
444
+
445
+ def __init__(self) -> None:
446
+ self.servers: Dict[str, dict] = {}
447
+
448
+ async def detect_dev_command(self, project_dir: str) -> Optional[dict]:
449
+ """Detect the dev command from project files."""
450
+ root = Path(project_dir)
451
+ if not root.is_dir():
452
+ return None
453
+
454
+ pkg_json = root / "package.json"
455
+ if pkg_json.exists():
456
+ try:
457
+ pkg = json.loads(pkg_json.read_text())
458
+ scripts = pkg.get("scripts", {})
459
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
460
+ if "dev" in scripts:
461
+ port = 5173 if "vite" in deps else 3000
462
+ fw = "vite" if "vite" in deps else "next" if "next" in deps else "node"
463
+ return {"command": "npm run dev", "expected_port": port, "framework": fw}
464
+ if "start" in scripts:
465
+ fw = "next" if "next" in deps else "react" if "react" in deps else "node"
466
+ return {"command": "npm start", "expected_port": 3000, "framework": fw}
467
+ if "serve" in scripts:
468
+ return {"command": "npm run serve", "expected_port": 3000, "framework": "node"}
469
+ except (json.JSONDecodeError, OSError):
470
+ pass
471
+
472
+ makefile = root / "Makefile"
473
+ if makefile.exists():
474
+ try:
475
+ content = makefile.read_text()
476
+ for target in ("dev", "run", "serve"):
477
+ if re.search(rf"^{target}\s*:", content, re.MULTILINE):
478
+ return {"command": f"make {target}", "expected_port": 8000, "framework": "make"}
479
+ except OSError:
480
+ pass
481
+
482
+ if (root / "manage.py").exists():
483
+ return {"command": "python manage.py runserver", "expected_port": 8000, "framework": "django"}
484
+
485
+ for py_entry in ("app.py", "main.py", "server.py"):
486
+ py_file = root / py_entry
487
+ if py_file.exists():
488
+ try:
489
+ src = py_file.read_text(errors="replace")
490
+ if "fastapi" in src.lower() or "FastAPI" in src:
491
+ module = py_entry[:-3]
492
+ return {"command": f"uvicorn {module}:app --reload --port 8000",
493
+ "expected_port": 8000, "framework": "fastapi"}
494
+ if "flask" in src.lower() or "Flask" in src:
495
+ return {"command": "flask run --port 5000",
496
+ "expected_port": 5000, "framework": "flask"}
497
+ except OSError:
498
+ pass
499
+
500
+ if (root / "go.mod").exists():
501
+ return {"command": "go run .", "expected_port": 8080, "framework": "go"}
502
+ if (root / "Cargo.toml").exists():
503
+ return {"command": "cargo run", "expected_port": 8080, "framework": "rust"}
504
+
505
+ return None
506
+
507
+ def _parse_port(self, output: str) -> Optional[int]:
508
+ """Parse port from dev server stdout."""
509
+ for pattern in self._PORT_PATTERNS:
510
+ m = pattern.search(output)
511
+ if m:
512
+ port = int(m.group(1))
513
+ if 1024 <= port <= 65535:
514
+ return port
515
+ return None
516
+
517
+ async def start(self, session_id: str, project_dir: str, command: Optional[str] = None) -> dict:
518
+ """Start dev server. Auto-detect command if not provided."""
519
+ if session_id in self.servers:
520
+ await self.stop(session_id)
521
+
522
+ detected = await self.detect_dev_command(project_dir)
523
+ if not command and not detected:
524
+ return {"status": "error", "message": "No dev command detected. Provide one explicitly."}
525
+
526
+ cmd_str = command or (detected["command"] if detected else "")
527
+ expected_port = detected["expected_port"] if detected else 3000
528
+ framework = detected["framework"] if detected else "unknown"
529
+
530
+ build_env = {**os.environ}
531
+ build_env.update(_load_secrets())
532
+
533
+ try:
534
+ proc = subprocess.Popen(
535
+ cmd_str,
536
+ shell=True,
537
+ stdout=subprocess.PIPE,
538
+ stderr=subprocess.STDOUT,
539
+ stdin=subprocess.DEVNULL,
540
+ text=True,
541
+ cwd=project_dir,
542
+ env=build_env,
543
+ **({"start_new_session": True} if sys.platform != "win32"
544
+ else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
545
+ )
546
+ except Exception as e:
547
+ return {"status": "error", "message": f"Failed to start: {e}"}
548
+
549
+ server_info: dict = {
550
+ "process": proc,
551
+ "port": None,
552
+ "expected_port": expected_port,
553
+ "command": cmd_str,
554
+ "framework": framework,
555
+ "status": "starting",
556
+ "pid": proc.pid,
557
+ "project_dir": project_dir,
558
+ "output_lines": [],
559
+ }
560
+ self.servers[session_id] = server_info
561
+
562
+ asyncio.create_task(self._monitor_output(session_id))
563
+
564
+ # Wait for port detection (up to 30s)
565
+ for _ in range(60):
566
+ await asyncio.sleep(0.5)
567
+ info = self.servers.get(session_id)
568
+ if not info:
569
+ return {"status": "error", "message": "Server entry disappeared"}
570
+ if info["status"] == "error":
571
+ return {
572
+ "status": "error",
573
+ "message": "Dev server crashed",
574
+ "output": info["output_lines"][-10:] if info["output_lines"] else [],
575
+ }
576
+ if info["port"] is not None:
577
+ health_ok = await self._health_check(info["port"])
578
+ if health_ok:
579
+ info["status"] = "running"
580
+ return {
581
+ "status": "running",
582
+ "port": info["port"],
583
+ "command": cmd_str,
584
+ "pid": proc.pid,
585
+ "url": f"/proxy/{session_id}/",
586
+ }
587
+
588
+ if proc.poll() is not None:
589
+ server_info["status"] = "error"
590
+ return {
591
+ "status": "error",
592
+ "message": "Dev server exited before port was detected",
593
+ "output": server_info["output_lines"][-10:],
594
+ }
595
+
596
+ health_ok = await self._health_check(expected_port)
597
+ if health_ok:
598
+ server_info["port"] = expected_port
599
+ server_info["status"] = "running"
600
+ return {
601
+ "status": "running",
602
+ "port": expected_port,
603
+ "command": cmd_str,
604
+ "pid": proc.pid,
605
+ "url": f"/proxy/{session_id}/",
606
+ }
607
+
608
+ server_info["status"] = "starting"
609
+ server_info["port"] = expected_port
610
+ return {
611
+ "status": "starting",
612
+ "message": "Server started but port not yet confirmed",
613
+ "port": expected_port,
614
+ "command": cmd_str,
615
+ "pid": proc.pid,
616
+ "url": f"/proxy/{session_id}/",
617
+ }
618
+
619
+ async def _monitor_output(self, session_id: str) -> None:
620
+ """Background task: read dev server stdout and detect port."""
621
+ info = self.servers.get(session_id)
622
+ if not info:
623
+ return
624
+ proc = info["process"]
625
+ loop = asyncio.get_running_loop()
626
+ try:
627
+ while proc.poll() is None:
628
+ line = await loop.run_in_executor(None, proc.stdout.readline)
629
+ if not line:
630
+ break
631
+ text = line.rstrip("\n")
632
+ info["output_lines"].append(text)
633
+ if len(info["output_lines"]) > 200:
634
+ info["output_lines"] = info["output_lines"][-200:]
635
+ if info["port"] is None:
636
+ detected_port = self._parse_port(text)
637
+ if detected_port:
638
+ info["port"] = detected_port
639
+ except Exception:
640
+ pass
641
+ finally:
642
+ # Process exited -- mark as error if it was still starting or running
643
+ if info.get("status") in ("starting", "running"):
644
+ info["status"] = "error"
645
+
646
+ async def _health_check(self, port: int, retries: int = 3) -> bool:
647
+ """Check if a port is responding to TCP connections."""
648
+ import socket
649
+ loop = asyncio.get_running_loop()
650
+ for _ in range(retries):
651
+ try:
652
+ def check() -> bool:
653
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
654
+ s.settimeout(1)
655
+ try:
656
+ s.connect(("127.0.0.1", port))
657
+ return True
658
+ except (ConnectionRefusedError, OSError):
659
+ return False
660
+ finally:
661
+ s.close()
662
+ if await loop.run_in_executor(None, check):
663
+ return True
664
+ except Exception:
665
+ pass
666
+ await asyncio.sleep(0.5)
667
+ return False
668
+
669
+ async def stop(self, session_id: str) -> dict:
670
+ """Stop dev server for session."""
671
+ info = self.servers.pop(session_id, None)
672
+ if not info:
673
+ return {"stopped": False, "message": "No dev server running"}
674
+
675
+ proc = info["process"]
676
+ if proc.poll() is None:
677
+ if sys.platform != "win32":
678
+ try:
679
+ pgid = os.getpgid(proc.pid)
680
+ os.killpg(pgid, signal.SIGTERM)
681
+ except (ProcessLookupError, PermissionError, OSError):
682
+ try:
683
+ proc.terminate()
684
+ except Exception:
685
+ pass
686
+ else:
687
+ try:
688
+ proc.terminate()
689
+ except Exception:
690
+ pass
691
+ try:
692
+ proc.wait(timeout=5)
693
+ except subprocess.TimeoutExpired:
694
+ if sys.platform != "win32":
695
+ try:
696
+ pgid = os.getpgid(proc.pid)
697
+ os.killpg(pgid, signal.SIGKILL)
698
+ except (ProcessLookupError, PermissionError, OSError):
699
+ try:
700
+ proc.kill()
701
+ except Exception:
702
+ pass
703
+ else:
704
+ try:
705
+ proc.kill()
706
+ except Exception:
707
+ pass
708
+
709
+ return {"stopped": True, "message": "Dev server stopped"}
710
+
711
+ async def status(self, session_id: str) -> dict:
712
+ """Get dev server status."""
713
+ info = self.servers.get(session_id)
714
+ if not info:
715
+ return {
716
+ "running": False,
717
+ "status": "stopped",
718
+ "port": None,
719
+ "command": None,
720
+ "pid": None,
721
+ "url": None,
722
+ "framework": None,
723
+ "output": [],
724
+ }
725
+
726
+ proc = info["process"]
727
+ alive = proc.poll() is None
728
+ if not alive and info["status"] in ("running", "starting"):
729
+ info["status"] = "error"
730
+
731
+ return {
732
+ "running": alive and info["status"] == "running",
733
+ "status": info["status"],
734
+ "port": info.get("port"),
735
+ "command": info.get("command"),
736
+ "pid": proc.pid if alive else None,
737
+ "url": f"/proxy/{session_id}/" if info.get("port") and alive else None,
738
+ "framework": info.get("framework"),
739
+ "output": info.get("output_lines", [])[-20:],
740
+ }
741
+
742
+ async def stop_all(self) -> None:
743
+ """Stop all dev servers (used on shutdown)."""
744
+ for sid in list(self.servers.keys()):
745
+ await self.stop(sid)
746
+
747
+
748
+ dev_server_manager = DevServerManager()
749
+
750
+
252
751
  # ---------------------------------------------------------------------------
253
752
  # Helpers
254
753
  # ---------------------------------------------------------------------------
@@ -412,9 +911,35 @@ class ChatTask:
412
911
  self.complete = False
413
912
  self.returncode: int = -1
414
913
  self.files_changed: list[str] = []
914
+ self.process: Optional[subprocess.Popen] = None
915
+ self.cancelled = False
916
+ self.created_at: float = time.time()
415
917
 
416
918
 
417
919
  _chat_tasks: dict[str, ChatTask] = {}
920
+ _CHAT_TASK_MAX_AGE = 600 # Seconds to keep completed tasks before cleanup
921
+ _CHAT_TASK_MAX_COUNT = 100 # Max tasks to keep in memory
922
+
923
+
924
+ def _cleanup_chat_tasks() -> None:
925
+ """Remove completed tasks older than _CHAT_TASK_MAX_AGE, or oldest if over limit."""
926
+ now = time.time()
927
+ # Remove old completed tasks
928
+ expired = [
929
+ tid for tid, t in _chat_tasks.items()
930
+ if t.complete and (now - t.created_at) > _CHAT_TASK_MAX_AGE
931
+ ]
932
+ for tid in expired:
933
+ del _chat_tasks[tid]
934
+ # If still over limit, remove oldest completed tasks
935
+ if len(_chat_tasks) > _CHAT_TASK_MAX_COUNT:
936
+ completed = sorted(
937
+ [(tid, t) for tid, t in _chat_tasks.items() if t.complete],
938
+ key=lambda x: x[1].created_at,
939
+ )
940
+ while len(_chat_tasks) > _CHAT_TASK_MAX_COUNT and completed:
941
+ tid, _ = completed.pop(0)
942
+ del _chat_tasks[tid]
418
943
 
419
944
  # ---------------------------------------------------------------------------
420
945
  # API endpoints
@@ -507,6 +1032,14 @@ async def start_session(req: StartRequest) -> JSONResponse:
507
1032
  # Start background output reader
508
1033
  session._reader_task = asyncio.create_task(_read_process_output())
509
1034
 
1035
+ # Start file watcher for the project directory
1036
+ file_watcher.start(
1037
+ "session",
1038
+ project_dir,
1039
+ _broadcast,
1040
+ asyncio.get_running_loop(),
1041
+ )
1042
+
510
1043
  await _broadcast({"type": "session_start", "data": {
511
1044
  "provider": req.provider,
512
1045
  "projectDir": project_dir,
@@ -577,6 +1110,23 @@ async def stop_session() -> JSONResponse:
577
1110
  except Exception:
578
1111
  pass
579
1112
 
1113
+ # Stop file watcher
1114
+ file_watcher.stop("session")
1115
+
1116
+ # Stop dev server if running for this session
1117
+ # (The current active session uses "session" as its key for file_watcher
1118
+ # but dev servers are keyed by session_id from the URL. Stop all to be safe.)
1119
+ await dev_server_manager.stop_all()
1120
+
1121
+ # Clean up any terminal PTYs
1122
+ for sid, pty in list(_terminal_ptys.items()):
1123
+ try:
1124
+ if pty.isalive():
1125
+ pty.close(force=True)
1126
+ except Exception:
1127
+ pass
1128
+ _terminal_ptys.clear()
1129
+
580
1130
  # Kill any orphaned loki-run processes for this project
581
1131
  if session.project_dir:
582
1132
  await asyncio.get_running_loop().run_in_executor(
@@ -588,9 +1138,41 @@ async def stop_session() -> JSONResponse:
588
1138
  return JSONResponse(content={"stopped": True, "message": "Session stopped"})
589
1139
 
590
1140
 
1141
+ @app.on_event("startup")
1142
+ async def startup_event():
1143
+ """Initialize database if DATABASE_URL is configured."""
1144
+ try:
1145
+ from models import init_db
1146
+ db_url = os.environ.get("DATABASE_URL")
1147
+ if db_url:
1148
+ await init_db(db_url)
1149
+ logger.info("Database initialized")
1150
+ else:
1151
+ logger.info("No DATABASE_URL set -- running in local mode (no auth, file-based storage)")
1152
+ except ImportError:
1153
+ logger.info("Database models not available -- running in local mode")
1154
+ except Exception as exc:
1155
+ logger.warning("Database initialization failed: %s -- falling back to local mode", exc)
1156
+
1157
+
591
1158
  @app.on_event("shutdown")
592
1159
  async def shutdown_event():
593
- """Clean up any running session when Purple Lab shuts down."""
1160
+ """Clean up any running session, file watchers, and dev servers when Purple Lab shuts down."""
1161
+ # Stop all file watchers
1162
+ file_watcher.stop_all()
1163
+
1164
+ # Stop all dev servers
1165
+ await dev_server_manager.stop_all()
1166
+
1167
+ # Clean up all terminal PTYs
1168
+ for _sid, _pty in list(_terminal_ptys.items()):
1169
+ try:
1170
+ if _pty.isalive():
1171
+ _pty.close(force=True)
1172
+ except Exception:
1173
+ pass
1174
+ _terminal_ptys.clear()
1175
+
594
1176
  if not session.running or session.process is None:
595
1177
  return
596
1178
 
@@ -639,7 +1221,7 @@ async def shutdown_event():
639
1221
  # Kill any orphaned loki-run processes for this project
640
1222
  if project_dir:
641
1223
  await asyncio.get_running_loop().run_in_executor(
642
- None, _kill_orphan_loki_processes, project_dir
1224
+ None, _kill_tracked_child_processes
643
1225
  )
644
1226
 
645
1227
 
@@ -986,11 +1568,7 @@ async def plan_session(req: PlanRequest) -> JSONResponse:
986
1568
  pass
987
1569
 
988
1570
  # Try to parse structured JSON from output first (loki plan may emit JSON blocks)
989
- import json as _json
990
- import logging as _logging
991
- import re as _re
992
-
993
- _log = _logging.getLogger("purple-lab.plan")
1571
+ _log = logging.getLogger("purple-lab.plan")
994
1572
 
995
1573
  complexity = "standard"
996
1574
  cost_estimate = "unknown"
@@ -999,12 +1577,12 @@ async def plan_session(req: PlanRequest) -> JSONResponse:
999
1577
  parsed = False
1000
1578
 
1001
1579
  # Look for any JSON object containing plan-related keys (supports nested braces)
1002
- json_match = _re.search(r'\{[^{}]*"complexity"[^{}]*\}', output, _re.DOTALL)
1580
+ json_match = re.search(r'\{[^{}]*"complexity"[^{}]*\}', output, re.DOTALL)
1003
1581
  if not json_match:
1004
- json_match = _re.search(r'\{[^{}]*"iterations"[^{}]*\}', output, _re.DOTALL)
1582
+ json_match = re.search(r'\{[^{}]*"iterations"[^{}]*\}', output, re.DOTALL)
1005
1583
  if json_match:
1006
1584
  try:
1007
- data = _json.loads(json_match.group(0))
1585
+ data = json.loads(json_match.group(0))
1008
1586
  if isinstance(data.get("complexity"), dict):
1009
1587
  complexity = data["complexity"].get("tier", "standard")
1010
1588
  elif isinstance(data.get("complexity"), str):
@@ -1026,37 +1604,37 @@ async def plan_session(req: PlanRequest) -> JSONResponse:
1026
1604
  elif isinstance(data.get("phases"), list):
1027
1605
  phases = [p for p in data["phases"] if isinstance(p, str)]
1028
1606
  parsed = True
1029
- except (_json.JSONDecodeError, TypeError, KeyError) as exc:
1607
+ except (json.JSONDecodeError, TypeError, KeyError) as exc:
1030
1608
  _log.warning("JSON plan block found but failed to parse: %s", exc)
1031
1609
 
1032
1610
  # Fallback: line-by-line text parsing with tighter patterns
1033
1611
  if not parsed:
1034
1612
  _log.info("No JSON plan block found, falling back to text parsing")
1035
1613
  for line in output.splitlines():
1036
- stripped = _re.sub(r'\x1b\[[0-9;]*m', '', line) # strip ANSI codes
1614
+ stripped = re.sub(r'\x1b\[[0-9;]*m', '', line) # strip ANSI codes
1037
1615
  lower = stripped.lower().strip()
1038
1616
  if not lower:
1039
1617
  continue
1040
1618
  # Complexity detection: match "complexity: standard" or "Complexity Tier: complex" etc.
1041
- if _re.search(r'complexity\s*(?:tier)?\s*[:=]', lower):
1619
+ if re.search(r'complexity\s*(?:tier)?\s*[:=]', lower):
1042
1620
  for val in ("simple", "standard", "complex", "expert"):
1043
- if _re.search(rf'\b{val}\b', lower):
1621
+ if re.search(rf'\b{val}\b', lower):
1044
1622
  complexity = val
1045
1623
  break
1046
1624
  # Cost parsing: look for dollar amounts in cost/estimate lines
1047
1625
  if ("cost" in lower or "estimate" in lower) and "$" in stripped:
1048
- m = _re.search(r"\$[\d,]+\.?\d*", stripped)
1626
+ m = re.search(r"\$[\d,]+\.?\d*", stripped)
1049
1627
  if m:
1050
1628
  cost_estimate = m.group(0)
1051
1629
  # Iteration count
1052
- if _re.search(r'iterations?\s*[:=]\s*\d+', lower):
1053
- m = _re.search(r'iterations?\s*[:=]\s*(\d+)', lower)
1630
+ if re.search(r'iterations?\s*[:=]\s*\d+', lower):
1631
+ m = re.search(r'iterations?\s*[:=]\s*(\d+)', lower)
1054
1632
  if m:
1055
1633
  iterations = int(m.group(1))
1056
1634
  # Phase/step lines
1057
- if _re.match(r'^\s*(phase|step)\s+\d', lower):
1635
+ if re.match(r'^\s*(phase|step)\s+\d', lower):
1058
1636
  for phase_name in ("planning", "implementation", "testing", "review", "deployment"):
1059
- if _re.search(rf'\b{phase_name}\b', lower) and phase_name not in phases:
1637
+ if re.search(rf'\b{phase_name}\b', lower) and phase_name not in phases:
1060
1638
  phases.append(phase_name)
1061
1639
 
1062
1640
  if not parsed and not phases:
@@ -1093,7 +1671,6 @@ async def share_session() -> JSONResponse:
1093
1671
  None, lambda: _run_loki_cmd(["share"], timeout=60)
1094
1672
  )
1095
1673
  # Try to extract URL from output
1096
- import re
1097
1674
  url_match = re.search(r"https://gist\.github\.com/\S+", output)
1098
1675
  url = url_match.group(0) if url_match else ""
1099
1676
  return JSONResponse(content={
@@ -1163,7 +1740,6 @@ async def get_metrics() -> JSONResponse:
1163
1740
  except (json.JSONDecodeError, ValueError):
1164
1741
  pass
1165
1742
  # Fallback: parse key metrics from text output
1166
- import re
1167
1743
  metrics: dict = {
1168
1744
  "iterations": 0,
1169
1745
  "quality_gate_pass_rate": 0.0,
@@ -1314,7 +1890,6 @@ async def get_sessions_history() -> JSONResponse:
1314
1890
  @app.get("/api/sessions/{session_id}")
1315
1891
  async def get_session_detail(session_id: str) -> JSONResponse:
1316
1892
  """Get details of a past session for read-only viewing."""
1317
- import re
1318
1893
  # Validate session_id format (prevent path traversal)
1319
1894
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1320
1895
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
@@ -1376,7 +1951,6 @@ async def get_session_detail(session_id: str) -> JSONResponse:
1376
1951
  @app.get("/api/sessions/{session_id}/file")
1377
1952
  async def get_session_file(session_id: str, path: str = "") -> JSONResponse:
1378
1953
  """Get file content from a past session with path traversal protection."""
1379
- import re
1380
1954
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id) or not path:
1381
1955
  return JSONResponse(status_code=400, content={"error": "Invalid session ID or path"})
1382
1956
 
@@ -1418,7 +1992,6 @@ async def preview_session_file(session_id: str, file_path: str = "index.html") -
1418
1992
  This enables live preview of built projects -- HTML files can load their
1419
1993
  relative CSS, JS, and image assets correctly.
1420
1994
  """
1421
- import re
1422
1995
  import mimetypes
1423
1996
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1424
1997
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
@@ -1456,7 +2029,6 @@ async def preview_session_file(session_id: str, file_path: str = "index.html") -
1456
2029
  @app.put("/api/sessions/{session_id}/file")
1457
2030
  async def save_session_file(session_id: str, req: FileWriteRequest) -> JSONResponse:
1458
2031
  """Save or update file content in a session's project directory."""
1459
- import re
1460
2032
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1461
2033
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1462
2034
  if not req.path:
@@ -1504,7 +2076,6 @@ async def save_session_file(session_id: str, req: FileWriteRequest) -> JSONRespo
1504
2076
  @app.post("/api/sessions/{session_id}/file")
1505
2077
  async def create_session_file(session_id: str, req: FileWriteRequest) -> JSONResponse:
1506
2078
  """Create a new file in a session's project directory."""
1507
- import re
1508
2079
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1509
2080
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1510
2081
  if not req.path:
@@ -1545,7 +2116,6 @@ async def create_session_file(session_id: str, req: FileWriteRequest) -> JSONRes
1545
2116
  @app.delete("/api/sessions/{session_id}/file")
1546
2117
  async def delete_session_file(session_id: str, req: FileDeleteRequest) -> JSONResponse:
1547
2118
  """Delete a file from a session's project directory."""
1548
- import re
1549
2119
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1550
2120
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1551
2121
  if not req.path:
@@ -1588,7 +2158,6 @@ async def delete_session_file(session_id: str, req: FileDeleteRequest) -> JSONRe
1588
2158
  @app.post("/api/sessions/{session_id}/directory")
1589
2159
  async def create_session_directory(session_id: str, req: DirectoryCreateRequest) -> JSONResponse:
1590
2160
  """Create a directory in a session's project directory."""
1591
- import re
1592
2161
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1593
2162
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1594
2163
  if not req.path:
@@ -1677,26 +2246,79 @@ async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
1677
2246
  if target is None:
1678
2247
  return JSONResponse(status_code=404, content={"error": "Session not found"})
1679
2248
 
2249
+ # Clean up old completed tasks to prevent unbounded memory growth
2250
+ _cleanup_chat_tasks()
2251
+
2252
+ _cleanup_chat_tasks()
1680
2253
  task = ChatTask()
1681
2254
  _chat_tasks[task.id] = task
1682
2255
 
1683
2256
  async def run_chat() -> None:
1684
- loop = asyncio.get_running_loop()
2257
+ proc: Optional[subprocess.Popen] = None
2258
+ loki = _find_loki_cli()
2259
+ if loki is None:
2260
+ task.output_lines = ["loki CLI not found"]
2261
+ task.returncode = 1
2262
+ task.complete = True
2263
+ return
1685
2264
  if req.mode == "quick":
1686
- cmd_args = ["quick", req.message]
2265
+ cmd_args = [loki, "quick", req.message]
1687
2266
  else:
1688
- cmd_args = ["start", "--provider", "claude", str(target / "PRD.md")]
1689
- rc, output = await loop.run_in_executor(
1690
- None, lambda: _run_loki_cmd(cmd_args, cwd=str(target), timeout=300)
1691
- )
1692
- task.output_lines = output.splitlines()
1693
- task.returncode = rc
2267
+ cmd_args = [loki, "start", "--provider", "claude", str(target / "PRD.md")]
2268
+ try:
2269
+ proc = subprocess.Popen(
2270
+ cmd_args,
2271
+ stdout=subprocess.PIPE,
2272
+ stderr=subprocess.STDOUT,
2273
+ stdin=subprocess.DEVNULL,
2274
+ text=True,
2275
+ cwd=str(target),
2276
+ env={**os.environ},
2277
+ start_new_session=True,
2278
+ )
2279
+ task.process = proc
2280
+ loop = asyncio.get_running_loop()
2281
+
2282
+ def _read_lines() -> None:
2283
+ """Read stdout line-by-line in a thread."""
2284
+ assert proc.stdout is not None
2285
+ for raw_line in proc.stdout:
2286
+ if task.cancelled:
2287
+ break
2288
+ task.output_lines.append(raw_line.rstrip("\n"))
2289
+ proc.stdout.close()
2290
+
2291
+ await asyncio.wait_for(
2292
+ loop.run_in_executor(None, _read_lines),
2293
+ timeout=300,
2294
+ )
2295
+ proc.wait(timeout=10)
2296
+ task.returncode = proc.returncode
2297
+ except asyncio.TimeoutError:
2298
+ if proc is not None:
2299
+ try:
2300
+ pgid = os.getpgid(proc.pid)
2301
+ os.killpg(pgid, signal.SIGKILL)
2302
+ except (ProcessLookupError, OSError):
2303
+ proc.kill()
2304
+ proc.wait()
2305
+ task.output_lines.append("Command timed out after 5 minutes")
2306
+ task.returncode = 1
2307
+ except Exception as e:
2308
+ task.output_lines.append(str(e))
2309
+ task.returncode = 1
2310
+ if proc is not None and proc.poll() is None:
2311
+ try:
2312
+ pgid = os.getpgid(proc.pid)
2313
+ os.killpg(pgid, signal.SIGKILL)
2314
+ except (ProcessLookupError, OSError):
2315
+ proc.kill()
2316
+ proc.wait()
1694
2317
  # Detect changed files
1695
2318
  try:
1696
- import subprocess as _sp
1697
- result = _sp.run(
2319
+ result = subprocess.run(
1698
2320
  ["git", "diff", "--name-only", "HEAD~1"],
1699
- cwd=str(target), capture_output=True, text=True, timeout=10
2321
+ cwd=str(target), capture_output=True, text=True, timeout=10,
1700
2322
  )
1701
2323
  if result.returncode == 0:
1702
2324
  task.files_changed = [f for f in result.stdout.strip().splitlines() if f]
@@ -1728,10 +2350,88 @@ async def get_chat_status(session_id: str, task_id: str) -> JSONResponse:
1728
2350
  })
1729
2351
 
1730
2352
 
2353
+ @app.get("/api/sessions/{session_id}/chat/{task_id}/stream")
2354
+ async def stream_chat(session_id: str, task_id: str, request: Request) -> StreamingResponse:
2355
+ """Stream chat task output as Server-Sent Events.
2356
+
2357
+ Sends incremental output lines as they arrive, 10x faster than polling.
2358
+ Falls back gracefully -- the polling endpoint remains available.
2359
+ """
2360
+ task = _chat_tasks.get(task_id)
2361
+ _sse_headers = {
2362
+ "Cache-Control": "no-cache",
2363
+ "Connection": "keep-alive",
2364
+ "X-Accel-Buffering": "no",
2365
+ }
2366
+ if task is None:
2367
+ async def _not_found():
2368
+ yield f"event: error\ndata: {json.dumps({'error': 'Task not found'})}\n\n"
2369
+ return StreamingResponse(_not_found(), media_type="text/event-stream", headers=_sse_headers)
2370
+
2371
+ async def event_generator():
2372
+ last_line = 0
2373
+ while True:
2374
+ # Check if the client disconnected
2375
+ if await request.is_disconnected():
2376
+ break
2377
+
2378
+ # Send new lines since last check
2379
+ current_count = len(task.output_lines)
2380
+ if current_count > last_line:
2381
+ for line in task.output_lines[last_line:current_count]:
2382
+ yield f"event: output\ndata: {json.dumps({'line': line})}\n\n"
2383
+ last_line = current_count
2384
+
2385
+ # Check if complete -- flush any final lines first
2386
+ if task.complete:
2387
+ final_count = len(task.output_lines)
2388
+ if final_count > last_line:
2389
+ for line in task.output_lines[last_line:final_count]:
2390
+ yield f"event: output\ndata: {json.dumps({'line': line})}\n\n"
2391
+ yield f"event: complete\ndata: {json.dumps({'returncode': task.returncode, 'files_changed': task.files_changed})}\n\n"
2392
+ break
2393
+
2394
+ await asyncio.sleep(0.1) # 100ms -- 20x faster than 2s polling
2395
+
2396
+ return StreamingResponse(
2397
+ event_generator(),
2398
+ media_type="text/event-stream",
2399
+ headers=_sse_headers,
2400
+ )
2401
+
2402
+
2403
+ @app.post("/api/sessions/{session_id}/chat/{task_id}/cancel")
2404
+ async def cancel_chat(session_id: str, task_id: str) -> JSONResponse:
2405
+ """Cancel a running chat task."""
2406
+ task = _chat_tasks.get(task_id)
2407
+ if task is None:
2408
+ return JSONResponse(status_code=404, content={"error": "Task not found"})
2409
+ if task.complete:
2410
+ return JSONResponse(content={"cancelled": False, "reason": "Task already complete"})
2411
+ task.cancelled = True
2412
+ if task.process and task.process.poll() is None:
2413
+ try:
2414
+ pgid = os.getpgid(task.process.pid)
2415
+ os.killpg(pgid, signal.SIGTERM)
2416
+ task.process.wait(timeout=3)
2417
+ except (ProcessLookupError, OSError):
2418
+ pass
2419
+ if task.process.poll() is None:
2420
+ try:
2421
+ pgid = os.getpgid(task.process.pid)
2422
+ os.killpg(pgid, signal.SIGKILL)
2423
+ except (ProcessLookupError, OSError):
2424
+ task.process.kill()
2425
+ task.process.wait(timeout=5)
2426
+ task.output_lines.append("[cancelled by user]")
2427
+ task.returncode = 1
2428
+ task.complete = True
2429
+ return JSONResponse(content={"cancelled": True})
2430
+
2431
+
1731
2432
  @app.post("/api/sessions/{session_id}/review")
1732
2433
  async def review_session(session_id: str) -> JSONResponse:
1733
2434
  """Run loki review on a project."""
1734
- import re
1735
2435
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1736
2436
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1737
2437
  target = _find_session_dir(session_id)
@@ -1746,7 +2446,6 @@ async def review_session(session_id: str) -> JSONResponse:
1746
2446
  @app.post("/api/sessions/{session_id}/test")
1747
2447
  async def test_session(session_id: str) -> JSONResponse:
1748
2448
  """Run loki test on a project."""
1749
- import re
1750
2449
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1751
2450
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1752
2451
  target = _find_session_dir(session_id)
@@ -1761,7 +2460,6 @@ async def test_session(session_id: str) -> JSONResponse:
1761
2460
  @app.post("/api/sessions/{session_id}/explain")
1762
2461
  async def explain_session(session_id: str) -> JSONResponse:
1763
2462
  """Run loki explain on a project."""
1764
- import re
1765
2463
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1766
2464
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1767
2465
  target = _find_session_dir(session_id)
@@ -1776,7 +2474,6 @@ async def explain_session(session_id: str) -> JSONResponse:
1776
2474
  @app.post("/api/sessions/{session_id}/export")
1777
2475
  async def export_session(session_id: str) -> JSONResponse:
1778
2476
  """Run loki export json on a project."""
1779
- import re
1780
2477
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1781
2478
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1782
2479
  target = _find_session_dir(session_id)
@@ -1949,6 +2646,400 @@ async def get_preview_info(session_id: str) -> JSONResponse:
1949
2646
  return JSONResponse(content=info)
1950
2647
 
1951
2648
 
2649
+ # ---------------------------------------------------------------------------
2650
+ # Dev server management endpoints
2651
+ # ---------------------------------------------------------------------------
2652
+
2653
+
2654
+ @app.post("/api/sessions/{session_id}/devserver/start")
2655
+ async def start_devserver(session_id: str, req: DevServerStartRequest) -> JSONResponse:
2656
+ """Start a dev server for a session's project."""
2657
+ if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
2658
+ return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
2659
+ target = _find_session_dir(session_id)
2660
+ if target is None:
2661
+ return JSONResponse(status_code=404, content={"error": "Session not found"})
2662
+ result = await dev_server_manager.start(session_id, str(target), req.command)
2663
+ status_code = 200 if result.get("status") != "error" else 500
2664
+ return JSONResponse(content=result, status_code=status_code)
2665
+
2666
+
2667
+ @app.post("/api/sessions/{session_id}/devserver/stop")
2668
+ async def stop_devserver(session_id: str) -> JSONResponse:
2669
+ """Stop the dev server for a session."""
2670
+ if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
2671
+ return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
2672
+ result = await dev_server_manager.stop(session_id)
2673
+ return JSONResponse(content=result)
2674
+
2675
+
2676
+ @app.get("/api/sessions/{session_id}/devserver/status")
2677
+ async def get_devserver_status(session_id: str) -> JSONResponse:
2678
+ """Get dev server status for a session."""
2679
+ if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
2680
+ return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
2681
+ result = await dev_server_manager.status(session_id)
2682
+ return JSONResponse(content=result)
2683
+
2684
+
2685
+ # ---------------------------------------------------------------------------
2686
+ # HTTP Proxy for dev server preview
2687
+ # ---------------------------------------------------------------------------
2688
+
2689
+
2690
+ @app.api_route("/proxy/{session_id}/{path:path}",
2691
+ methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
2692
+ async def proxy_to_devserver(session_id: str, path: str, request: Request):
2693
+ """Proxy requests to the dev server running for this session.
2694
+
2695
+ Uses streaming to handle large responses without buffering them entirely
2696
+ in memory (important for JS bundles, images, etc.).
2697
+ """
2698
+ import httpx
2699
+
2700
+ server = dev_server_manager.servers.get(session_id)
2701
+ if not server or server["status"] != "running" or server.get("port") is None:
2702
+ return JSONResponse(
2703
+ {"error": "Dev server not running", "hint": "Start the dev server first"},
2704
+ status_code=503,
2705
+ )
2706
+
2707
+ target_port = server["port"]
2708
+ target_url = f"http://127.0.0.1:{target_port}/{path}"
2709
+ if request.url.query:
2710
+ target_url += f"?{request.url.query}"
2711
+
2712
+ # Build headers to forward (skip hop-by-hop headers)
2713
+ skip_headers = {"host", "connection", "keep-alive", "transfer-encoding",
2714
+ "te", "trailer", "upgrade"}
2715
+ fwd_headers = {
2716
+ k: v for k, v in request.headers.items()
2717
+ if k.lower() not in skip_headers
2718
+ }
2719
+ fwd_headers["host"] = f"127.0.0.1:{target_port}"
2720
+
2721
+ body = await request.body()
2722
+
2723
+ # Use a client that is NOT used as a context manager so we can stream
2724
+ # the response body without closing the connection prematurely.
2725
+ client = httpx.AsyncClient(timeout=60.0, follow_redirects=False)
2726
+ try:
2727
+ resp = await client.send(
2728
+ client.build_request(
2729
+ method=request.method,
2730
+ url=target_url,
2731
+ headers=fwd_headers,
2732
+ content=body if body else None,
2733
+ ),
2734
+ stream=True,
2735
+ )
2736
+ except httpx.ConnectError:
2737
+ await client.aclose()
2738
+ return JSONResponse(
2739
+ {"error": "Cannot connect to dev server", "port": target_port},
2740
+ status_code=502,
2741
+ )
2742
+ except httpx.TimeoutException:
2743
+ await client.aclose()
2744
+ return JSONResponse(
2745
+ {"error": "Dev server request timed out"},
2746
+ status_code=504,
2747
+ )
2748
+ except Exception as e:
2749
+ await client.aclose()
2750
+ return JSONResponse(
2751
+ {"error": f"Proxy error: {e}"},
2752
+ status_code=502,
2753
+ )
2754
+
2755
+ # Build response headers, passing through relevant ones
2756
+ resp_skip = {"transfer-encoding", "connection", "keep-alive", "content-encoding"}
2757
+ resp_headers = {
2758
+ k: v for k, v in resp.headers.items()
2759
+ if k.lower() not in resp_skip
2760
+ }
2761
+
2762
+ async def stream_body():
2763
+ try:
2764
+ async for chunk in resp.aiter_bytes(chunk_size=65536):
2765
+ yield chunk
2766
+ finally:
2767
+ await resp.aclose()
2768
+ await client.aclose()
2769
+
2770
+ return StreamingResponse(
2771
+ content=stream_body(),
2772
+ status_code=resp.status_code,
2773
+ headers=resp_headers,
2774
+ )
2775
+
2776
+
2777
+ @app.websocket("/proxy/{session_id}/{path:path}")
2778
+ async def proxy_websocket(websocket: WebSocket, session_id: str, path: str):
2779
+ """Proxy WebSocket connections for HMR (Vite, webpack, etc.).
2780
+
2781
+ Handles any WS path under /proxy/{session_id}/ so that Vite's
2782
+ /__vite_hmr, webpack's /ws, and other HMR paths all work.
2783
+ """
2784
+ import websockets
2785
+
2786
+ server = dev_server_manager.servers.get(session_id)
2787
+ if not server or server["status"] != "running" or server.get("port") is None:
2788
+ await websocket.close(code=1008, reason="Dev server not running")
2789
+ return
2790
+
2791
+ target_port = server["port"]
2792
+ # Forward the exact sub-path to the upstream dev server
2793
+ ws_url = f"ws://127.0.0.1:{target_port}/{path}"
2794
+
2795
+ await websocket.accept()
2796
+
2797
+ try:
2798
+ async with websockets.connect(ws_url) as upstream:
2799
+ async def client_to_upstream():
2800
+ try:
2801
+ while True:
2802
+ data = await websocket.receive_text()
2803
+ await upstream.send(data)
2804
+ except (WebSocketDisconnect, Exception):
2805
+ pass
2806
+
2807
+ async def upstream_to_client():
2808
+ try:
2809
+ async for msg in upstream:
2810
+ if isinstance(msg, str):
2811
+ await websocket.send_text(msg)
2812
+ elif isinstance(msg, bytes):
2813
+ await websocket.send_bytes(msg)
2814
+ except Exception:
2815
+ pass
2816
+
2817
+ await asyncio.gather(
2818
+ client_to_upstream(),
2819
+ upstream_to_client(),
2820
+ return_exceptions=True,
2821
+ )
2822
+ except Exception:
2823
+ pass
2824
+ finally:
2825
+ try:
2826
+ await websocket.close()
2827
+ except Exception:
2828
+ pass
2829
+
2830
+
2831
+ # ---------------------------------------------------------------------------
2832
+ # Authentication middleware
2833
+ # ---------------------------------------------------------------------------
2834
+
2835
+
2836
+ @app.middleware("http")
2837
+ async def auth_middleware(request: Request, call_next):
2838
+ """Enforce JWT auth when database is configured. Skip for public paths."""
2839
+ path = request.url.path
2840
+ skip_auth_prefixes = ["/health", "/api/auth/", "/ws", "/proxy/"]
2841
+ if any(path.startswith(p) for p in skip_auth_prefixes) or not path.startswith("/api/"):
2842
+ return await call_next(request)
2843
+
2844
+ # If no DB configured, skip auth (local mode)
2845
+ try:
2846
+ from models import async_session_factory
2847
+ if async_session_factory is None:
2848
+ return await call_next(request)
2849
+ except ImportError:
2850
+ return await call_next(request)
2851
+
2852
+ # Verify JWT
2853
+ auth_header = request.headers.get("Authorization")
2854
+ if not auth_header or not auth_header.startswith("Bearer "):
2855
+ return JSONResponse({"error": "Not authenticated"}, status_code=401)
2856
+
2857
+ try:
2858
+ from auth import verify_token
2859
+ except ImportError:
2860
+ # Auth module not installed but database IS configured -- block the request
2861
+ logger.warning("Auth module not available but database is configured -- blocking request")
2862
+ return JSONResponse({"error": "Server authentication misconfigured"}, status_code=500)
2863
+
2864
+ token = auth_header.split(" ", 1)[1]
2865
+ payload = verify_token(token)
2866
+ if not payload:
2867
+ return JSONResponse({"error": "Invalid or expired token"}, status_code=401)
2868
+ request.state.user = payload
2869
+
2870
+ return await call_next(request)
2871
+
2872
+
2873
+ # ---------------------------------------------------------------------------
2874
+ # Auth endpoints
2875
+ # ---------------------------------------------------------------------------
2876
+
2877
+
2878
+ @app.get("/api/auth/me")
2879
+ async def get_me(request: Request) -> JSONResponse:
2880
+ """Get current user info. Returns local_mode=True when no auth configured."""
2881
+ try:
2882
+ from models import async_session_factory
2883
+ if async_session_factory is None:
2884
+ return JSONResponse(content={"authenticated": False, "local_mode": True})
2885
+ except ImportError:
2886
+ return JSONResponse(content={"authenticated": False, "local_mode": True})
2887
+
2888
+ user = getattr(request.state, "user", None)
2889
+ if user is None:
2890
+ return JSONResponse(content={"authenticated": False, "local_mode": False})
2891
+ return JSONResponse(content={"authenticated": True, **user})
2892
+
2893
+
2894
+ @app.get("/api/auth/github/url")
2895
+ async def github_auth_url() -> JSONResponse:
2896
+ """Get GitHub OAuth authorization URL with CSRF state parameter."""
2897
+ try:
2898
+ from auth import GITHUB_CLIENT_ID, generate_oauth_state
2899
+ except ImportError:
2900
+ return JSONResponse(status_code=501, content={"error": "Auth module not available"})
2901
+ if not GITHUB_CLIENT_ID:
2902
+ return JSONResponse(status_code=501, content={"error": "GitHub OAuth not configured"})
2903
+ state = generate_oauth_state()
2904
+ url = f"https://github.com/login/oauth/authorize?client_id={GITHUB_CLIENT_ID}&scope=user:email&state={state}"
2905
+ return JSONResponse(content={"url": url})
2906
+
2907
+
2908
+ @app.post("/api/auth/github/callback")
2909
+ async def github_callback(body: dict = Body(...)) -> JSONResponse:
2910
+ """Handle GitHub OAuth callback -- exchange code for JWT."""
2911
+ try:
2912
+ from auth import create_access_token, github_oauth_callback, validate_oauth_state
2913
+ from models import async_session_factory, User
2914
+ except ImportError:
2915
+ return JSONResponse(status_code=501, content={"error": "Auth module not available"})
2916
+
2917
+ code = body.get("code")
2918
+ state = body.get("state")
2919
+ if not code:
2920
+ return JSONResponse(status_code=400, content={"error": "Missing code parameter"})
2921
+ if not validate_oauth_state(state):
2922
+ return JSONResponse(status_code=403, content={"error": "Invalid or expired OAuth state (CSRF check failed)"})
2923
+
2924
+ try:
2925
+ user_info = await github_oauth_callback(code)
2926
+ except HTTPException:
2927
+ raise
2928
+ except Exception as exc:
2929
+ logger.error("GitHub OAuth callback failed: %s", exc)
2930
+ return JSONResponse(status_code=502, content={"error": "GitHub authentication failed"})
2931
+
2932
+ # Create or update user in DB if database is configured
2933
+ if async_session_factory:
2934
+ from sqlalchemy import select
2935
+ async with async_session_factory() as db:
2936
+ result = await db.execute(
2937
+ select(User).where(User.email == user_info["email"])
2938
+ )
2939
+ db_user = result.scalar_one_or_none()
2940
+ if db_user is None:
2941
+ db_user = User(
2942
+ email=user_info["email"],
2943
+ name=user_info["name"],
2944
+ avatar_url=user_info.get("avatar_url"),
2945
+ provider="github",
2946
+ provider_id=user_info["provider_id"],
2947
+ )
2948
+ db.add(db_user)
2949
+ else:
2950
+ db_user.name = user_info["name"]
2951
+ db_user.avatar_url = user_info.get("avatar_url")
2952
+ db_user.last_login = datetime.utcnow()
2953
+ await db.commit()
2954
+
2955
+ token = create_access_token({
2956
+ "sub": user_info["email"],
2957
+ "name": user_info["name"],
2958
+ "avatar": user_info.get("avatar_url", ""),
2959
+ })
2960
+ return JSONResponse(content={"token": token, "user": user_info})
2961
+
2962
+
2963
+ @app.get("/api/auth/google/url")
2964
+ async def google_auth_url() -> JSONResponse:
2965
+ """Get Google OAuth authorization URL with CSRF state parameter."""
2966
+ try:
2967
+ from auth import GOOGLE_CLIENT_ID, generate_oauth_state
2968
+ except ImportError:
2969
+ return JSONResponse(status_code=501, content={"error": "Auth module not available"})
2970
+ if not GOOGLE_CLIENT_ID:
2971
+ return JSONResponse(status_code=501, content={"error": "Google OAuth not configured"})
2972
+ redirect_uri = os.environ.get("GOOGLE_REDIRECT_URI", f"http://localhost:{PORT}/api/auth/google/callback")
2973
+ state = generate_oauth_state()
2974
+ url = (
2975
+ f"https://accounts.google.com/o/oauth2/v2/auth"
2976
+ f"?client_id={GOOGLE_CLIENT_ID}"
2977
+ f"&redirect_uri={redirect_uri}"
2978
+ f"&response_type=code"
2979
+ f"&scope=openid%20email%20profile"
2980
+ f"&state={state}"
2981
+ )
2982
+ return JSONResponse(content={"url": url})
2983
+
2984
+
2985
+ @app.post("/api/auth/google/callback")
2986
+ async def google_callback(body: dict = Body(...)) -> JSONResponse:
2987
+ """Handle Google OAuth callback -- exchange code for JWT."""
2988
+ try:
2989
+ from auth import create_access_token, google_oauth_callback, validate_oauth_state
2990
+ from models import async_session_factory, User
2991
+ except ImportError:
2992
+ return JSONResponse(status_code=501, content={"error": "Auth module not available"})
2993
+
2994
+ code = body.get("code")
2995
+ state = body.get("state")
2996
+ if not code:
2997
+ return JSONResponse(status_code=400, content={"error": "Missing code parameter"})
2998
+ if not validate_oauth_state(state):
2999
+ return JSONResponse(status_code=403, content={"error": "Invalid or expired OAuth state (CSRF check failed)"})
3000
+
3001
+ # Use server-controlled redirect_uri -- never trust client-supplied value
3002
+ redirect_uri = os.environ.get("GOOGLE_REDIRECT_URI", f"http://localhost:{PORT}/api/auth/google/callback")
3003
+
3004
+ try:
3005
+ user_info = await google_oauth_callback(code, redirect_uri)
3006
+ except HTTPException:
3007
+ raise
3008
+ except Exception as exc:
3009
+ logger.error("Google OAuth callback failed: %s", exc)
3010
+ return JSONResponse(status_code=502, content={"error": "Google authentication failed"})
3011
+
3012
+ # Create or update user in DB if database is configured
3013
+ if async_session_factory:
3014
+ from sqlalchemy import select
3015
+ async with async_session_factory() as db:
3016
+ result = await db.execute(
3017
+ select(User).where(User.email == user_info["email"])
3018
+ )
3019
+ db_user = result.scalar_one_or_none()
3020
+ if db_user is None:
3021
+ db_user = User(
3022
+ email=user_info["email"],
3023
+ name=user_info["name"],
3024
+ avatar_url=user_info.get("avatar_url"),
3025
+ provider="google",
3026
+ provider_id=user_info["provider_id"],
3027
+ )
3028
+ db.add(db_user)
3029
+ else:
3030
+ db_user.name = user_info["name"]
3031
+ db_user.avatar_url = user_info.get("avatar_url")
3032
+ db_user.last_login = datetime.utcnow()
3033
+ await db.commit()
3034
+
3035
+ token = create_access_token({
3036
+ "sub": user_info["email"],
3037
+ "name": user_info["name"],
3038
+ "avatar": user_info.get("avatar_url", ""),
3039
+ })
3040
+ return JSONResponse(content={"token": token, "user": user_info})
3041
+
3042
+
1952
3043
  # ---------------------------------------------------------------------------
1953
3044
  # Health check
1954
3045
  # ---------------------------------------------------------------------------
@@ -2081,6 +3172,10 @@ async def websocket_endpoint(ws: WebSocket) -> None:
2081
3172
  "data": {"running": session.running, "provider": session.provider},
2082
3173
  }))
2083
3174
 
3175
+ # Ensure file watcher is running if session is active (handles page reloads)
3176
+ if session.running and session.project_dir and "session" not in file_watcher._observers:
3177
+ file_watcher.start("session", session.project_dir, _broadcast, asyncio.get_running_loop())
3178
+
2084
3179
  # Send recent log lines as backfill
2085
3180
  for line in session.log_lines[-100:]:
2086
3181
  await ws.send_text(json.dumps({
@@ -2126,6 +3221,204 @@ async def websocket_endpoint(ws: WebSocket) -> None:
2126
3221
  session.ws_clients.discard(ws)
2127
3222
 
2128
3223
 
3224
+ # ---------------------------------------------------------------------------
3225
+
3226
+ # ---------------------------------------------------------------------------
3227
+ # Terminal PTY WebSocket (interactive shell per session)
3228
+ # ---------------------------------------------------------------------------
3229
+
3230
+ # Track active WebSocket connections per session for multi-tab awareness
3231
+ _terminal_ws_clients: Dict[str, set] = {}
3232
+
3233
+
3234
+ @app.websocket("/ws/terminal/{session_id}")
3235
+ async def terminal_websocket(ws: WebSocket, session_id: str) -> None:
3236
+ """Interactive terminal via PTY. Requires pexpect.
3237
+
3238
+ Reuses an existing PTY if one is already alive for this session (e.g. on
3239
+ reconnect or second browser tab). Only kills the PTY when the *last*
3240
+ WebSocket client for this session disconnects.
3241
+ """
3242
+ await ws.accept()
3243
+
3244
+ if not HAS_PEXPECT:
3245
+ await ws.send_text(json.dumps({
3246
+ "type": "output",
3247
+ "data": "\r\n[Error] pexpect is not installed. "
3248
+ "Run: pip install pexpect\r\n",
3249
+ }))
3250
+ await ws.close()
3251
+ return
3252
+
3253
+ # ---- Reuse or spawn PTY ------------------------------------------------
3254
+ pty = _terminal_ptys.get(session_id)
3255
+ spawned_new = False
3256
+ if pty is None or not pty.isalive():
3257
+ # Determine working directory
3258
+ cwd = str(Path.home())
3259
+ session_dir = _find_session_dir(session_id)
3260
+ if session_dir and session_dir.is_dir():
3261
+ cwd = str(session_dir)
3262
+ elif session.project_dir and Path(session.project_dir).is_dir():
3263
+ cwd = session.project_dir
3264
+
3265
+ # Build environment with secrets injected
3266
+ env = os.environ.copy()
3267
+ env["TERM"] = "xterm-256color"
3268
+ try:
3269
+ secrets = _load_secrets()
3270
+ env.update(secrets)
3271
+ except Exception:
3272
+ pass
3273
+
3274
+ # Prefer user configured shell, fall back to /bin/bash
3275
+ user_shell = os.environ.get("SHELL", "/bin/bash")
3276
+ if not os.path.isfile(user_shell):
3277
+ user_shell = "/bin/bash"
3278
+
3279
+ try:
3280
+ pty = pexpect.spawn(
3281
+ user_shell,
3282
+ args=["--login"],
3283
+ encoding="utf-8",
3284
+ codec_errors="replace",
3285
+ cwd=cwd,
3286
+ env=env,
3287
+ dimensions=(24, 80),
3288
+ )
3289
+ pty.setecho(True)
3290
+ spawned_new = True
3291
+ except Exception as exc:
3292
+ await ws.send_text(json.dumps({
3293
+ "type": "output",
3294
+ "data": f"\r\n[Error] Failed to spawn terminal: {exc}\r\n",
3295
+ }))
3296
+ await ws.close()
3297
+ return
3298
+
3299
+ _terminal_ptys[session_id] = pty
3300
+
3301
+ # ---- Track this WebSocket client ----------------------------------------
3302
+ if session_id not in _terminal_ws_clients:
3303
+ _terminal_ws_clients[session_id] = set()
3304
+ ws_id = id(ws)
3305
+ _terminal_ws_clients[session_id].add(ws_id)
3306
+
3307
+ if not spawned_new:
3308
+ try:
3309
+ await ws.send_text(json.dumps({
3310
+ "type": "output",
3311
+ "data": "\r\n\x1b[32m-- Reconnected to existing terminal session --\x1b[0m\r\n",
3312
+ }))
3313
+ except Exception:
3314
+ pass
3315
+
3316
+ # ---- Background task: read PTY output and forward to WebSocket ----------
3317
+ async def read_pty_output() -> None:
3318
+ loop = asyncio.get_event_loop()
3319
+ while True:
3320
+ try:
3321
+ data = await loop.run_in_executor(None, _pty_read, pty)
3322
+ if data is None:
3323
+ # PTY closed / EOF
3324
+ try:
3325
+ await ws.send_text(json.dumps({
3326
+ "type": "output",
3327
+ "data": "\r\n[Terminal session ended]\r\n",
3328
+ }))
3329
+ except Exception:
3330
+ pass
3331
+ break
3332
+ if data:
3333
+ await ws.send_text(json.dumps({
3334
+ "type": "output",
3335
+ "data": data,
3336
+ }))
3337
+ except asyncio.CancelledError:
3338
+ break
3339
+ except Exception:
3340
+ break
3341
+
3342
+ reader_task = asyncio.create_task(read_pty_output())
3343
+
3344
+ try:
3345
+ while True:
3346
+ raw = await ws.receive_text()
3347
+ try:
3348
+ msg = json.loads(raw)
3349
+ except json.JSONDecodeError:
3350
+ continue
3351
+
3352
+ msg_type = msg.get("type", "")
3353
+
3354
+ if msg_type == "input":
3355
+ data = msg.get("data", "")
3356
+ if data and pty.isalive():
3357
+ pty.send(data)
3358
+
3359
+ elif msg_type == "resize":
3360
+ cols = msg.get("cols", 80)
3361
+ rows = msg.get("rows", 24)
3362
+ if pty.isalive():
3363
+ pty.setwinsize(rows, cols)
3364
+
3365
+ except WebSocketDisconnect:
3366
+ pass
3367
+ except Exception:
3368
+ pass
3369
+ finally:
3370
+ reader_task.cancel()
3371
+ try:
3372
+ await reader_task
3373
+ except (asyncio.CancelledError, Exception):
3374
+ pass
3375
+
3376
+ # Untrack this client
3377
+ clients = _terminal_ws_clients.get(session_id)
3378
+ if clients:
3379
+ clients.discard(ws_id)
3380
+
3381
+ # Only kill PTY when the last client disconnects
3382
+ remaining = _terminal_ws_clients.get(session_id)
3383
+ if not remaining:
3384
+ _terminal_ws_clients.pop(session_id, None)
3385
+ if pty.isalive():
3386
+ pty.close(force=True)
3387
+ _terminal_ptys.pop(session_id, None)
3388
+
3389
+
3390
+ def _pty_read(pty: "pexpect.spawn") -> Optional[str]:
3391
+ """Blocking read from PTY with batching.
3392
+
3393
+ Uses a longer timeout (0.5s) to avoid busy-looping when idle.
3394
+ When data IS available, greedily reads more to batch output
3395
+ (up to 32KB per batch to keep latency reasonable).
3396
+ Returns None on EOF.
3397
+ """
3398
+ try:
3399
+ data = pty.read_nonblocking(size=4096, timeout=0.5)
3400
+ # Greedily read more available data to batch output (e.g. cat large_file)
3401
+ total = len(data) if data else 0
3402
+ while total < 32768:
3403
+ try:
3404
+ more = pty.read_nonblocking(size=4096, timeout=0.01)
3405
+ if more:
3406
+ data += more
3407
+ total += len(more)
3408
+ else:
3409
+ break
3410
+ except (pexpect.TIMEOUT, pexpect.EOF, Exception):
3411
+ break
3412
+ return data
3413
+ except pexpect.TIMEOUT:
3414
+ return ""
3415
+ except pexpect.EOF:
3416
+ return None
3417
+ except Exception:
3418
+ return None
3419
+
3420
+
3421
+
2129
3422
  # ---------------------------------------------------------------------------
2130
3423
  # Static file serving (built React app)
2131
3424
  # ---------------------------------------------------------------------------